Compare commits
65 Commits
0fab8b6d42
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d938953b | ||
|
|
97fc5f5f1d | ||
|
|
140d82c7f4 | ||
|
|
80a388cd7c | ||
|
|
c3bd0408f4 | ||
|
|
7618cfa6df | ||
|
|
0827147745 | ||
|
|
c5e96718a4 | ||
|
|
7febe90b36 | ||
|
|
c723d72508 | ||
|
|
bf6362d3ad | ||
|
|
9e901d7dfe | ||
|
|
0e727b2648 | ||
|
|
d6523deae5 | ||
|
|
5216abbcc0 | ||
|
|
4af5412c76 | ||
|
|
d381cba302 | ||
|
|
26212dec1c | ||
|
|
2876a8f80e | ||
|
|
93005af0a1 | ||
|
|
0b4e3f89d1 | ||
|
|
ec287c85ea | ||
|
|
7c05bd2346 | ||
|
|
9d639fbd64 | ||
|
|
0831968881 | ||
|
|
3aaad57076 | ||
|
|
01d553bfea | ||
|
|
a47698d5ca | ||
|
|
1b733c63d5 | ||
|
|
d43481716d | ||
|
|
8b3389725e | ||
|
|
5706792980 | ||
|
|
7b94537670 | ||
|
|
db1914d69b | ||
|
|
2c6889ad20 | ||
|
|
97a9fcf7d5 | ||
|
|
9b0d82da30 | ||
|
|
44e033c7ae | ||
|
|
8f3bcebbf6 | ||
|
|
2c27fc65d0 | ||
|
|
8f2b214c9f | ||
|
|
c4ff39394e | ||
|
|
c3b3e133a8 | ||
|
|
81d74ced0c | ||
|
|
467b513b67 | ||
|
|
c1038245e8 | ||
|
|
ee391a7b8d | ||
|
|
5ce35817a1 | ||
|
|
81580de2a5 | ||
|
|
2129e1c115 | ||
|
|
103309e0ff | ||
|
|
2dc837b0e9 | ||
|
|
214547362c | ||
|
|
a1090e0e2c | ||
|
|
72fe1d4079 | ||
|
|
e1120f617e | ||
|
|
236eb628d2 | ||
|
|
d88d77b082 | ||
|
|
c065b5ee17 | ||
|
|
bd423dbcc6 | ||
|
|
ee8902b843 | ||
|
|
ba0e789b80 | ||
|
|
d8fe9337bb | ||
|
|
ced136fb4d | ||
|
|
cac26e73ce |
63
.github/workflows/build-and-deploy.yaml
vendored
Normal file
63
.github/workflows/build-and-deploy.yaml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Update kustomization.yaml
|
||||
run: |
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
SHORT_SHA=${COMMIT_SHA:0:7}
|
||||
sed -i "s|newTag: .*|newTag: ${SHORT_SHA}|" k8s/kustomization.yaml
|
||||
|
||||
- name: Commit and push changes
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add k8s/kustomization.yaml
|
||||
git diff --quiet && git diff --staged --quiet || git commit -m "deploy: update image to ${SHORT_SHA} [skip ci]"
|
||||
git push
|
||||
135
ASSET_INVENTORY.md
Normal file
135
ASSET_INVENTORY.md
Normal 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** | - |
|
||||
39
Dockerfile
39
Dockerfile
@@ -1,27 +1,30 @@
|
||||
FROM node:22-alpine AS base
|
||||
# Multi-stage build for Next.js
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm install --prefer-offline --no-audit
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
# Production stage
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs || true
|
||||
RUN adduser --system --uid 1001 nextjs || true
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy necessary files from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
271
MIGRATION_GUIDE.md
Normal file
271
MIGRATION_GUIDE.md
Normal 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
|
||||
@@ -35,3 +35,7 @@ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
# CI/CD Test
|
||||
// Flux auto-deploy test - Sat Mar 7 10:55:48 AM EET 2026
|
||||
// Auto-deploy test: 2026-03-07T09:02:49Z
|
||||
// Auto-deploy test: 2026-03-07T10:33:23Z
|
||||
// Auto-deploy test 2: 2026-03-07T10:37:05Z
|
||||
|
||||
444
REDESIGN_SPECIFICATION.md
Normal file
444
REDESIGN_SPECIFICATION.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# ManoonOils Redesign Specification
|
||||
## Inspired by moumoujus.com Premium Skincare Aesthetic
|
||||
|
||||
---
|
||||
|
||||
## Design Analysis Summary
|
||||
|
||||
### Key Visual Elements from moumoujus.com:
|
||||
|
||||
1. **Hero Section**: Full-screen video background with autoplay, muted, loop
|
||||
2. **Navigation**: Minimalist sticky header with logo left, nav center, icons right
|
||||
3. **Typography**: Clean sans-serif, generous letter-spacing, all-caps for headings
|
||||
4. **Color Palette**:
|
||||
- White/Off-white backgrounds
|
||||
- Soft blue-gray accents (#e8f0f5 range)
|
||||
- Black for CTAs and text
|
||||
- Gold/bronze highlights for luxury feel
|
||||
5. **Product Pages**: Two-column layout, vertical thumbnails, expandable sections
|
||||
6. **Cart**: Slide-out drawer from right
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Global Design System & Theme
|
||||
|
||||
### Color Palette Refinement
|
||||
```
|
||||
Primary:
|
||||
- Background: #ffffff (pure white)
|
||||
- Background-alt: #f8f9fa (soft gray-white)
|
||||
- Text: #1a1a1a (near black)
|
||||
- Text-muted: #666666 (gray)
|
||||
|
||||
Accent:
|
||||
- Accent-blue: #e8f0f5 (soft blue-gray)
|
||||
- Accent-blue-dark: #a8c5d8
|
||||
- CTA-black: #000000
|
||||
- Gold: #c9a962 (for awards/accents)
|
||||
|
||||
UI:
|
||||
- Border: #e5e5e5
|
||||
- Border-dark: #d1d1d1
|
||||
```
|
||||
|
||||
### Typography System
|
||||
```
|
||||
Display Font: Inter or DM Sans (clean, modern)
|
||||
- H1: 48px/56px, font-weight: 500, letter-spacing: -0.02em
|
||||
- H2: 36px/44px, font-weight: 500
|
||||
- H3: 24px/32px, font-weight: 500
|
||||
- Body: 16px/24px
|
||||
- Small: 14px/20px
|
||||
- Caption: 12px/16px, uppercase, letter-spacing: 0.1em
|
||||
```
|
||||
|
||||
### Spacing System
|
||||
```
|
||||
- xs: 4px
|
||||
- sm: 8px
|
||||
- md: 16px
|
||||
- lg: 24px
|
||||
- xl: 32px
|
||||
- 2xl: 48px
|
||||
- 3xl: 64px
|
||||
- 4xl: 96px
|
||||
- 5xl: 128px
|
||||
```
|
||||
|
||||
### TODOs:
|
||||
- [ ] Update CSS variables in globals.css
|
||||
- [ ] Define new color tokens
|
||||
- [ ] Update font system (keep DM Sans, add Inter for UI)
|
||||
- [ ] Create design token file
|
||||
- [ ] Update Tailwind theme config
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Navigation & Header Redesign
|
||||
|
||||
### Header Layout (inspired by moumoujus.com)
|
||||
```
|
||||
[Logo] [Shop] [About] [Library] [Contact] [Account] [Cart (0)]
|
||||
```
|
||||
|
||||
### Specifications:
|
||||
- **Height**: 72px desktop, 64px mobile
|
||||
- **Background**: White with subtle bottom border (#e5e5e5)
|
||||
- **Position**: Sticky top-0 (not 10px offset like current)
|
||||
- **Logo**: Centered on mobile, left on desktop
|
||||
- **Nav Links**: Centered, uppercase, letter-spacing: 0.05em, font-size: 13px
|
||||
- **Icons**: User outline, Shopping bag outline
|
||||
- **Cart Badge**: Small dot or number in circle
|
||||
|
||||
### Mobile Menu:
|
||||
- Full-screen overlay
|
||||
- Large typography for nav links
|
||||
- Close button top right
|
||||
- Social links at bottom
|
||||
|
||||
### TODOs:
|
||||
- [ ] Redesign Header.tsx with new layout
|
||||
- [ ] Update MobileMenu.tsx with full-screen overlay
|
||||
- [ ] Implement sticky header behavior
|
||||
- [ ] Add scroll-based background change (transparent → white)
|
||||
- [ ] Update cart icon with new design
|
||||
- [ ] Add hover states for nav links (underline animation)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Homepage Hero with Video Background
|
||||
|
||||
### Hero Section Specifications:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [Video Background - Full Screen] │
|
||||
│ │
|
||||
│ │
|
||||
│ [Product Shot or Lifestyle Video] │
|
||||
│ │
|
||||
│ │
|
||||
│ [Brand Tagline] │
|
||||
│ PREMIUM ORGANIC OILS │
|
||||
│ │
|
||||
│ [Shop Now Button - Black] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Technical Requirements:
|
||||
- Video: MP4/WebM format, 1920x1080, <5MB
|
||||
- Autoplay, muted, loop, playsinline
|
||||
- Poster image for loading state
|
||||
- Gradient overlay for text readability
|
||||
- Text centered, white color
|
||||
- Scroll indicator at bottom
|
||||
|
||||
### TODOs:
|
||||
- [ ] Create new HeroVideo component
|
||||
- [ ] Add video asset (placeholder for now)
|
||||
- [ ] Implement video background with overlay
|
||||
- [ ] Add centered text content with animation
|
||||
- [ ] Create scroll-down indicator
|
||||
- [ ] Add poster image fallback
|
||||
- [ ] Ensure mobile fallback (image instead of video)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Product Detail Page Redesign
|
||||
|
||||
### Layout Structure (Two-Column):
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [Header - Sticky] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Home / [Product Name] │
|
||||
├──────────────────────┬──────────────────────────────┤
|
||||
│ │ │
|
||||
│ [Thumbnail 1] │ [Award Badge - optional] │
|
||||
│ [Thumbnail 2] │ │
|
||||
│ [Thumbnail 3] │ PRODUCT NAME │
|
||||
│ │ Short description │
|
||||
│ [Main Image] │ │
|
||||
│ [Large, centered] │ £XX.00 ★★★★★ (12) │
|
||||
│ │ │
|
||||
│ │ ────────────────────── │
|
||||
│ │ SIZE │
|
||||
│ │ [50ml] [100ml] [250ml] │
|
||||
│ │ ────────────────────── │
|
||||
│ │ │
|
||||
│ │ [ADD TO CART - FREE │
|
||||
│ │ SHIPPING - Black Button] │
|
||||
│ │ │
|
||||
│ │ ────────────────────── │
|
||||
│ │ BENEFITS │
|
||||
│ │ [Tag 1] [Tag 2] [Tag 3] │
|
||||
│ │ ────────────────────── │
|
||||
│ │ DESCRIPTION [+] │
|
||||
│ │ ────────────────────── │
|
||||
│ │ HOW TO USE [+] │
|
||||
│ │ ────────────────────── │
|
||||
│ │ INGREDIENTS [+] │
|
||||
│ │ │
|
||||
└──────────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Specifications:
|
||||
|
||||
#### Image Gallery:
|
||||
- Vertical thumbnail list on left (desktop)
|
||||
- Horizontal thumbnails below (mobile)
|
||||
- Click to change main image
|
||||
- Zoom on hover (optional)
|
||||
- Smooth transitions
|
||||
|
||||
#### Product Info:
|
||||
- Breadcrumb: Home / [Product Name]
|
||||
- Product name: 24-32px, font-weight: 500
|
||||
- Short description below name
|
||||
- Price + reviews on same line
|
||||
- Size selector: Pill buttons
|
||||
- CTA: Full-width black button
|
||||
|
||||
#### Expandable Sections:
|
||||
- Accordion style
|
||||
- Plus/minus icons
|
||||
- Smooth expand/collapse animation
|
||||
- Content: Description, How to Use, Ingredients
|
||||
|
||||
### TODOs:
|
||||
- [ ] Redesign ProductDetail.tsx with new two-column layout
|
||||
- [ ] Create ProductImageGallery component with vertical thumbnails
|
||||
- [ ] Add breadcrumb navigation
|
||||
- [ ] Create size selector component (pill buttons)
|
||||
- [ ] Implement expandable accordion sections
|
||||
- [ ] Add benefits/tags display
|
||||
- [ ] Style "Add to Cart" button (black, full-width)
|
||||
- [ ] Add star rating component
|
||||
- [ ] Make layout responsive
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Product Listing/Shop Page
|
||||
|
||||
### Layout:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [Header] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ All Products [Sort]
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ [Image] │ │ [Image] │ │ [Image] │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ Product │ │ Product │ │ Product │ │
|
||||
│ │ £XX.00 │ │ £XX.00 │ │ £XX.00 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ [Load More / Pagination] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Product Card Specifications:
|
||||
- Image: Square aspect ratio, object-cover
|
||||
- Product name: 14-16px, single line, truncate
|
||||
- Price: 14px, below name
|
||||
- Hover: Slight image zoom, shadow
|
||||
- Clean white background
|
||||
|
||||
### TODOs:
|
||||
- [ ] Redesign ProductCard.tsx
|
||||
- [ ] Create grid layout (3 columns desktop, 2 tablet, 1 mobile)
|
||||
- [ ] Add sorting dropdown
|
||||
- [ ] Implement hover effects
|
||||
- [ ] Add pagination or infinite scroll
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Cart Drawer & Checkout Flow
|
||||
|
||||
### Cart Drawer Design:
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ YOUR CART [X] │
|
||||
├──────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────┐ Product Name 🗑️ │
|
||||
│ │IMG │ Variant info │
|
||||
│ └────┤ [-] 1 [+] £XX.00 │
|
||||
│ │
|
||||
│ ─────────────────────────────── │
|
||||
│ │
|
||||
│ ┌────┐ Another Product │
|
||||
│ │IMG │ [-] 2 [+] £XX.00 │
|
||||
│ └────┘ │
|
||||
│ │
|
||||
├──────────────────────────────────┤
|
||||
│ Subtotal £XX.00 │
|
||||
│ Shipping FREE │
|
||||
├──────────────────────────────────┤
|
||||
│ TOTAL £XX.00 │
|
||||
│ │
|
||||
│ [CHECKOUT - Black Button] │
|
||||
│ [Continue Shopping] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Specifications:
|
||||
- Slide in from right
|
||||
- Width: 400px desktop, 100% mobile
|
||||
- Backdrop blur/overlay
|
||||
- Quantity controls (+/-)
|
||||
- Remove item button
|
||||
- Clear subtotal/total breakdown
|
||||
- Prominent checkout CTA
|
||||
|
||||
### Checkout Page:
|
||||
- Multi-step or single-page
|
||||
- Shipping info
|
||||
- Payment method (COD for Serbia)
|
||||
- Order summary sidebar
|
||||
|
||||
### TODOs:
|
||||
- [ ] Redesign CartDrawer.tsx with slide-out design
|
||||
- [ ] Update cart item layout
|
||||
- [ ] Add quantity stepper controls
|
||||
- [ ] Style cart totals section
|
||||
- [ ] Improve checkout button
|
||||
- [ ] Add backdrop overlay
|
||||
- [ ] Add empty cart state
|
||||
- [ ] Test checkout flow end-to-end
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Footer & Trust Signals
|
||||
|
||||
### Footer Layout:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [NEWSLETTER SECTION] │
|
||||
│ Stay updated with our latest offers │
|
||||
│ [Email Input] [Subscribe] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ SHOP ABOUT HELP SOCIAL │
|
||||
│ - Products - Our Story - FAQ - IG │
|
||||
│ - Bundles - Process - Shipping - FB │
|
||||
│ - Gifts - Sourcing - Returns - X │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Payment Icons] [Security Badges] │
|
||||
│ │
|
||||
│ © 2024 ManoonOils. All rights reserved. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Trust Signals to Add:
|
||||
- Payment icons (Visa, Mastercard, PayPal)
|
||||
- Security badges (SSL, Secure checkout)
|
||||
- Shipping info
|
||||
- Money-back guarantee
|
||||
|
||||
### TODOs:
|
||||
- [ ] Redesign Footer.tsx
|
||||
- [ ] Add newsletter signup section
|
||||
- [ ] Create link columns
|
||||
- [ ] Add payment/security badges
|
||||
- [ ] Add social media links
|
||||
- [ ] Style copyright section
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Mobile Responsive Optimization
|
||||
|
||||
### Breakpoints:
|
||||
- Mobile: < 640px
|
||||
- Tablet: 640px - 1024px
|
||||
- Desktop: > 1024px
|
||||
|
||||
### Mobile-Specific Changes:
|
||||
- Hamburger menu with full-screen overlay
|
||||
- Single column product pages
|
||||
- Bottom sticky add-to-cart bar
|
||||
- Simplified navigation
|
||||
- Touch-friendly tap targets (min 44px)
|
||||
|
||||
### TODOs:
|
||||
- [ ] Test all pages on mobile viewport
|
||||
- [ ] Add bottom sticky CTA on product pages
|
||||
- [ ] Optimize images for mobile
|
||||
- [ ] Ensure touch targets are 44px+
|
||||
- [ ] Test mobile navigation flow
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Performance & SEO Polish
|
||||
|
||||
### Performance:
|
||||
- Lazy load images
|
||||
- Video optimization (WebM + MP4)
|
||||
- Font preloading
|
||||
- CSS optimization
|
||||
|
||||
### SEO:
|
||||
- Meta titles/descriptions
|
||||
- Structured data (Product schema)
|
||||
- Open Graph tags
|
||||
- Alt text for images
|
||||
|
||||
### TODOs:
|
||||
- [ ] Add Next.js Image optimization
|
||||
- [ ] Implement lazy loading
|
||||
- [ ] Add meta tags for all pages
|
||||
- [ ] Add JSON-LD structured data
|
||||
- [ ] Optimize Core Web Vitals
|
||||
- [ ] Add sitemap.xml
|
||||
|
||||
---
|
||||
|
||||
## Asset Requirements
|
||||
|
||||
### Images Needed:
|
||||
1. Hero video (MP4/WebM, 1920x1080)
|
||||
2. Hero poster image (fallback)
|
||||
3. Product photography (high-res, consistent style)
|
||||
4. Lifestyle images for homepage sections
|
||||
|
||||
### Icons (Lucide):
|
||||
- All current icons are good
|
||||
- May need: Award, Leaf, Droplet (for benefits)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Week 1: Foundation
|
||||
1. Phase 1: Design System
|
||||
2. Phase 2: Navigation
|
||||
|
||||
### Week 2: Core Pages
|
||||
3. Phase 3: Hero Video
|
||||
4. Phase 4: Product Detail Page
|
||||
|
||||
### Week 3: E-commerce
|
||||
5. Phase 5: Shop Page
|
||||
6. Phase 6: Cart & Checkout
|
||||
|
||||
### Week 4: Polish
|
||||
7. Phase 7: Footer
|
||||
8. Phase 8: Mobile
|
||||
9. Phase 9: Performance
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] Homepage video loads < 3s
|
||||
- [ ] Product page LCP < 2.5s
|
||||
- [ ] Mobile score 90+ on Lighthouse
|
||||
- [ ] All pages responsive
|
||||
- [ ] Cart drawer works smoothly
|
||||
- [ ] No console errors
|
||||
- [ ] WCAG AA accessibility compliance
|
||||
528
SALEOR_MIGRATION_PLAN.md
Normal file
528
SALEOR_MIGRATION_PLAN.md
Normal 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
|
||||
```
|
||||
460
ecommerce-features-checklist.md
Normal file
460
ecommerce-features-checklist.md
Normal 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
302
infrastructure-overview.md
Normal 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
|
||||
@@ -1,72 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: storefront
|
||||
namespace: manoonoils
|
||||
labels:
|
||||
app: storefront
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: storefront
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: storefront
|
||||
spec:
|
||||
containers:
|
||||
- name: storefront
|
||||
image: node:22-alpine
|
||||
workingDir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
if [ ! -d ".git" ]; then
|
||||
echo "Cloning repository..."
|
||||
apk add --no-cache git openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -p 222 100.74.155.73 >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
GIT_SSH_COMMAND='ssh -p 222 -o StrictHostKeyChecking=accept-new' git clone ssh://git@100.74.155.73:222/unchained/manoon-headless.git /app
|
||||
else
|
||||
echo "Pulling latest changes..."
|
||||
git pull
|
||||
fi
|
||||
|
||||
echo "Installing dependencies..."
|
||||
npm ci --legacy-peer-deps
|
||||
|
||||
echo "Building..."
|
||||
npm run build
|
||||
|
||||
echo "Starting..."
|
||||
npm start
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: HOSTNAME
|
||||
value: "0.0.0.0"
|
||||
- name: NEXT_PUBLIC_WOOCOMMERCE_URL
|
||||
value: "https://manoonoils.com"
|
||||
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY
|
||||
value: "ck_6a62a2ac8fa8d50e4757bf3b35c9d052dbbcf09f"
|
||||
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET
|
||||
value: "cs_0ea41d2c8fc232d1e609e559ea8561d02c4406ee"
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
@@ -1,100 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: storefront
|
||||
namespace: manoonoils
|
||||
labels:
|
||||
app: storefront
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: storefront
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: storefront
|
||||
spec:
|
||||
initContainers:
|
||||
- name: build
|
||||
image: node:22-alpine
|
||||
workingDir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Installing dependencies..."
|
||||
npm ci --legacy-peer-deps
|
||||
echo "Building Next.js app..."
|
||||
npm run build
|
||||
echo "Build complete!"
|
||||
env:
|
||||
- name: NEXT_PUBLIC_WOOCOMMERCE_URL
|
||||
value: "https://manoonoils.com"
|
||||
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY
|
||||
value: "ck_6a62a2ac8fa8d50e4757bf3b35c9d052dbbcf09f"
|
||||
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET
|
||||
value: "cs_0ea41d2c8fc232d1e609e559ea8561d02c4406ee"
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
containers:
|
||||
- name: storefront
|
||||
image: node:22-alpine
|
||||
workingDir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Starting Next.js..."
|
||||
npm start
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: HOSTNAME
|
||||
value: "0.0.0.0"
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
periodSeconds: 5
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
volumes:
|
||||
- name: app-code
|
||||
emptyDir: {}
|
||||
@@ -3,8 +3,6 @@ kind: Deployment
|
||||
metadata:
|
||||
name: storefront
|
||||
namespace: manoonoils
|
||||
labels:
|
||||
app: storefront
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
@@ -15,10 +13,88 @@ spec:
|
||||
labels:
|
||||
app: storefront
|
||||
spec:
|
||||
initContainers:
|
||||
- name: clone
|
||||
image: alpine/git:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch master \
|
||||
http://gitea.gitea.svc.cluster.local:3000/unchained/manoon-headless.git \
|
||||
/workspace
|
||||
echo "Clone complete."
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
- name: install
|
||||
image: node:20-slim
|
||||
workingDir: /workspace
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "Installing dependencies..."
|
||||
npm install --prefer-offline --no-audit 2>&1
|
||||
echo "Dependencies installed."
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 3Gi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 1Gi
|
||||
- name: build
|
||||
image: node:20-slim
|
||||
workingDir: /workspace
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "Building Next.js app..."
|
||||
npm run build
|
||||
echo "Build complete!"
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: NEXT_PUBLIC_SALEOR_API_URL
|
||||
value: "https://api.manoonoils.com/graphql/"
|
||||
- name: NEXT_PUBLIC_SITE_URL
|
||||
value: "https://dev.manoonoils.com"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
containers:
|
||||
- name: storefront
|
||||
image: manoonoils-store:latest
|
||||
imagePullPolicy: Never
|
||||
image: node:20-slim
|
||||
workingDir: /workspace
|
||||
command:
|
||||
- npm
|
||||
- start
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
@@ -28,16 +104,39 @@ spec:
|
||||
value: "3000"
|
||||
- name: HOSTNAME
|
||||
value: "0.0.0.0"
|
||||
- name: NEXT_PUBLIC_SALEOR_API_URL
|
||||
value: "https://api.manoonoils.com/graphql/"
|
||||
- name: NEXT_PUBLIC_SITE_URL
|
||||
value: "https://dev.manoonoils.com"
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /favicon.ico
|
||||
port: 3000
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /favicon.ico
|
||||
port: 3000
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /favicon.ico
|
||||
port: 3000
|
||||
periodSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
volumes:
|
||||
- name: workspace
|
||||
emptyDir:
|
||||
sizeLimit: 2Gi
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
images:
|
||||
- name: ghcr.io/unchainedio/manoon-headless
|
||||
newTag: 2c27fc6 # Updated by GitHub Actions
|
||||
|
||||
466
mautic-abandoned-cart.md
Normal file
466
mautic-abandoned-cart.md
Normal 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
449
media-migration-guide.md
Normal 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
|
||||
@@ -17,6 +17,16 @@ const nextConfig: NextConfig = {
|
||||
hostname: "minio-api.nodecrew.me",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.manoonoils.com",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**.saleor.cloud",
|
||||
pathname: "/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
513
package-lock.json
generated
513
package-lock.json
generated
@@ -8,9 +8,10 @@
|
||||
"name": "manoonoils-store",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@woocommerce/woocommerce-rest-api": "^1.0.2",
|
||||
"@apollo/client": "^4.1.6",
|
||||
"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 +44,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 +555,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 +2158,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"
|
||||
@@ -2686,19 +2738,52 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@woocommerce/woocommerce-rest-api": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@woocommerce/woocommerce-rest-api/-/woocommerce-rest-api-1.0.2.tgz",
|
||||
"integrity": "sha512-G+0VwM0MINF83KnT7Rg/htm9EEYADWvDPT/UWEJdZ0de1vXvsPrr4M1ksKaxgKHO8qIJViRrIHCtrui2JoVA+Q==",
|
||||
"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": {
|
||||
"axios": "^1.6.8",
|
||||
"create-hmac": "^1.1.7",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"url-parse": "^1.4.7"
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
"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": {
|
||||
@@ -2951,16 +3036,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"possible-typed-array-names": "^1.0.0"
|
||||
@@ -2982,17 +3062,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -3084,6 +3153,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.0",
|
||||
@@ -3102,6 +3172,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3115,6 +3186,7 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -3174,20 +3246,6 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cipher-base": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz",
|
||||
"integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.4",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"to-buffer": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -3223,18 +3281,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3249,39 +3295,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/create-hash": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
|
||||
"integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cipher-base": "^1.0.1",
|
||||
"inherits": "^2.0.1",
|
||||
"md5.js": "^1.3.4",
|
||||
"ripemd160": "^2.0.1",
|
||||
"sha.js": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/create-hmac": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
|
||||
"integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cipher-base": "^1.0.3",
|
||||
"create-hash": "^1.1.0",
|
||||
"inherits": "^2.0.1",
|
||||
"ripemd160": "^2.0.0",
|
||||
"safe-buffer": "^5.0.1",
|
||||
"sha.js": "^2.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3301,7 +3314,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": {
|
||||
@@ -3400,6 +3413,7 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
@@ -3431,15 +3445,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -3466,6 +3471,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -3577,6 +3583,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3586,6 +3593,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3623,6 +3631,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -3635,6 +3644,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -4255,30 +4265,11 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-callable": "^1.2.7"
|
||||
@@ -4290,22 +4281,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.34.4",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.4.tgz",
|
||||
@@ -4337,6 +4312,7 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -4397,6 +4373,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -4421,6 +4398,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@@ -4508,6 +4486,7 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4523,6 +4502,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",
|
||||
@@ -4550,6 +4553,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
@@ -4578,6 +4582,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4590,6 +4595,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -4601,25 +4607,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash-base": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz",
|
||||
"integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^2.3.8",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"to-buffer": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -4697,12 +4689,6 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -4828,6 +4814,7 @@
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5084,6 +5071,7 @@
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"which-typed-array": "^1.1.16"
|
||||
@@ -5145,6 +5133,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
@@ -5639,22 +5628,12 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/md5.js": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
||||
"integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hash-base": "^3.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"safe-buffer": "^5.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -5679,27 +5658,6 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -5984,12 +5942,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth-1.0a": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz",
|
||||
"integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -6113,6 +6065,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",
|
||||
@@ -6250,6 +6214,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6294,12 +6259,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -6312,12 +6271,6 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6328,12 +6281,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -6383,33 +6330,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6454,12 +6374,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6512,19 +6426,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ripemd160": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz",
|
||||
"integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hash-base": "^3.1.2",
|
||||
"inherits": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -6549,6 +6450,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",
|
||||
@@ -6569,26 +6480,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -6644,6 +6535,7 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
@@ -6688,26 +6580,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sha.js": {
|
||||
"version": "2.4.12",
|
||||
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
|
||||
"integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
|
||||
"license": "(MIT AND BSD-3-Clause)",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.4",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"to-buffer": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"sha.js": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
@@ -6895,21 +6767,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -7174,20 +7031,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/to-buffer": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
|
||||
"integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isarray": "^2.0.5",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"typed-array-buffer": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -7263,6 +7106,7 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||
"integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
@@ -7340,7 +7184,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",
|
||||
@@ -7476,16 +7320,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-intl": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz",
|
||||
@@ -7507,12 +7341,6 @@
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -7600,6 +7428,7 @@
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@woocommerce/woocommerce-rest-api": "^1.0.2",
|
||||
"@apollo/client": "^4.1.6",
|
||||
"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
296
saleor-features.md
Normal 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
521
saleor-migration.md
Normal 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.
|
||||
304
scripts/EMAIL_REACTIVATION_CAMPAIGNS.md
Normal file
304
scripts/EMAIL_REACTIVATION_CAMPAIGNS.md
Normal 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
|
||||
852
scripts/migrate_all_users_and_orders.py
Normal file
852
scripts/migrate_all_users_and_orders.py
Normal 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()
|
||||
576
scripts/migrate_cod_orders.py
Normal file
576
scripts/migrate_cod_orders.py
Normal 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
785
scripts/migrate_complete.py
Normal 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()
|
||||
736
scripts/migrate_guest_orders.py
Normal file
736
scripts/migrate_guest_orders.py
Normal 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()
|
||||
201
scripts/migrate_guest_orders.sql
Normal file
201
scripts/migrate_guest_orders.sql
Normal 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)
|
||||
@@ -8,59 +8,114 @@ export const metadata = {
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="min-h-screen pt-16 md:pt-20">
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-white">
|
||||
{/* Page Header */}
|
||||
<div className="pt-[104px]">
|
||||
<div className="container py-12 md:py-16">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">Our Story</span>
|
||||
<h1 className="text-4xl md:text-5xl font-medium tracking-tight">
|
||||
About ManoonOils
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl font-serif text-center mb-8">
|
||||
Our Story
|
||||
</h1>
|
||||
{/* Hero Image */}
|
||||
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
||||
alt="Natural oils production"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
</div>
|
||||
|
||||
<div className="prose prose-lg max-w-none text-foreground-muted space-y-6">
|
||||
<p>
|
||||
ManoonOils was born from a passion for natural beauty and the belief
|
||||
that the best skincare comes from nature itself. Our journey began with
|
||||
a simple question: how can we create products that truly nurture both
|
||||
hair and skin?
|
||||
</p>
|
||||
{/* Content */}
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="container">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Introduction */}
|
||||
<div className="mb-16">
|
||||
<p className="text-xl md:text-2xl text-[#1a1a1a] leading-relaxed mb-8">
|
||||
ManoonOils was born from a passion for natural beauty and the belief
|
||||
that the best skincare comes from nature itself.
|
||||
</p>
|
||||
<p className="text-[#666666] leading-relaxed">
|
||||
We believe in the power of natural ingredients. Every oil in our
|
||||
collection is carefully selected for its unique properties and
|
||||
benefits. From nourishing oils that restore hair vitality to serums
|
||||
that rejuvenate skin, we craft each product with love and attention
|
||||
to detail.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
We believe in the power of natural ingredients. Every oil in our
|
||||
collection is carefully selected for its unique properties and
|
||||
benefits. From nourishing oils that restore hair vitality to serums
|
||||
that rejuvenate skin, we craft each product with love and attention
|
||||
to detail.
|
||||
</p>
|
||||
{/* Values Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 mb-16">
|
||||
<div className="p-6 bg-[#f8f9fa]">
|
||||
<h3 className="text-lg font-medium mb-3">Natural Ingredients</h3>
|
||||
<p className="text-[#666666] text-sm leading-relaxed">
|
||||
We use only the finest natural ingredients, sourced ethically and sustainably
|
||||
from trusted suppliers around the world.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-[#f8f9fa]">
|
||||
<h3 className="text-lg font-medium mb-3">Cruelty-Free</h3>
|
||||
<p className="text-[#666666] text-sm leading-relaxed">
|
||||
Our products are never tested on animals. We believe in beauty
|
||||
without compromise.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-[#f8f9fa]">
|
||||
<h3 className="text-lg font-medium mb-3">Sustainable Packaging</h3>
|
||||
<p className="text-[#666666] text-sm leading-relaxed">
|
||||
We use eco-friendly packaging materials and minimize waste
|
||||
throughout our production process.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-[#f8f9fa]">
|
||||
<h3 className="text-lg font-medium mb-3">Handcrafted Quality</h3>
|
||||
<p className="text-[#666666] text-sm leading-relaxed">
|
||||
Every bottle is handcrafted in small batches to ensure
|
||||
the highest quality and freshness.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
|
||||
Our Mission
|
||||
</h2>
|
||||
<p>
|
||||
Our mission is to provide premium quality, natural products that
|
||||
enhance your daily beauty routine. We are committed to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Using only the finest natural ingredients</li>
|
||||
<li>Cruelty-free and ethical production</li>
|
||||
<li>Sustainable packaging practices</li>
|
||||
<li>Transparency in our formulations</li>
|
||||
</ul>
|
||||
{/* Mission */}
|
||||
<div className="text-center py-12 border-t border-b border-[#e5e5e5]">
|
||||
<span className="text-caption text-[#666666] mb-4 block">Our Mission</span>
|
||||
<blockquote className="text-2xl md:text-3xl font-medium tracking-tight">
|
||||
“To provide premium quality, natural products that enhance
|
||||
your daily beauty routine.”
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
|
||||
Handmade with Love
|
||||
</h2>
|
||||
<p>
|
||||
Every bottle of ManoonOils is handcrafted with care. We small-batch
|
||||
produce our products to ensure the highest quality and freshness.
|
||||
When you use ManoonOils, you can feel confident that you're using
|
||||
something made with genuine care and expertise.
|
||||
</p>
|
||||
{/* Story Section */}
|
||||
<div className="mt-16">
|
||||
<h2 className="text-2xl font-medium mb-6">Handmade with Love</h2>
|
||||
<p className="text-[#666666] leading-relaxed mb-6">
|
||||
Every bottle of ManoonOils is handcrafted with care. We small-batch
|
||||
produce our products to ensure the highest quality and freshness.
|
||||
When you use ManoonOils, you can feel confident that you're using
|
||||
something made with genuine care and expertise.
|
||||
</p>
|
||||
<p className="text-[#666666] leading-relaxed">
|
||||
Our journey began with a simple question: how can we create products
|
||||
that truly nurture both hair and skin? Today, we continue to innovate
|
||||
while staying true to our commitment to natural, effective beauty solutions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
</main>
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
494
src/app/checkout/page.tsx
Normal file
494
src/app/checkout/page.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { formatPrice } from "@/lib/saleor";
|
||||
import { saleorClient } from "@/lib/saleor/client";
|
||||
import {
|
||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
CHECKOUT_COMPLETE,
|
||||
} from "@/lib/saleor/mutations/Checkout";
|
||||
import type { Checkout } from "@/types/saleor";
|
||||
|
||||
// GraphQL Response Types
|
||||
interface ShippingAddressUpdateResponse {
|
||||
checkoutShippingAddressUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface BillingAddressUpdateResponse {
|
||||
checkoutBillingAddressUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutCompleteResponse {
|
||||
checkoutComplete?: {
|
||||
order?: { number: string };
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddressForm {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
streetAddress1: string;
|
||||
streetAddress2: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const router = useRouter();
|
||||
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orderComplete, setOrderComplete] = useState(false);
|
||||
const [orderNumber, setOrderNumber] = useState<string | null>(null);
|
||||
|
||||
const [sameAsShipping, setSameAsShipping] = useState(true);
|
||||
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
streetAddress1: "",
|
||||
streetAddress2: "",
|
||||
city: "",
|
||||
postalCode: "",
|
||||
phone: "",
|
||||
});
|
||||
const [billingAddress, setBillingAddress] = useState<AddressForm>({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
streetAddress1: "",
|
||||
streetAddress2: "",
|
||||
city: "",
|
||||
postalCode: "",
|
||||
phone: "",
|
||||
});
|
||||
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkout) {
|
||||
refreshCheckout();
|
||||
}
|
||||
}, [checkout, refreshCheckout]);
|
||||
|
||||
// Redirect if cart is empty
|
||||
useEffect(() => {
|
||||
if (lines.length === 0 && !orderComplete) {
|
||||
// Optionally redirect to cart or products
|
||||
// router.push("/products");
|
||||
}
|
||||
}, [lines, orderComplete, router]);
|
||||
|
||||
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
||||
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
||||
if (sameAsShipping) {
|
||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingChange = (field: keyof AddressForm, value: string) => {
|
||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!checkout) {
|
||||
setError("No active checkout. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Update shipping address
|
||||
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
shippingAddress: {
|
||||
...shippingAddress,
|
||||
country: "RS", // Serbia
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
|
||||
}
|
||||
|
||||
// Update billing address
|
||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
billingAddress: {
|
||||
...billingAddress,
|
||||
country: "RS",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
|
||||
}
|
||||
|
||||
// Complete checkout (creates order)
|
||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||
mutation: CHECKOUT_COMPLETE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) {
|
||||
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
|
||||
}
|
||||
|
||||
const order = completeResult.data?.checkoutComplete?.order;
|
||||
if (order) {
|
||||
setOrderNumber(order.number);
|
||||
setOrderComplete(true);
|
||||
} else {
|
||||
throw new Error("Failed to create order");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred during checkout");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Order Success Page
|
||||
if (orderComplete) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen">
|
||||
<section className="pt-[120px] pb-20 px-4">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-serif mb-2">Order Confirmed!</h1>
|
||||
<p className="text-foreground-muted">Thank you for your purchase.</p>
|
||||
</div>
|
||||
|
||||
{orderNumber && (
|
||||
<div className="bg-background-ice p-6 rounded-lg mb-6">
|
||||
<p className="text-sm text-foreground-muted mb-1">Order Number</p>
|
||||
<p className="text-2xl font-serif">#{orderNumber}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-foreground-muted mb-8">
|
||||
You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-block px-8 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen">
|
||||
<section className="pt-[120px] pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-serif mb-8">Checkout</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 p-4 mb-6 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Checkout Form */}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Shipping Address */}
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">Shipping Address</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.firstName}
|
||||
onChange={(e) => handleShippingChange("firstName", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.lastName}
|
||||
onChange={(e) => handleShippingChange("lastName", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Street Address</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.streetAddress1}
|
||||
onChange={(e) => handleShippingChange("streetAddress1", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
value={shippingAddress.streetAddress2}
|
||||
onChange={(e) => handleShippingChange("streetAddress2", e.target.value)}
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.city}
|
||||
onChange={(e) => handleShippingChange("city", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Postal Code</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.postalCode}
|
||||
onChange={(e) => handleShippingChange("postalCode", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={shippingAddress.phone}
|
||||
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Address Toggle */}
|
||||
<div className="border-b border-border pb-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sameAsShipping}
|
||||
onChange={(e) => setSameAsShipping(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>Billing address same as shipping</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Billing Address (if different) */}
|
||||
{!sameAsShipping && (
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">Billing Address</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingAddress.firstName}
|
||||
onChange={(e) => handleBillingChange("firstName", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingAddress.lastName}
|
||||
onChange={(e) => handleBillingChange("lastName", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Street Address</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingAddress.streetAddress1}
|
||||
onChange={(e) => handleBillingChange("streetAddress1", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingAddress.city}
|
||||
onChange={(e) => handleBillingChange("city", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Postal Code</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingAddress.postalCode}
|
||||
onChange={(e) => handleBillingChange("postalCode", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={billingAddress.phone}
|
||||
onChange={(e) => handleBillingChange("phone", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">Payment Method</h2>
|
||||
<div className="bg-background-ice p-4 rounded">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
checked
|
||||
readOnly
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>Cash on Delivery (COD)</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-muted mt-2 ml-7">
|
||||
Pay when your order is delivered to your door.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || lines.length === 0}
|
||||
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Processing..." : `Complete Order - ${formatPrice(total)}`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="bg-background-ice p-6 rounded-lg h-fit">
|
||||
<h2 className="text-xl font-serif mb-6">Order Summary</h2>
|
||||
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-foreground-muted">Your cart is empty</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4 mb-6">
|
||||
{lines.map((line) => (
|
||||
<div key={line.id} className="flex gap-4">
|
||||
<div className="w-16 h-16 bg-white relative flex-shrink-0">
|
||||
{line.variant.product.media[0]?.url && (
|
||||
<Image
|
||||
src={line.variant.product.media[0].url}
|
||||
alt={line.variant.product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-sm">{line.variant.product.name}</h3>
|
||||
<p className="text-foreground-muted text-sm">
|
||||
Qty: {line.quantity}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{formatPrice(line.totalPrice.gross.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-muted">Subtotal</span>
|
||||
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-muted">Shipping</span>
|
||||
<span>
|
||||
{checkout?.shippingPrice?.gross?.amount
|
||||
? formatPrice(checkout.shippingPrice.gross.amount)
|
||||
: "Calculated"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import { Mail, MapPin, Truck, Check } from "lucide-react";
|
||||
|
||||
export default function ContactPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -18,97 +19,185 @@ export default function ContactPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pt-16 md:pt-20">
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-white">
|
||||
{/* Page Header */}
|
||||
<div className="pt-[104px]">
|
||||
<div className="container py-12 md:py-16">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">Get in Touch</span>
|
||||
<h1 className="text-4xl md:text-5xl font-medium tracking-tight mb-4">
|
||||
Contact Us
|
||||
</h1>
|
||||
<p className="text-[#666666]">
|
||||
Have questions? We'd love to hear from you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl font-serif text-center mb-8">
|
||||
Contact Us
|
||||
</h1>
|
||||
{/* Contact Section */}
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
|
||||
{/* Contact Info */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium mb-6">Get in Touch</h2>
|
||||
<p className="text-[#666666] mb-8 leading-relaxed">
|
||||
We're here to help! Whether you have questions about our products,
|
||||
need assistance with an order, or just want to say hello, we'd love to hear from you.
|
||||
</p>
|
||||
|
||||
<p className="text-foreground-muted text-center mb-12">
|
||||
Have questions? We'd love to hear from you.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-[#f8f9fa] flex items-center justify-center flex-shrink-0">
|
||||
<Mail className="w-5 h-5 text-[#666666]" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Email</h3>
|
||||
<p className="text-[#666666] text-sm">hello@manoonoils.com</p>
|
||||
<p className="text-[#999999] text-xs mt-1">We reply within 24 hours</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="bg-green-50 text-green-700 p-6 text-center">
|
||||
<p className="text-lg">Thank you for your message!</p>
|
||||
<p className="mt-2">We'll get back to you soon.</p>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-[#f8f9fa] flex items-center justify-center flex-shrink-0">
|
||||
<Truck className="w-5 h-5 text-[#666666]" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Shipping</h3>
|
||||
<p className="text-[#666666] text-sm">Free shipping over 3,000 RSD</p>
|
||||
<p className="text-[#999999] text-xs mt-1">Delivered within 2-5 business days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-[#f8f9fa] flex items-center justify-center flex-shrink-0">
|
||||
<MapPin className="w-5 h-5 text-[#666666]" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Location</h3>
|
||||
<p className="text-[#666666] text-sm">Serbia</p>
|
||||
<p className="text-[#999999] text-xs mt-1">Shipping nationwide</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground"
|
||||
/>
|
||||
</div>
|
||||
{/* Contact Form */}
|
||||
<div className="bg-[#f8f9fa] p-8 md:p-10">
|
||||
{submitted ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Check className="w-8 h-8 text-green-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">Thank You!</h3>
|
||||
<p className="text-[#666666]">
|
||||
Your message has been sent. We'll get back to you soon.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border border-[#e5e5e5] focus:outline-none focus:border-black transition-colors"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium mb-2">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
required
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border border-[#e5e5e5] focus:outline-none focus:border-black transition-colors"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium mb-2">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
required
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border border-[#e5e5e5] focus:outline-none focus:border-black transition-colors resize-none"
|
||||
placeholder="How can we help you?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 pt-8 border-t border-border/30">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<h3 className="font-serif mb-2">Email</h3>
|
||||
<p className="text-foreground-muted">hello@manoonoils.com</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif mb-2">Shipping</h3>
|
||||
<p className="text-foreground-muted">Free over 3000 RSD</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif mb-2">Location</h3>
|
||||
<p className="text-foreground-muted">Serbia</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 bg-black text-white text-sm uppercase tracking-[0.1em] font-medium hover:bg-[#333333] transition-colors"
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
{/* FAQ Section */}
|
||||
<section className="py-16 md:py-24 border-t border-[#e5e5e5]">
|
||||
<div className="container">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="text-2xl font-medium text-center mb-12">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{
|
||||
q: "How long does shipping take?",
|
||||
a: "Orders are typically delivered within 2-5 business days for domestic shipping. You'll receive a tracking number once your order ships."
|
||||
},
|
||||
{
|
||||
q: "Are your products 100% natural?",
|
||||
a: "Yes! All our oils are 100% natural, cold-pressed, and free from any additives, preservatives, or artificial fragrances."
|
||||
},
|
||||
{
|
||||
q: "What is your return policy?",
|
||||
a: "We accept returns within 14 days of delivery for unopened products. Please contact us if you have any issues with your order."
|
||||
},
|
||||
{
|
||||
q: "Do you offer wholesale?",
|
||||
a: "Yes, we offer wholesale pricing for bulk orders. Please contact us at hello@manoonoils.com for more information."
|
||||
}
|
||||
].map((faq, index) => (
|
||||
<div key={index} className="border-b border-[#e5e5e5] pb-6">
|
||||
<h3 className="font-medium mb-2">{faq.q}</h3>
|
||||
<p className="text-[#666666] text-sm leading-relaxed">{faq.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,2 @@
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
|
||||
export const metadata = {
|
||||
title: "About - ManoonOils",
|
||||
description: "Learn about ManoonOils - our story, mission, and commitment to natural beauty.",
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="min-h-screen pt-16 md:pt-20">
|
||||
<Header />
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl font-serif text-center mb-8">
|
||||
Our Story
|
||||
</h1>
|
||||
|
||||
<div className="prose prose-lg max-w-none text-foreground-muted space-y-6">
|
||||
<p>
|
||||
ManoonOils was born from a passion for natural beauty and the belief
|
||||
that the best skincare comes from nature itself. Our journey began with
|
||||
a simple question: how can we create products that truly nurture both
|
||||
hair and skin?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
We believe in the power of natural ingredients. Every oil in our
|
||||
collection is carefully selected for its unique properties and
|
||||
benefits. From nourishing oils that restore hair vitality to serums
|
||||
that rejuvenate skin, we craft each product with love and attention
|
||||
to detail.
|
||||
</p>
|
||||
|
||||
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
|
||||
Our Mission
|
||||
</h2>
|
||||
<p>
|
||||
Our mission is to provide premium quality, natural products that
|
||||
enhance your daily beauty routine. We are committed to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Using only the finest natural ingredients</li>
|
||||
<li>Cruelty-free and ethical production</li>
|
||||
<li>Sustainable packaging practices</li>
|
||||
<li>Transparency in our formulations</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
|
||||
Handmade with Love
|
||||
</h2>
|
||||
<p>
|
||||
Every bottle of ManoonOils is handcrafted with care. We small-batch
|
||||
produce our products to ensure the highest quality and freshness.
|
||||
When you use ManoonOils, you can feel confident that you're using
|
||||
something made with genuine care and expertise.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
// Re-export from main about page
|
||||
export { default, metadata } from "../../about/page";
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { getProducts } from "@/lib/woocommerce";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import AnnouncementBar from "@/components/home/AnnouncementBar";
|
||||
import NewHero from "@/components/home/NewHero";
|
||||
import StatsSection from "@/components/home/StatsSection";
|
||||
import FeaturesSection from "@/components/home/FeaturesSection";
|
||||
import TestimonialsSection from "@/components/home/TestimonialsSection";
|
||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||
import HeroVideo from "@/components/home/HeroVideo";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
|
||||
export const metadata = {
|
||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||
@@ -17,61 +13,160 @@ export const metadata = {
|
||||
export default async function Homepage() {
|
||||
let products: any[] = [];
|
||||
try {
|
||||
products = await getProducts();
|
||||
products = await getProducts("EN");
|
||||
} catch (e) {
|
||||
// Fallback for build time when API is unavailable
|
||||
console.log('Failed to fetch products during build');
|
||||
}
|
||||
const featuredProduct = products.find((p) => p.status === "publish");
|
||||
const publishedProducts = products
|
||||
.filter((p) => p.status === "publish")
|
||||
.slice(0, 4);
|
||||
|
||||
const featuredProducts = products.slice(0, 4);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<AnnouncementBar />
|
||||
<div className="pt-10">
|
||||
<Header />
|
||||
</div>
|
||||
<>
|
||||
<Header />
|
||||
|
||||
{/* New Hero Section */}
|
||||
<NewHero featuredProduct={featuredProduct} />
|
||||
<main className="min-h-screen bg-white">
|
||||
{/* Hero Section with Video Background */}
|
||||
<HeroVideo />
|
||||
|
||||
{/* Stats & Philosophy Section */}
|
||||
<StatsSection />
|
||||
{/* Main Content */}
|
||||
<div id="main-content">
|
||||
{/* Products Grid Section */}
|
||||
{featuredProducts.length > 0 && (
|
||||
<section className="py-24 px-4 sm:px-6 lg:px-8 bg-white">
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">Our Collection</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium mb-4">Premium Natural Oils</h2>
|
||||
<p className="text-[#666666] max-w-xl mx-auto">
|
||||
Cold-pressed, pure, and natural oils for your daily beauty routine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesSection />
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{featuredProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} locale="EN" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<TestimonialsSection />
|
||||
{/* View All Link */}
|
||||
<div className="text-center mt-12">
|
||||
<a
|
||||
href="/en/products"
|
||||
className="inline-block text-sm uppercase tracking-[0.1em] border-b border-black pb-1 hover:text-[#666666] hover:border-[#666666] transition-colors"
|
||||
>
|
||||
View All Products
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Newsletter Section */}
|
||||
<NewsletterSection />
|
||||
|
||||
{/* Products Grid Section */}
|
||||
{publishedProducts.length > 0 && (
|
||||
<section className="py-20 px-6 bg-white">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<h2 className="font-serif italic text-4xl text-center mb-4">
|
||||
Our Collection
|
||||
</h2>
|
||||
<p className="text-center text-[#4A4A4A] mb-12 max-w-2xl mx-auto">
|
||||
Cold-pressed, pure, and natural oils for your daily beauty routine
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{publishedProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
))}
|
||||
{/* Brand Story Section */}
|
||||
<section className="py-24 px-4 sm:px-6 lg:px-8 bg-[#f8f9fa]">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">Our Story</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium mb-6">Handmade with Love</h2>
|
||||
<p className="text-[#666666] mb-6 leading-relaxed">
|
||||
Every bottle of ManoonOils is crafted with care using traditional
|
||||
methods passed down through generations. We source only the finest
|
||||
organic ingredients to bring you oils that nourish both hair and skin.
|
||||
</p>
|
||||
<p className="text-[#666666] mb-8 leading-relaxed">
|
||||
Our commitment to purity means no additives, no preservatives -
|
||||
just nature's goodness in its most potent form.
|
||||
</p>
|
||||
<a
|
||||
href="/en/about"
|
||||
className="inline-block text-sm uppercase tracking-[0.1em] border-b border-black pb-1 hover:text-[#666666] hover:border-[#666666] transition-colors"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
|
||||
alt="Natural oils production"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
{/* Benefits Section */}
|
||||
<section className="py-24 px-4 sm:px-6 lg:px-8 bg-white">
|
||||
<div className="container">
|
||||
<div className="text-center mb-16">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">Why Choose Us</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium">The Manoon Difference</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12">
|
||||
{[
|
||||
{
|
||||
title: "100% Natural",
|
||||
description: "Pure, cold-pressed oils with no additives or preservatives. Just nature's goodness.",
|
||||
},
|
||||
{
|
||||
title: "Handcrafted",
|
||||
description: "Each batch is carefully prepared by hand to ensure the highest quality.",
|
||||
},
|
||||
{
|
||||
title: "Sustainable",
|
||||
description: "Ethically sourced ingredients and eco-friendly packaging for a better planet.",
|
||||
},
|
||||
].map((benefit, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-[#e8f0f5] flex items-center justify-center">
|
||||
<span className="text-2xl font-medium text-[#1a1a1a]">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-3">{benefit.title}</h3>
|
||||
<p className="text-[#666666]">{benefit.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Newsletter Section */}
|
||||
<section className="py-28 lg:py-32 px-4 sm:px-6 lg:px-8 bg-[#1a1a1a] text-white mb-16">
|
||||
<div className="container">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-white/60 mb-3 block">Stay Connected</span>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-medium mb-6">Join Our Community</h2>
|
||||
<p className="text-white/70 mb-10 mx-auto text-lg">
|
||||
Subscribe to receive exclusive offers, beauty tips, and be the first to know about new products.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<form className="inline-flex flex-col sm:flex-row">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="w-64 sm:w-80 px-5 h-14 bg-white/10 border border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-8 h-14 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Import ProductCard here to avoid circular dependency
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
|
||||
@@ -1,70 +1,97 @@
|
||||
import { getProducts } from "@/lib/woocommerce";
|
||||
import { getProductBySlug, getProducts, getLocalizedProduct } from "@/lib/saleor";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import ProductDetail from "@/components/product/ProductDetail";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Disable static generation - this page will be server-rendered
|
||||
export const generateStaticParams = undefined;
|
||||
|
||||
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
let product = null;
|
||||
interface ProductPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
// Generate static params for all products
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const products = await getProducts();
|
||||
product = products.find((p) => (p.slug || p.id.toString()) === slug);
|
||||
const products = await getProducts("EN", 100);
|
||||
const params: Array<{ slug: string }> = [];
|
||||
|
||||
products.forEach((product: Product) => {
|
||||
// English slug (if translation exists)
|
||||
if (product.translation?.slug) {
|
||||
params.push({ slug: product.translation.slug });
|
||||
} else {
|
||||
params.push({ slug: product.slug });
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const product = await getProductBySlug(slug, "EN");
|
||||
|
||||
if (!product) {
|
||||
return {
|
||||
title: "Product Not Found",
|
||||
};
|
||||
}
|
||||
|
||||
const localized = getLocalizedProduct(product, "EN");
|
||||
|
||||
return {
|
||||
title: localized.name,
|
||||
description: localized.seoDescription || localized.description?.slice(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { slug } = await params;
|
||||
const product = await getProductBySlug(slug, "EN");
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<main className="min-h-screen bg-white">
|
||||
<Header />
|
||||
<div className="pt-24 text-center">
|
||||
<h1 className="text-2xl">Product not found</h1>
|
||||
<div className="pt-[120px] text-center px-4">
|
||||
<h1 className="text-2xl font-medium mb-4">Product not found</h1>
|
||||
<p className="text-[#666666] mb-8">
|
||||
The product you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<a
|
||||
href="/products"
|
||||
className="inline-block px-8 py-3 bg-black text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
||||
>
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const image = product.images?.[0]?.src || '/placeholder.jpg';
|
||||
const price = product.sale_price || product.price;
|
||||
// Get related products
|
||||
let relatedProducts: Product[] = [];
|
||||
try {
|
||||
const allProducts = await getProducts("EN", 8);
|
||||
relatedProducts = allProducts
|
||||
.filter((p: Product) => p.id !== product.id)
|
||||
.slice(0, 4);
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<main className="min-h-screen bg-white">
|
||||
<Header />
|
||||
|
||||
<section className="pt-24 pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={product.name}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-4xl font-serif mb-4">{product.name}</h1>
|
||||
|
||||
<div className="text-2xl mb-6">{price} RSD</div>
|
||||
|
||||
<div className="prose max-w-none mb-8" dangerouslySetInnerHTML={{ __html: product.description || '' }} />
|
||||
|
||||
<button
|
||||
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProductDetail
|
||||
product={product}
|
||||
relatedProducts={relatedProducts}
|
||||
locale="EN"
|
||||
/>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getProducts } from "@/lib/woocommerce";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Products - ManoonOils",
|
||||
@@ -9,38 +10,71 @@ export const metadata = {
|
||||
};
|
||||
|
||||
export default async function ProductsPage() {
|
||||
let products: any[] = [];
|
||||
try {
|
||||
products = await getProducts();
|
||||
} catch (e) {
|
||||
console.log('Failed to fetch products during build');
|
||||
}
|
||||
|
||||
const publishedProducts = products.filter((p) => p.status === "publish");
|
||||
const products = await getProducts("EN");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pt-16 md:pt-20">
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
|
||||
All Products
|
||||
</h1>
|
||||
<main className="min-h-screen bg-white">
|
||||
{/* Page Header */}
|
||||
<div className="pt-[104px]">
|
||||
<div className="border-b border-[#e5e5e5]">
|
||||
<div className="container py-8 md:py-12">
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-2 block">Our Collection</span>
|
||||
<h1 className="text-3xl md:text-4xl font-medium">All Products</h1>
|
||||
</div>
|
||||
|
||||
{publishedProducts.length === 0 ? (
|
||||
<p className="text-center text-foreground-muted">No products available</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{publishedProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
))}
|
||||
{/* Sort Dropdown */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-[#666666]">{products.length} products</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="appearance-none bg-transparent border border-[#e5e5e5] pl-4 pr-10 py-2 text-sm focus:outline-none focus:border-black cursor-pointer"
|
||||
defaultValue="featured"
|
||||
>
|
||||
<option value="featured">Featured</option>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="price-low">Price: Low to High</option>
|
||||
<option value="price-high">Price: High to Low</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none text-[#666666]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
{/* Products Grid */}
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="container">
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#666666] mb-4">No products available</p>
|
||||
<p className="text-sm text-[#999999]">Please check back later for new arrivals.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{products.map((product, index) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
index={index}
|
||||
locale="EN"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,75 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #f0f4f8;
|
||||
--background-ice: #e8f0f5;
|
||||
--foreground: #1a1a1a;
|
||||
--foreground-muted: #666666;
|
||||
--accent: #a8c5d8;
|
||||
--accent-dark: #7ba3bc;
|
||||
--white: #ffffff;
|
||||
--border: #d1d9e0;
|
||||
}
|
||||
/* ============================================
|
||||
MANOONOILS DESIGN SYSTEM
|
||||
Tailwind 4 compatible - uses CSS layers
|
||||
============================================ */
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-background-ice: var(--background-ice);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-foreground-muted: var(--foreground-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-dark: var(--accent-dark);
|
||||
--color-white: var(--white);
|
||||
--color-border: var(--border);
|
||||
--font-display: var(--font-cedrat);
|
||||
--font-body: var(--font-dm-sans);
|
||||
/* Colors - reference CSS variables */
|
||||
--color-white: var(--color-white);
|
||||
--color-background: var(--color-background);
|
||||
--color-background-alt: var(--color-background-alt);
|
||||
--color-foreground: var(--color-foreground);
|
||||
--color-foreground-muted: var(--color-foreground-muted);
|
||||
--color-foreground-subtle: var(--color-foreground-subtle);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-accent-dark: var(--color-accent-dark);
|
||||
--color-accent-blue: var(--color-accent-blue);
|
||||
--color-gold: var(--color-gold);
|
||||
--color-gold-light: var(--color-gold-light);
|
||||
--color-border: var(--color-border);
|
||||
--color-border-dark: var(--color-border-dark);
|
||||
--color-cta: var(--color-cta);
|
||||
--color-cta-hover: var(--color-cta-hover);
|
||||
--color-overlay: var(--color-overlay);
|
||||
|
||||
/* Typography */
|
||||
--font-display: var(--font-display);
|
||||
--font-body: var(--font-body);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Cedrat Display';
|
||||
src: url('https://fonts.gstatic.com/s/cedratdisplay/v16/0nkoC9_pK3CvS5lZuZ7MAUmK5w.woff2') format('woff2');
|
||||
font-weight: 400 900;
|
||||
font-display: swap;
|
||||
/* ============================================
|
||||
CSS VARIABLES
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
--color-background: #fafafa;
|
||||
--color-background-alt: #f5f5f5;
|
||||
--color-foreground: #1a1a1a;
|
||||
--color-foreground-muted: #666666;
|
||||
--color-foreground-subtle: #999999;
|
||||
|
||||
--color-accent: #e8f0f5;
|
||||
--color-accent-dark: #a8c5d8;
|
||||
--color-accent-blue: #e8f0f5;
|
||||
--color-gold: #c9a962;
|
||||
--color-gold-light: #d4b978;
|
||||
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-dark: #d1d1d1;
|
||||
--color-cta: #000000;
|
||||
--color-cta-hover: #333333;
|
||||
--color-overlay: rgba(0, 0, 0, 0.4);
|
||||
|
||||
--font-display: 'DM Sans', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FONT IMPORTS
|
||||
============================================ */
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhS2f3ZGMZpg.woff2') format('woff2');
|
||||
@@ -38,77 +77,294 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
/* ============================================
|
||||
BASE STYLES (in Tailwind base layer)
|
||||
============================================ */
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Cedrat Display', serif;
|
||||
}
|
||||
|
||||
/* Marquee Animations */
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
|
||||
body {
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: clamp(1.25rem, 3vw, 1.75rem);
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-foreground);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-slow {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
/* ============================================
|
||||
COMPONENTS
|
||||
============================================ */
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding-left: 48px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.container-narrow {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.container-wide {
|
||||
max-width: 1600px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 32px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-cta);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-cta-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-foreground);
|
||||
color: var(--color-white);
|
||||
border-color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-underline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
.link-underline:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-foreground-muted);
|
||||
}
|
||||
|
||||
.text-subtle {
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 25s linear infinite;
|
||||
/* ============================================
|
||||
UTILITIES
|
||||
============================================ */
|
||||
|
||||
@layer utilities {
|
||||
.section {
|
||||
padding-top: 96px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.section-sm {
|
||||
padding-top: 48px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-background-alt);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-foreground-muted);
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn var(--transition-slow) forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp var(--transition-slow) forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight var(--transition-slow) forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 25s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-slow {
|
||||
animation: marquee 35s linear infinite;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee-slow {
|
||||
animation: marquee-slow 35s linear infinite;
|
||||
}
|
||||
/* ============================================
|
||||
REDUCED MOTION
|
||||
============================================ */
|
||||
|
||||
.animate-marquee-fast {
|
||||
animation: marquee 15s linear infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.font-serif {
|
||||
font-family: 'Cedrat Display', serif;
|
||||
}
|
||||
|
||||
/* Smooth scroll */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import ErrorBoundary from "@/components/providers/ErrorBoundary";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -16,15 +17,20 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
// Suppress extension-caused hydration warnings
|
||||
const suppressHydrationWarning = true;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="antialiased" suppressHydrationWarning>
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
228
src/app/page.tsx
228
src/app/page.tsx
@@ -1,12 +1,14 @@
|
||||
import { getProducts } from "@/lib/woocommerce";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import AnnouncementBar from "@/components/home/AnnouncementBar";
|
||||
import NewHero from "@/components/home/NewHero";
|
||||
import StatsSection from "@/components/home/StatsSection";
|
||||
import FeaturesSection from "@/components/home/FeaturesSection";
|
||||
import HeroVideo from "@/components/home/HeroVideo";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
import TrustBadges from "@/components/home/TrustBadges";
|
||||
import AsSeenIn from "@/components/home/AsSeenIn";
|
||||
import TestimonialsSection from "@/components/home/TestimonialsSection";
|
||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||
import ProblemSection from "@/components/home/ProblemSection";
|
||||
import HowItWorks from "@/components/home/HowItWorks";
|
||||
|
||||
export const metadata = {
|
||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||
@@ -17,61 +19,193 @@ export const metadata = {
|
||||
export default async function Homepage() {
|
||||
let products: any[] = [];
|
||||
try {
|
||||
products = await getProducts();
|
||||
products = await getProducts("SR");
|
||||
} catch (e) {
|
||||
// Fallback for build time when API is unavailable
|
||||
console.log('Failed to fetch products during build');
|
||||
}
|
||||
const featuredProduct = products.find((p) => p.status === "publish");
|
||||
const publishedProducts = products
|
||||
.filter((p) => p.status === "publish")
|
||||
.slice(0, 4);
|
||||
|
||||
const featuredProducts = products?.slice(0, 4) || [];
|
||||
const hasProducts = featuredProducts.length > 0;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<AnnouncementBar />
|
||||
<div className="pt-10">
|
||||
<Header />
|
||||
</div>
|
||||
<>
|
||||
<Header />
|
||||
|
||||
{/* New Hero Section */}
|
||||
<NewHero featuredProduct={featuredProduct} />
|
||||
<main className="min-h-screen bg-white">
|
||||
{/* Hero Section with Video Background */}
|
||||
<HeroVideo />
|
||||
|
||||
{/* Stats & Philosophy Section */}
|
||||
<StatsSection />
|
||||
{/* Trust Badges */}
|
||||
<TrustBadges />
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesSection />
|
||||
{/* As Seen In */}
|
||||
<AsSeenIn />
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<TestimonialsSection />
|
||||
{/* Problem Section - Create empathy */}
|
||||
<ProblemSection />
|
||||
|
||||
{/* Newsletter Section */}
|
||||
<NewsletterSection />
|
||||
{/* Main Content */}
|
||||
<div id="main-content" className="scroll-mt-[72px] lg:scroll-mt-[72px]">
|
||||
{/* Products Grid Section */}
|
||||
{hasProducts && (
|
||||
<section className="py-24 px-4 sm:px-6 lg:px-8 bg-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
Our Collection
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium mb-4">
|
||||
Premium Natural Oils
|
||||
</h2>
|
||||
<p className="text-[#666666] max-w-xl mx-auto">
|
||||
Cold-pressed, pure, and natural oils for your daily beauty routine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Products Grid Section */}
|
||||
{publishedProducts.length > 0 && (
|
||||
<section className="py-20 px-6 bg-white">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<h2 className="font-serif italic text-4xl text-center mb-4">
|
||||
Our Collection
|
||||
</h2>
|
||||
<p className="text-center text-[#4A4A4A] mb-12 max-w-2xl mx-auto">
|
||||
Cold-pressed, pure, and natural oils for your daily beauty routine
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{publishedProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
))}
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{featuredProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} locale="SR" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View All Link */}
|
||||
<div className="text-center mt-12">
|
||||
<a
|
||||
href="/products"
|
||||
className="inline-block text-sm uppercase tracking-[0.1em] border-b border-black pb-1 hover:text-[#666666] hover:border-[#666666] transition-colors"
|
||||
>
|
||||
View All Products
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Before/After Gallery */}
|
||||
<BeforeAfterGallery />
|
||||
|
||||
{/* How It Works */}
|
||||
<HowItWorks />
|
||||
|
||||
{/* Brand Story Section */}
|
||||
<section className="py-24 px-4 sm:px-6 lg:px-8 bg-[#f8f9fa]">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
Our Story
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium mb-6">
|
||||
Handmade with Love
|
||||
</h2>
|
||||
<p className="text-[#666666] mb-6 leading-relaxed">
|
||||
Every bottle of ManoonOils is crafted with care using traditional
|
||||
methods passed down through generations. We source only the finest
|
||||
organic ingredients to bring you oils that nourish both hair and skin.
|
||||
</p>
|
||||
<p className="text-[#666666] mb-8 leading-relaxed">
|
||||
Our commitment to purity means no additives, no preservatives -
|
||||
just nature's goodness in its most potent form.
|
||||
</p>
|
||||
<a
|
||||
href="/about"
|
||||
className="inline-block text-sm uppercase tracking-[0.1em] border-b border-black pb-1 hover:text-[#666666] hover:border-[#666666] transition-colors"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
|
||||
alt="Natural oils production"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="py-24 px-4 sm:px-6 lg:px-8 bg-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
Why Choose Us
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium">
|
||||
The Manoon Difference
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12">
|
||||
{[
|
||||
{
|
||||
title: "100% Natural",
|
||||
description: "Pure, cold-pressed oils with no additives or preservatives. Just nature's goodness.",
|
||||
},
|
||||
{
|
||||
title: "Handcrafted",
|
||||
description: "Each batch is carefully prepared by hand to ensure the highest quality.",
|
||||
},
|
||||
{
|
||||
title: "Sustainable",
|
||||
description: "Ethically sourced ingredients and eco-friendly packaging for a better planet.",
|
||||
},
|
||||
].map((benefit, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-[#e8f0f5] flex items-center justify-center">
|
||||
<span className="text-2xl font-medium text-[#1a1a1a]">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-3">{benefit.title}</h3>
|
||||
<p className="text-[#666666]">{benefit.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<TestimonialsSection />
|
||||
|
||||
{/* Newsletter Section */}
|
||||
<section className="py-28 lg:py-32 px-4 sm:px-6 lg:px-8 bg-[#1a1a1a] text-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-white/60 mb-3 block">
|
||||
Stay Connected
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-medium mb-6">
|
||||
Join Our Community
|
||||
</h2>
|
||||
<p className="text-white/70 mb-10 mx-auto text-lg">
|
||||
Subscribe to receive exclusive offers, beauty tips, and be the first to know about new products.
|
||||
</p>
|
||||
{/* Newsletter Form - Centered */}
|
||||
<form className="flex flex-col sm:flex-row items-stretch justify-center max-w-md mx-auto gap-0">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="flex-1 min-w-0 px-5 h-14 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-8 h-14 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap flex-shrink-0 rounded-b sm:rounded-r sm:rounded-bl-none"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Import ProductCard here to avoid circular dependency
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
|
||||
@@ -1,71 +1,123 @@
|
||||
import { getProducts } from "@/lib/woocommerce";
|
||||
import { getProductBySlug, getProducts, getLocalizedProduct } from "@/lib/saleor";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import ProductDetail from "@/components/product/ProductDetail";
|
||||
import type { Product } from "@/types/saleor";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Disable static generation - this page will be server-rendered
|
||||
export const generateStaticParams = undefined;
|
||||
|
||||
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
let product = null;
|
||||
interface ProductPageProps {
|
||||
params: Promise<{ slug: string; locale?: string }>;
|
||||
}
|
||||
|
||||
// Generate static params for all products
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const products = await getProducts();
|
||||
product = products.find((p) => (p.slug || p.id.toString()) === slug);
|
||||
const products = await getProducts("SR", 100);
|
||||
const params: Array<{ slug: string; locale: string }> = [];
|
||||
|
||||
products.forEach((product: Product) => {
|
||||
// Serbian slug
|
||||
params.push({ slug: product.slug, locale: "sr" });
|
||||
|
||||
// English slug (if translation exists)
|
||||
if (product.translation?.slug) {
|
||||
params.push({ slug: product.translation.slug, locale: "en" });
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps) {
|
||||
const { slug, locale = "sr" } = await params;
|
||||
const product = await getProductBySlug(slug, locale.toUpperCase());
|
||||
|
||||
if (!product) {
|
||||
return {
|
||||
title: locale === "en" ? "Product Not Found" : "Proizvod nije pronađen",
|
||||
};
|
||||
}
|
||||
|
||||
const localized = getLocalizedProduct(product, locale.toUpperCase());
|
||||
|
||||
return {
|
||||
title: localized.name,
|
||||
description: localized.seoDescription || localized.description?.slice(0, 160),
|
||||
alternates: {
|
||||
canonical: `/products/${product.slug}`,
|
||||
languages: {
|
||||
"sr": `/products/${product.slug}`,
|
||||
"en": product.translation?.slug ? `/products/${product.translation.slug}` : `/products/${product.slug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: localized.name,
|
||||
description: localized.seoDescription || localized.description?.slice(0, 160),
|
||||
images: product.media?.[0]?.url ? [product.media[0].url] : [],
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { slug, locale = "sr" } = await params;
|
||||
const product = await getProductBySlug(slug, locale.toUpperCase());
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<>
|
||||
<Header />
|
||||
<div className="pt-24 text-center">
|
||||
<h1 className="text-2xl">Product not found</h1>
|
||||
</div>
|
||||
<main className="min-h-screen bg-white">
|
||||
<div className="pt-[180px] lg:pt-[200px] pb-20 text-center px-4">
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
{locale === "en" ? "Product not found" : "Proizvod nije pronađen"}
|
||||
</h1>
|
||||
<p className="text-[#666666] mb-8">
|
||||
{locale === "en"
|
||||
? "The product you're looking for doesn't exist or has been removed."
|
||||
: "Proizvod koji tražite ne postoji ili je uklonjen."}
|
||||
</p>
|
||||
<a
|
||||
href="/products"
|
||||
className="inline-block px-8 py-3 bg-black text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
||||
>
|
||||
{locale === "en" ? "Browse Products" : "Pregledaj proizvode"}
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const image = product.images?.[0]?.src || '/placeholder.jpg';
|
||||
const price = product.sale_price || product.price;
|
||||
// Determine language based on which slug matched
|
||||
const isEnglishSlug = slug === product.translation?.slug;
|
||||
const currentLocale = isEnglishSlug ? "EN" : "SR";
|
||||
|
||||
// Get related products (same category or just other products)
|
||||
let relatedProducts: Product[] = [];
|
||||
try {
|
||||
const allProducts = await getProducts(currentLocale, 8);
|
||||
relatedProducts = allProducts
|
||||
.filter((p: Product) => p.id !== product.id)
|
||||
.slice(0, 4);
|
||||
} catch (e) {
|
||||
// Ignore error, just won't show related products
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<section className="pt-24 pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={product.name}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-4xl font-serif mb-4">{product.name}</h1>
|
||||
|
||||
<div className="text-2xl mb-6">{price} RSD</div>
|
||||
|
||||
<div className="prose max-w-none mb-8" dangerouslySetInnerHTML={{ __html: product.description || '' }} />
|
||||
|
||||
<button
|
||||
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="min-h-screen bg-white">
|
||||
<ProductDetail
|
||||
product={product}
|
||||
relatedProducts={relatedProducts}
|
||||
locale={currentLocale}
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,106 @@
|
||||
import { getProducts } from "@/lib/woocommerce";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Products - ManoonOils",
|
||||
description: "Browse our collection of premium natural oils for hair and skin care.",
|
||||
};
|
||||
|
||||
export default async function ProductsPage() {
|
||||
let products: any[] = [];
|
||||
try {
|
||||
products = await getProducts();
|
||||
} catch (e) {
|
||||
console.log('Failed to fetch products during build');
|
||||
}
|
||||
interface ProductsPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
const publishedProducts = products.filter((p) => p.status === "publish");
|
||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const { locale = "sr" } = await params;
|
||||
const products = await getProducts(locale.toUpperCase());
|
||||
const localeUpper = locale.toUpperCase();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pt-16 md:pt-20">
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
|
||||
All Products
|
||||
</h1>
|
||||
<main className="min-h-screen bg-white">
|
||||
{/* Page Header */}
|
||||
<div className="pt-[72px] lg:pt-[72px]">
|
||||
<div className="border-b border-[#e5e5e5]">
|
||||
<div className="container py-8 md:py-12">
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-2 block">
|
||||
{localeUpper === "EN" ? "Our Collection" : "Naša kolekcija"}
|
||||
</span>
|
||||
<h1 className="text-3xl md:text-4xl font-medium">
|
||||
{localeUpper === "EN" ? "All Products" : "Svi Proizvodi"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{publishedProducts.length === 0 ? (
|
||||
<p className="text-center text-foreground-muted">No products available</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{publishedProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
))}
|
||||
{/* Sort Dropdown */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-[#666666]">
|
||||
{products.length} {localeUpper === "EN" ? "products" : "proizvoda"}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="appearance-none bg-transparent border border-[#e5e5e5] pl-4 pr-10 py-2 text-sm focus:outline-none focus:border-black cursor-pointer"
|
||||
defaultValue="featured"
|
||||
>
|
||||
<option value="featured">
|
||||
{localeUpper === "EN" ? "Featured" : "Istaknuto"}
|
||||
</option>
|
||||
<option value="newest">
|
||||
{localeUpper === "EN" ? "Newest" : "Najnovije"}
|
||||
</option>
|
||||
<option value="price-low">
|
||||
{localeUpper === "EN" ? "Price: Low to High" : "Cena: Rastuće"}
|
||||
</option>
|
||||
<option value="price-high">
|
||||
{localeUpper === "EN" ? "Price: High to Low" : "Cena: Opadajuće"}
|
||||
</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none text-[#666666]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
{/* Products Grid */}
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="container">
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#666666] mb-4">
|
||||
{localeUpper === "EN" ? "No products available" : "Nema dostupnih proizvoda"}
|
||||
</p>
|
||||
<p className="text-sm text-[#999999]">
|
||||
{localeUpper === "EN"
|
||||
? "Please check back later for new arrivals."
|
||||
: "Molimo proverite ponovo kasnije za nove proizvode."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{products.map((product, index) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
index={index}
|
||||
locale={localeUpper}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { getProducts } from "@/lib/woocommerce";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
let products: any[] = [];
|
||||
try {
|
||||
products = await getProducts();
|
||||
products = await getProducts("SR", 100);
|
||||
} catch (e) {
|
||||
console.log('Failed to fetch products for sitemap during build');
|
||||
}
|
||||
|
||||
const productUrls = products
|
||||
.filter((p) => p.status === "publish")
|
||||
.map((product) => ({
|
||||
url: `${baseUrl}/products/${product.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
const productUrls = products.map((product) => ({
|
||||
url: `${baseUrl}/products/${product.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,99 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCartStore } from "@/stores/cartStore";
|
||||
import { formatPrice } from "@/lib/woocommerce";
|
||||
import { X, Minus, Plus, Trash2, ShoppingBag } from "lucide-react";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { formatPrice } from "@/lib/saleor";
|
||||
|
||||
export default function CartDrawer() {
|
||||
const { items, isOpen, closeCart, removeItem, updateQuantity, getTotal } = useCartStore();
|
||||
const {
|
||||
checkout,
|
||||
isOpen,
|
||||
isLoading,
|
||||
error,
|
||||
closeCart,
|
||||
removeLine,
|
||||
updateLine,
|
||||
getTotal,
|
||||
getLineCount,
|
||||
getLines,
|
||||
initCheckout,
|
||||
clearError,
|
||||
} = useSaleorCheckoutStore();
|
||||
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
const lineCount = getLineCount();
|
||||
|
||||
// Initialize checkout on mount
|
||||
useEffect(() => {
|
||||
initCheckout();
|
||||
}, [initCheckout]);
|
||||
|
||||
// Lock body scroll when cart is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-50"
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={closeCart}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
className="fixed top-0 right-0 bottom-0 w-full max-w-md bg-white z-50 shadow-xl flex flex-col"
|
||||
className="fixed top-0 right-0 bottom-0 w-full max-w-[420px] bg-white z-50 shadow-2xl flex flex-col"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "tween", duration: 0.3 }}
|
||||
transition={{ type: "tween", duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-border/30">
|
||||
<h2 className="text-xl font-serif">Your Cart</h2>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-[#e5e5e5]">
|
||||
<h2 className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||
Your Cart ({lineCount})
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeCart}
|
||||
className="p-2"
|
||||
className="p-2 -mr-2 hover:bg-black/5 rounded-full transition-colors"
|
||||
aria-label="Close cart"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="w-5 h-5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-foreground-muted mb-6">Your cart is empty</p>
|
||||
{/* Error Message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-4 bg-red-50 border-b border-red-100">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="text-red-600 text-xs underline mt-1 hover:no-underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{lines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6">
|
||||
<div className="w-16 h-16 rounded-full bg-[#f8f9fa] flex items-center justify-center mb-6">
|
||||
<ShoppingBag className="w-8 h-8 text-[#999999]" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-[#666666] mb-2">Your cart is empty</p>
|
||||
<p className="text-sm text-[#999999] mb-8 text-center">
|
||||
Looks like you haven't added anything to your cart yet.
|
||||
</p>
|
||||
<Link
|
||||
href="/en/products"
|
||||
href="/products"
|
||||
onClick={closeCart}
|
||||
className="inline-block px-6 py-3 bg-foreground text-white"
|
||||
className="inline-block px-8 py-3 bg-black text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
||||
>
|
||||
Continue Shopping
|
||||
Start Shopping
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-4">
|
||||
<div className="w-20 h-20 bg-background-ice relative flex-shrink-0">
|
||||
{item.image && (
|
||||
<div className="p-6 space-y-6">
|
||||
{lines.map((line) => (
|
||||
<div key={line.id} className="flex gap-4">
|
||||
{/* Product Image */}
|
||||
<div className="w-24 h-24 bg-[#f8f9fa] relative flex-shrink-0 overflow-hidden">
|
||||
{line.variant.product.media[0]?.url ? (
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
src={line.variant.product.media[0].url}
|
||||
alt={line.variant.product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
||||
<ShoppingBag className="w-6 h-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-serif text-sm">{item.name}</h3>
|
||||
<p className="text-foreground-muted text-sm mt-1">
|
||||
{formatPrice(item.price)}
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium truncate">
|
||||
{line.variant.product.name}
|
||||
</h3>
|
||||
{line.variant.name !== "Default" && (
|
||||
<p className="text-[#999999] text-xs mt-0.5">
|
||||
{line.variant.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[#666666] text-sm mt-2">
|
||||
{formatPrice(
|
||||
line.variant.pricing?.price?.gross?.amount || 0,
|
||||
line.variant.pricing?.price?.gross?.currency
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center border border-[#e5e5e5]">
|
||||
<button
|
||||
onClick={() => updateLine(line.id, line.quantity - 1)}
|
||||
disabled={isLoading || line.quantity <= 1}
|
||||
className="w-8 h-8 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</button>
|
||||
<span className="w-10 text-center text-sm font-medium">
|
||||
{line.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => updateLine(line.id, line.quantity + 1)}
|
||||
disabled={isLoading}
|
||||
className="w-8 h-8 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
className="w-8 h-8 border border-border flex items-center justify-center"
|
||||
onClick={() => removeLine(line.id)}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-[#999999] hover:text-red-500 transition-colors"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span>{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-8 h-8 border border-border flex items-center justify-center"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="ml-auto text-foreground-muted hover:text-red-500"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,18 +198,67 @@ export default function CartDrawer() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-6 border-t border-border/30">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-serif">Subtotal</span>
|
||||
<span className="font-serif text-lg">{formatPrice(total.toString())}</span>
|
||||
{/* Footer with Checkout */}
|
||||
{lines.length > 0 && (
|
||||
<div className="border-t border-[#e5e5e5] bg-white">
|
||||
{/* Order Summary */}
|
||||
<div className="p-6 space-y-3">
|
||||
{/* Subtotal */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[#666666]">Subtotal</span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Shipping */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[#666666]">Shipping</span>
|
||||
<span className="text-[#666666]">
|
||||
{checkout?.shippingPrice?.gross?.amount
|
||||
? formatPrice(checkout.shippingPrice.gross.amount)
|
||||
: "Calculated at checkout"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[#e5e5e5] my-4" />
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm uppercase tracking-[0.05em] font-medium">Total</span>
|
||||
<span className="text-lg font-medium">
|
||||
{formatPrice(total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(checkout?.subtotalPrice?.gross?.amount || 0) < 5000 && (
|
||||
<p className="text-xs text-[#666666] text-center">
|
||||
Free shipping on orders over {formatPrice(5000)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 pb-6 space-y-3">
|
||||
{/* Checkout Button */}
|
||||
<Link
|
||||
href="/checkout"
|
||||
onClick={closeCart}
|
||||
className="block w-full py-4 bg-black text-white text-center text-sm uppercase tracking-[0.1em] font-medium hover:bg-[#333333] transition-colors"
|
||||
>
|
||||
{isLoading ? "Processing..." : "Checkout"}
|
||||
</Link>
|
||||
|
||||
{/* Continue Shopping */}
|
||||
<button
|
||||
onClick={closeCart}
|
||||
className="block w-full py-3 text-center text-sm text-[#666666] hover:text-black transition-colors"
|
||||
>
|
||||
Continue Shopping
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
href="https://manoonoils.com/checkout"
|
||||
className="block w-full py-3 bg-foreground text-white text-center font-medium hover:bg-accent-dark transition-colors"
|
||||
>
|
||||
Checkout
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
88
src/components/home/AsSeenIn.tsx
Normal file
88
src/components/home/AsSeenIn.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const mediaLogos = [
|
||||
{ name: "VOGUE", style: "serif" },
|
||||
{ name: "Allure", style: "sans" },
|
||||
{ name: "ELLE", style: "serif" },
|
||||
{ name: "COSMOPOLITAN", style: "serif" },
|
||||
{ name: "Bazaar", style: "serif" },
|
||||
{ name: "GLAMOUR", style: "serif" },
|
||||
{ name: "WOMEN'S HEALTH", style: "sans" },
|
||||
{ name: "Shape", style: "sans" },
|
||||
];
|
||||
|
||||
function LogoItem({ name }: { name: string }) {
|
||||
const isSerif = name === "VOGUE" || name === "ELLE" || name === "COSMOPOLITAN" || name === "Bazaar" || name === "GLAMOUR";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center px-10 py-4 grayscale opacity-40 hover:grayscale-0 hover:opacity-100 transition-all duration-500 flex-shrink-0">
|
||||
<span
|
||||
className={`
|
||||
text-xl md:text-2xl tracking-[0.15em] text-white font-bold
|
||||
${isSerif ? 'font-serif italic' : 'font-sans uppercase'}
|
||||
`}
|
||||
style={{
|
||||
textShadow: '0 0 20px rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AsSeenIn() {
|
||||
return (
|
||||
<section className="py-12 bg-[#1a1a1a] overflow-hidden border-y border-white/10">
|
||||
<div className="container mx-auto px-4 mb-8">
|
||||
<motion.p
|
||||
className="text-center text-[10px] uppercase tracking-[0.4em] text-white/30 font-medium"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
As Featured In
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Scrolling Marquee */}
|
||||
<div className="relative">
|
||||
{/* Left gradient fade */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-32 bg-gradient-to-r from-[#1a1a1a] to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-[#1a1a1a] to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Marquee container */}
|
||||
<div className="flex overflow-hidden">
|
||||
<motion.div
|
||||
className="flex items-center gap-16"
|
||||
animate={{
|
||||
x: [0, -50 + "%"],
|
||||
}}
|
||||
transition={{
|
||||
x: {
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
duration: 30,
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* First set of logos */}
|
||||
{mediaLogos.map((logo, index) => (
|
||||
<LogoItem key={`first-${index}`} name={logo.name} />
|
||||
))}
|
||||
{/* Duplicate for seamless loop */}
|
||||
{mediaLogos.map((logo, index) => (
|
||||
<LogoItem key={`second-${index}`} name={logo.name} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
180
src/components/home/BeforeAfterGallery.tsx
Normal file
180
src/components/home/BeforeAfterGallery.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
const results = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Facial Skin Transformation",
|
||||
beforeImg: "https://minio-api.nodecrew.me/saleor/marketing/use_case_2.webp",
|
||||
afterImg: "https://minio-api.nodecrew.me/saleor/marketing/use_case_2_1.webp",
|
||||
timeline: "4-6 Weeks",
|
||||
rating: 5,
|
||||
reviewCount: 2847,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Skin Radiance Transformation",
|
||||
beforeImg: "https://minio-api.nodecrew.me/saleor/marketing/use_case_3.webp",
|
||||
afterImg: "https://minio-api.nodecrew.me/saleor/marketing/use_case_3_1.webp",
|
||||
timeline: "6-8 Weeks",
|
||||
rating: 5,
|
||||
reviewCount: 1856,
|
||||
},
|
||||
];
|
||||
|
||||
function BeforeAfterSlider({ result }: { result: typeof results[0] }) {
|
||||
const [sliderPosition, setSliderPosition] = useState(50);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
setSliderPosition(Math.max(0, Math.min(100, x)));
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = ((e.touches[0].clientX - rect.left) / rect.width) * 100;
|
||||
setSliderPosition(Math.max(0, Math.min(100, x)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Before/After Slider */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative aspect-[4/3] rounded-2xl overflow-hidden shadow-2xl cursor-ew-resize select-none"
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchMove={handleTouchMove}
|
||||
>
|
||||
{/* After Image */}
|
||||
<img
|
||||
src={result.afterImg}
|
||||
alt="After - Smooth skin"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Before Image (clipped) */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ width: `${sliderPosition}%` }}
|
||||
>
|
||||
<img
|
||||
src={result.beforeImg}
|
||||
alt="Before - Wrinkled skin"
|
||||
className="absolute inset-0 h-full object-cover"
|
||||
style={{ width: `${100 / (sliderPosition / 100)}%`, maxWidth: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slider Handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 bg-white shadow-lg cursor-ew-resize"
|
||||
style={{ left: `${sliderPosition}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="absolute top-3 left-3 bg-black/70 text-white px-3 py-1.5 rounded-full text-xs font-medium backdrop-blur-sm">
|
||||
BEFORE
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 bg-black/70 text-white px-3 py-1.5 rounded-full text-xs font-medium backdrop-blur-sm">
|
||||
AFTER
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline and Rating */}
|
||||
<div className="flex items-center justify-center gap-4 mt-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium">{result.timeline}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 fill-gold text-gold" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[#666666]">({result.reviewCount.toLocaleString()})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verified Badge */}
|
||||
<div className="flex items-center justify-center gap-1.5 mt-2">
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span className="text-xs text-green-700 font-medium">Verified</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BeforeAfterGallery() {
|
||||
return (
|
||||
<section className="py-24 bg-[#faf9f7]">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
Real Results
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium mb-4">
|
||||
See the Transformation
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Two transformations side by side */}
|
||||
<div className="flex gap-6 max-w-6xl mx-auto">
|
||||
{results.map((result, index) => (
|
||||
<motion.div
|
||||
key={result.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
<BeforeAfterSlider result={result} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
className="text-center mt-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
>
|
||||
<a
|
||||
href="/products"
|
||||
className="inline-block px-10 py-4 bg-black text-white text-[13px] uppercase tracking-[0.15em] font-semibold hover:bg-[#333] transition-colors"
|
||||
>
|
||||
Start Your Transformation
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
163
src/components/home/HeroVideo.tsx
Normal file
163
src/components/home/HeroVideo.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export default function HeroVideo() {
|
||||
const scrollToContent = () => {
|
||||
const element = document.getElementById("main-content");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative h-screen w-full overflow-hidden">
|
||||
{/* Video Background */}
|
||||
<div className="absolute inset-0">
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster="/images/hero-poster.jpg"
|
||||
className="w-full h-full object-cover"
|
||||
>
|
||||
{/* Placeholder - Add actual video files when available */}
|
||||
{/* <source src="/videos/hero.webm" type="video/webm" /> */}
|
||||
{/* <source src="/videos/hero.mp4" type="video/mp4" /> */}
|
||||
</video>
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/30 to-black/60" />
|
||||
</div>
|
||||
|
||||
{/* Fallback Background (shown when video isn't loaded) */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url('https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop')`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 h-full flex flex-col items-center justify-center text-center text-white px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="max-w-4xl mx-auto"
|
||||
>
|
||||
{/* Social Proof Micro */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="flex items-center justify-center gap-2 mb-6"
|
||||
>
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 fill-gold text-gold" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-white/80">
|
||||
Loved by 50,000+ customers worldwide
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Heading - Outcome Focused */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight"
|
||||
>
|
||||
Transform Your Hair & Skin
|
||||
<br />
|
||||
<span className="text-white/90">with 100% Natural Oils</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle - Expands on how */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.7 }}
|
||||
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed"
|
||||
>
|
||||
Cold-pressed, organic oils handcrafted with love.
|
||||
No additives, no preservatives—just nature's purest goodness for your daily beauty ritual.
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Button - Action verb + value */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.9 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||
>
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-block px-10 py-4 bg-white text-black text-[13px] uppercase tracking-[0.15em] font-semibold hover:bg-white/90 transition-all duration-300 hover:scale-105 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Transform My Hair & Skin
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="inline-block px-10 py-4 border border-white/50 text-white text-[13px] uppercase tracking-[0.15em] font-medium hover:bg-white/10 transition-all duration-300"
|
||||
>
|
||||
Learn Our Story
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.2, duration: 0.8 }}
|
||||
className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>30-Day Money Back</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<span>Free Shipping Over 3,000 RSD</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<span>Cruelty Free</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.5, duration: 0.8 }}
|
||||
onClick={scrollToContent}
|
||||
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Scroll to content"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
|
||||
>
|
||||
<ChevronDown className="w-6 h-6" strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
89
src/components/home/HowItWorks.tsx
Normal file
89
src/components/home/HowItWorks.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function HowItWorks() {
|
||||
const steps = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Choose Your Oil",
|
||||
description: "Select from our collection of pure, cold-pressed oils formulated for your specific hair and skin needs.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Apply Daily",
|
||||
description: " Massage a few drops into damp hair or skin. Our oils absorb instantly—never greasy, always nourishing.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "See Results",
|
||||
description: "Experience transformation in 4-6 weeks. Shinier hair, radiant skin, and confidence that glows.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
Simple Process
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium">
|
||||
How ManoonOils Works
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12 max-w-5xl mx-auto">
|
||||
{steps.map((step, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative text-center"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.15 }}
|
||||
>
|
||||
{/* Connector line (not on last item) */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="hidden md:block absolute top-10 left-[60%] w-[80%] h-[2px] bg-gradient-to-r from-[#e5e5e5] to-transparent" />
|
||||
)}
|
||||
|
||||
{/* Number circle */}
|
||||
<div className="relative inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<div className="absolute inset-0 rounded-full bg-[#1a1a1a]" />
|
||||
<span className="relative text-white text-2xl font-medium">{step.number}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-medium mb-3">{step.title}</h3>
|
||||
<p className="text-[#666666] text-sm leading-relaxed max-w-xs mx-auto">
|
||||
{step.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
className="text-center mt-16"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
>
|
||||
<a
|
||||
href="/products"
|
||||
className="inline-block px-10 py-4 bg-black text-white text-[13px] uppercase tracking-[0.15em] font-semibold hover:bg-[#333] transition-colors"
|
||||
>
|
||||
Start Your Transformation
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,30 +4,28 @@ import { motion } from "framer-motion";
|
||||
import { Star, ShoppingBag } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCartStore } from "@/stores/cartStore";
|
||||
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { getProductPrice, getProductImage, formatPrice, parseDescription } from "@/lib/saleor";
|
||||
|
||||
interface NewHeroProps {
|
||||
featuredProduct?: WooProduct;
|
||||
featuredProduct?: Product;
|
||||
}
|
||||
|
||||
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||
const { addItem, openCart } = useCartStore();
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (featuredProduct) {
|
||||
addItem({
|
||||
id: featuredProduct.id,
|
||||
name: featuredProduct.name,
|
||||
price: featuredProduct.price,
|
||||
quantity: 1,
|
||||
image: getProductImage(featuredProduct),
|
||||
sku: featuredProduct.sku,
|
||||
});
|
||||
const handleAddToCart = async () => {
|
||||
const variant = featuredProduct?.variants?.[0];
|
||||
if (variant?.id) {
|
||||
await addLine(variant.id, 1);
|
||||
openCart();
|
||||
}
|
||||
};
|
||||
|
||||
const price = featuredProduct ? getProductPrice(featuredProduct) : "";
|
||||
const image = featuredProduct ? getProductImage(featuredProduct) : "";
|
||||
|
||||
return (
|
||||
<section className="relative h-screen min-h-[700px] flex flex-col overflow-hidden pt-10">
|
||||
{/* Background Image */}
|
||||
@@ -63,7 +61,7 @@ export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||
{/* Product Image */}
|
||||
<div className="relative aspect-square bg-[#E8F4F8]">
|
||||
<Image
|
||||
src={getProductImage(featuredProduct)}
|
||||
src={image}
|
||||
alt={featuredProduct.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
@@ -89,7 +87,7 @@ export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[#4A4A4A]/70 mt-1 line-clamp-2">
|
||||
{featuredProduct.short_description?.replace(/<[^>]*>/g, "") ||
|
||||
{parseDescription(featuredProduct.description).slice(0, 100) ||
|
||||
"Premium natural oil for hair and skin care"}
|
||||
</p>
|
||||
|
||||
@@ -107,7 +105,7 @@ export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[#1A1A1A]/6">
|
||||
<div>
|
||||
<span className="text-lg font-medium text-[#1A1A1A]">
|
||||
{formatPrice(featuredProduct.price)}
|
||||
{price}
|
||||
</span>
|
||||
<span className="text-xs text-[#4A4A4A]/60 ml-2">50ml</span>
|
||||
</div>
|
||||
|
||||
73
src/components/home/ProblemSection.tsx
Normal file
73
src/components/home/ProblemSection.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function ProblemSection() {
|
||||
return (
|
||||
<section className="py-24 bg-[#faf9f7]">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
className="max-w-3xl mx-auto text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-4 block">
|
||||
The Problem
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium mb-8 leading-tight">
|
||||
Tired of Hair & Skin Products That Don't Deliver?
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto mt-12">
|
||||
{[
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
problem: "Dry, Damaged Hair",
|
||||
description: "Products leave your hair brittle, frizzy, and breaking despite expensive treatments",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
problem: "Confusing Ingredients",
|
||||
description: "Can't pronounce what's in your skincare. parabens, sulfates, synthetic fragrances—dangerous toxins",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
),
|
||||
problem: "No Real Results",
|
||||
description: "Countless products promise miracles but deliver nothing but empty promises and wasted money",
|
||||
},
|
||||
].map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="text-center p-8 bg-white rounded-2xl shadow-sm border border-[#f0ede8]"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-50 flex items-center justify-center text-red-400">
|
||||
{item.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-3">{item.problem}</h3>
|
||||
<p className="text-sm text-[#666666] leading-relaxed">{item.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { WooProduct } from "@/lib/woocommerce";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
|
||||
interface ProductShowcaseProps {
|
||||
products: WooProduct[];
|
||||
products: Product[];
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductShowcase({ products }: ProductShowcaseProps) {
|
||||
export default function ProductShowcase({ products, locale = "SR" }: ProductShowcaseProps) {
|
||||
if (!products || products.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="container">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -21,15 +22,16 @@ export default function ProductShowcase({ products }: ProductShowcaseProps) {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-serif mb-4">Our Products</h2>
|
||||
<p className="text-foreground-muted max-w-2xl mx-auto">
|
||||
<span className="text-caption text-[#666666] mb-4 block">Our Collection</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium mb-4">Our Products</h2>
|
||||
<p className="text-[#666666] max-w-2xl mx-auto">
|
||||
Discover our premium collection of natural oils for hair and skin care
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{products.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
<ProductCard key={product.id} product={product} index={index} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,8 +87,8 @@ export default function StatsSection() {
|
||||
className="relative aspect-[4/5] rounded-[4px] overflow-hidden bg-[#F0F7FA]"
|
||||
>
|
||||
<Image
|
||||
src="/images/product-showcase.jpg"
|
||||
alt="ManoonOils Products"
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/10/manoon3-resized-1.jpg"
|
||||
alt="ManoonOils Luksuzni Set"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
86
src/components/home/TrustBadges.tsx
Normal file
86
src/components/home/TrustBadges.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const badges = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
),
|
||||
stats: "4.9/5",
|
||||
label: "Average Rating",
|
||||
subtext: "Based on 2,847 reviews",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
),
|
||||
stats: "50,000+",
|
||||
label: "Happy Customers",
|
||||
subtext: "Worldwide",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
),
|
||||
stats: "100%",
|
||||
label: "Natural Ingredients",
|
||||
subtext: "No additives",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||
</svg>
|
||||
),
|
||||
stats: "Free",
|
||||
label: "Shipping",
|
||||
subtext: "Orders over 3,000 RSD",
|
||||
},
|
||||
];
|
||||
|
||||
export default function TrustBadges() {
|
||||
return (
|
||||
<section className="py-14 bg-gradient-to-b from-[#faf9f7] to-white border-b border-[#e8e4e0]">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
className="grid grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{badges.map((badge, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="flex flex-col items-center text-center p-6 bg-white rounded-2xl shadow-sm border border-[#f0ede8]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full bg-[#1a1a1a] flex items-center justify-center text-white mb-4">
|
||||
{badge.icon}
|
||||
</div>
|
||||
<p className="text-2xl lg:text-3xl font-semibold text-[#1a1a1a] tracking-tight">
|
||||
{badge.stats}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[#1a1a1a] mt-1">
|
||||
{badge.label}
|
||||
</p>
|
||||
<p className="text-xs text-[#888888] mt-1">
|
||||
{badge.subtext}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,161 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Instagram, Facebook } from "lucide-react";
|
||||
|
||||
const footerLinks = {
|
||||
shop: [
|
||||
{ label: "All Products", href: "/products" },
|
||||
{ label: "Hair Care", href: "/products" },
|
||||
{ label: "Skin Care", href: "/products" },
|
||||
{ label: "Gift Sets", href: "/products" },
|
||||
],
|
||||
about: [
|
||||
{ label: "Our Story", href: "/about" },
|
||||
{ label: "Process", href: "/about" },
|
||||
{ label: "Sustainability", href: "/about" },
|
||||
],
|
||||
help: [
|
||||
{ label: "FAQ", href: "/contact" },
|
||||
{ label: "Shipping", href: "/contact" },
|
||||
{ label: "Returns", href: "/contact" },
|
||||
{ label: "Contact Us", href: "/contact" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-background-ice border-t border-border/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-2xl font-serif mb-4">ManoonOils</h3>
|
||||
<p className="text-foreground-muted max-w-md">
|
||||
Premium natural oils for hair and skin care. Crafted with love for your daily beauty routine.
|
||||
<footer className="bg-white border-t border-[#e5e5e5]">
|
||||
{/* Main Footer */}
|
||||
<div className="container py-16 lg:py-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-8">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4">
|
||||
<Link href="/" className="inline-block mb-6">
|
||||
<Image
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
alt="ManoonOils"
|
||||
width={150}
|
||||
height={40}
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-[#666666] text-sm leading-relaxed max-w-xs mb-6">
|
||||
Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.
|
||||
</p>
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://instagram.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 rounded-full border border-[#e5e5e5] flex items-center justify-center text-[#666666] hover:border-black hover:text-black transition-colors"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<Instagram className="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://facebook.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 rounded-full border border-[#e5e5e5] flex items-center justify-center text-[#666666] hover:border-black hover:text-black transition-colors"
|
||||
aria-label="Facebook"
|
||||
>
|
||||
<Facebook className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-serif mb-4">Quick Links</h4>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link href="/en/products" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||
Products
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/en/about" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||
About Us
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||
Contact
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* Links Columns - All aligned at top */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-8">
|
||||
{/* Shop */}
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||
Shop
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.shop.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-[#666666] hover:text-black transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-serif mb-4">Customer Service</h4>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||
Shipping Info
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||
Returns
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://manoonoils.com" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||
WooCommerce Store
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{/* About */}
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||
About
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.about.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-[#666666] hover:text-black transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Help */}
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||
Help
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.help.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-[#666666] hover:text-black transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/30 mt-12 pt-8 text-center text-foreground-muted text-sm">
|
||||
<p>© {currentYear} ManoonOils. All rights reserved.</p>
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-[#e5e5e5]">
|
||||
<div className="container py-6">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
{/* Copyright */}
|
||||
<p className="text-xs text-[#999999]">
|
||||
© {currentYear} ManoonOils. All rights reserved.
|
||||
</p>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#999999]">We accept:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-[#666666] px-2 py-1 border border-[#e5e5e5] rounded">
|
||||
Visa
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[#666666] px-2 py-1 border border-[#e5e5e5] rounded">
|
||||
MC
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[#666666] px-2 py-1 border border-[#e5e5e5] rounded">
|
||||
COD
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,90 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { useCartStore } from "@/stores/cartStore";
|
||||
import { User, ShoppingBag, Menu } from "lucide-react";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
import Image from "next/image";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { User, ShoppingBag, Menu, X } from "lucide-react";
|
||||
import CartDrawer from "@/components/cart/CartDrawer";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/products", label: "Products" },
|
||||
{ href: "/about", label: "About" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const { items, toggleCart } = useCartStore();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
||||
|
||||
const itemCount = items.reduce((count, item) => count + item.quantity, 0);
|
||||
const itemCount = getLineCount();
|
||||
|
||||
// Initialize checkout on mount
|
||||
useEffect(() => {
|
||||
initCheckout();
|
||||
}, [initCheckout]);
|
||||
|
||||
// Track scroll for header styling
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Lock body scroll when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-10 z-40 bg-white border-b border-[#1A1A1A]/[0.06]">
|
||||
<div className="max-w-[1400px] mx-auto px-6">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Mobile Menu Button */}
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? "bg-white/95 backdrop-blur-md shadow-sm"
|
||||
: "bg-white/80 backdrop-blur-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex items-center justify-between h-[72px]">
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="lg:hidden p-2 -ml-2 hover:bg-black/5 rounded-full transition-colors"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Left side - Desktop Nav */}
|
||||
<nav className="hidden lg:flex items-center gap-10">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-[13px] uppercase tracking-[0.05em] text-[#1a1a1a] hover:text-[#666666] transition-colors relative group"
|
||||
>
|
||||
{link.label}
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-[1px] bg-current transition-all duration-300 group-hover:w-full" />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Logo - Centered (absolute on desktop, flex on mobile) */}
|
||||
<Link href="/" className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
|
||||
<Image
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
alt="ManoonOils"
|
||||
width={150}
|
||||
height={40}
|
||||
className="h-7 w-auto object-contain"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Right side - Icons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="lg:hidden p-2 -ml-2"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
|
||||
aria-label="Account"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
<User className="w-5 h-5" strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex-shrink-0">
|
||||
<span className="font-serif italic text-xl md:text-2xl text-[#1A1A1A]">
|
||||
ManoonOils
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex items-center gap-8">
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-sm text-[#1A1A1A] hover:text-[#1A1A1A]/70 transition-colors"
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-sm text-[#1A1A1A] hover:text-[#1A1A1A]/70 transition-colors"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="text-sm text-[#1A1A1A] hover:text-[#1A1A1A]/70 transition-colors"
|
||||
>
|
||||
Contact
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Icons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-2 hidden sm:block"
|
||||
aria-label="Account"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 relative"
|
||||
onClick={toggleCart}
|
||||
aria-label="Open cart"
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 bg-[#1A1A1A] text-white text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-colors relative"
|
||||
onClick={toggleCart}
|
||||
aria-label="Open cart"
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" strokeWidth={1.5} />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 bg-black text-white text-[10px] w-[18px] h-[18px] rounded-full flex items-center justify-center font-medium">
|
||||
{itemCount > 99 ? "99+" : itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && <MobileMenu onClose={() => setMobileMenuOpen(false)} />}
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[60] bg-white"
|
||||
>
|
||||
<div className="container h-full flex flex-col">
|
||||
{/* Mobile Header */}
|
||||
<div className="flex items-center justify-between h-[72px]">
|
||||
<Link href="/" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Image
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
alt="ManoonOils"
|
||||
width={150}
|
||||
height={40}
|
||||
className="h-7 w-auto object-contain"
|
||||
/>
|
||||
</Link>
|
||||
<button
|
||||
className="p-2 -mr-2 hover:bg-black/5 rounded-full transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<nav className="flex-1 flex flex-col justify-center gap-8">
|
||||
{navLinks.map((link, index) => (
|
||||
<motion.div
|
||||
key={link.href}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 + 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="text-3xl font-medium tracking-tight hover:text-[#666666] transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Mobile Footer */}
|
||||
<div className="py-8 border-t border-[#e5e5e5]">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="flex items-center gap-2 text-sm text-[#666666] hover:text-black transition-colors"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
toggleCart();
|
||||
}}
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" strokeWidth={1.5} />
|
||||
Cart ({itemCount})
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 text-sm text-[#666666] hover:text-black transition-colors"
|
||||
>
|
||||
<User className="w-5 h-5" strokeWidth={1.5} />
|
||||
Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<CartDrawer />
|
||||
|
||||
88
src/components/product/ProductBenefits.tsx
Normal file
88
src/components/product/ProductBenefits.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface ProductBenefitsProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Pure & Natural",
|
||||
description: "100% natural ingredients with no additives or preservatives",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.182 15.182a4.5 4.5 0 01-6.364 0M21 12a9 9 0 11-18 0 9 9 0 0118 0zM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75zm-.375 0h.008v.015h-.008V9.75zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Cruelty Free",
|
||||
description: "Never tested on animals, ethically sourced ingredients",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Made with Love",
|
||||
description: "Handcrafted in small batches for maximum quality",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Visible Results",
|
||||
description: "See noticeable improvements in 4-6 weeks",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductBenefits({ locale = "SR" }: ProductBenefitsProps) {
|
||||
return (
|
||||
<section className="py-20 bg-gradient-to-b from-white to-[#faf9f7]">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
{locale === "EN" ? "Why Choose This Product" : "Zašto odabrati ovaj proizvod"}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium">
|
||||
{locale === "EN" ? "The Manoon Difference" : "Manoon razlika"}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8 max-w-5xl mx-auto">
|
||||
{benefits.map((benefit, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="text-center p-6 bg-white rounded-2xl shadow-sm border border-[#f0ede8]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[#1a1a1a] flex items-center justify-center text-white">
|
||||
{benefit.icon}
|
||||
</div>
|
||||
<h3 className="text-base font-medium mb-2">{benefit.title}</h3>
|
||||
<p className="text-sm text-[#666666]">{benefit.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,20 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||
|
||||
interface ProductCardProps {
|
||||
product: WooProduct;
|
||||
product: Product;
|
||||
index?: number;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, index = 0 }: ProductCardProps) {
|
||||
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) {
|
||||
const image = getProductImage(product);
|
||||
const price = getProductPrice(product);
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -20,30 +25,56 @@ export default function ProductCard({ product, index = 0 }: ProductCardProps) {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={`/products/${product.slug}`} className="group block">
|
||||
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden mb-4">
|
||||
{image && (
|
||||
<Link href={`/products/${localized.slug}`} className="group block">
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
alt={localized.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
className="object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
/>
|
||||
)}
|
||||
{product.stock_status === "outofstock" && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<span className="text-white font-medium">Out of Stock</span>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
||||
<span className="text-sm">No image</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Out of Stock Overlay */}
|
||||
{!isAvailable && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
|
||||
<span className="text-sm uppercase tracking-[0.1em] text-[#666666]">
|
||||
{locale === "EN" ? "Out of Stock" : "Nema na stanju"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover Quick Add (optional) */}
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
||||
<button
|
||||
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Quick add functionality can be added here
|
||||
}}
|
||||
>
|
||||
{locale === "EN" ? "Quick Add" : "Dodaj u korpu"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-serif text-lg mb-1 group-hover:text-accent-dark transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
{/* Product Info */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-[15px] font-medium text-[#1a1a1a] mb-1 group-hover:text-[#666666] transition-colors line-clamp-1">
|
||||
{localized.name}
|
||||
</h3>
|
||||
|
||||
<p className="text-foreground-muted">
|
||||
{product.price ? formatPrice(product.price) : "Contact for price"}
|
||||
</p>
|
||||
<p className="text-[14px] text-[#666666]">
|
||||
{price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -2,181 +2,444 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
|
||||
import { useCartStore } from "@/stores/cartStore";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, Star, Minus, Plus } from "lucide-react";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { getProductPrice, getLocalizedProduct } from "@/lib/saleor";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
import ProductBenefits from "@/components/product/ProductBenefits";
|
||||
import ProductReviews from "@/components/product/ProductReviews";
|
||||
import AsSeenIn from "@/components/home/AsSeenIn";
|
||||
import TrustBadges from "@/components/home/TrustBadges";
|
||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||
import HowItWorks from "@/components/home/HowItWorks";
|
||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||
|
||||
interface ProductDetailProps {
|
||||
product: WooProduct;
|
||||
relatedProducts: WooProduct[];
|
||||
product: Product;
|
||||
relatedProducts: Product[];
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductDetail({ product, relatedProducts }: ProductDetailProps) {
|
||||
// Expandable Section Component
|
||||
function ExpandableSection({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border-b border-[#e5e5e5]">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full py-5 flex items-center justify-between text-left group"
|
||||
>
|
||||
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pb-6 text-[#666666] text-sm leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Star Rating Component
|
||||
function StarRating({ rating = 5, count = 0 }: { rating?: number; count?: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${i < rating ? 'fill-black text-black' : 'text-[#e5e5e5]'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<span className="text-sm text-[#666666] ml-1">({count})</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductDetail({ product, relatedProducts, locale = "SR" }: ProductDetailProps) {
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState<"details" | "ingredients" | "usage">("details");
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
|
||||
const images = product.images?.length > 0
|
||||
? product.images
|
||||
: [{ id: 0, src: "/placeholder-product.jpg", alt: product.name }];
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const variant = product.variants?.[0];
|
||||
|
||||
const handleAddToCart = () => {
|
||||
addItem({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price || product.regular_price,
|
||||
quantity,
|
||||
image: images[0]?.src || "",
|
||||
sku: product.sku || "",
|
||||
});
|
||||
// Get all images from media
|
||||
const images = product.media?.length > 0
|
||||
? product.media.filter(m => m.type === "IMAGE")
|
||||
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (!variant?.id) return;
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await addLine(variant.id, quantity);
|
||||
openCart();
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stripHtml = (html: string) => {
|
||||
return html.replace(/<[^>]*>/g, "");
|
||||
};
|
||||
const isAvailable = variant?.quantityAvailable > 0;
|
||||
const price = getProductPrice(product);
|
||||
|
||||
// Extract short description (first sentence or first 100 chars)
|
||||
const shortDescription = localized.description
|
||||
? localized.description.split('.')[0] + '.'
|
||||
: locale === "EN" ? "Premium natural oil for your beauty routine." : "Premium prirodno ulje za vašu rutinu lepote.";
|
||||
|
||||
// Parse benefits from product metadata or use defaults
|
||||
const benefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',') || [
|
||||
locale === "EN" ? "Natural" : "Prirodno",
|
||||
locale === "EN" ? "Organic" : "Organsko",
|
||||
locale === "EN" ? "Cruelty-free" : "Bez okrutnosti",
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="py-12 md:py-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="relative aspect-square bg-background-ice mb-4">
|
||||
{images[selectedImage] && (
|
||||
<Image
|
||||
src={images[selectedImage].src}
|
||||
alt={images[selectedImage].alt || product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<section className="min-h-screen" id="product-detail">
|
||||
{/* Breadcrumb - with proper top padding for fixed header */}
|
||||
<div className="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]">
|
||||
<div className="container py-5">
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link href="/" className="text-[#666666] hover:text-black transition-colors">
|
||||
{locale === "EN" ? "Home" : "Početna"}
|
||||
</Link>
|
||||
<span className="text-[#999999]">/</span>
|
||||
<span className="text-[#1a1a1a]">{localized.name}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Content */}
|
||||
<div className="container py-12 lg:py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
|
||||
{/* Image Gallery - Left Side */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="flex gap-4"
|
||||
>
|
||||
{/* Thumbnails - Vertical on Desktop, Hidden on Mobile */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
<div className="hidden md:flex flex-col gap-3 w-20 flex-shrink-0">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={image.id}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
className={`relative w-20 h-20 flex-shrink-0 ${
|
||||
selectedImage === index ? "ring-2 ring-foreground" : ""
|
||||
className={`relative aspect-square w-full overflow-hidden border-2 transition-colors ${
|
||||
selectedImage === index
|
||||
? "border-black"
|
||||
: "border-transparent hover:border-[#999999]"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.alt || product.name}
|
||||
src={image.url}
|
||||
alt={image.alt || localized.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="80px"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Image */}
|
||||
<div className="flex-1 relative aspect-square bg-[#f8f9fa] overflow-hidden">
|
||||
{images[selectedImage] && (
|
||||
<Image
|
||||
src={images[selectedImage].url}
|
||||
alt={images[selectedImage].alt || localized.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Award Badge - Optional */}
|
||||
<div className="absolute top-4 left-4">
|
||||
<div className="bg-black text-white text-[10px] uppercase tracking-[0.1em] px-3 py-1.5">
|
||||
{locale === "EN" ? "Bestseller" : "Najprodavanije"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Product Info - Right Side */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="lg:pl-8"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl font-serif mb-4">
|
||||
{product.name}
|
||||
{/* Product Name */}
|
||||
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
|
||||
{localized.name}
|
||||
</h1>
|
||||
|
||||
<p className="text-2xl text-foreground-muted mb-6">
|
||||
{product.price ? formatPrice(product.price) : "Contact for price"}
|
||||
{/* Short Description */}
|
||||
<p className="text-[#666666] leading-relaxed mb-6">
|
||||
{shortDescription}
|
||||
</p>
|
||||
|
||||
<div className="prose prose-sm max-w-none mb-8 text-foreground-muted">
|
||||
<p>{stripHtml(product.short_description || product.description.slice(0, 200))}</p>
|
||||
{/* Price & Rating */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<span className="text-3xl font-medium">
|
||||
{price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")}
|
||||
</span>
|
||||
<StarRating rating={5} count={12} />
|
||||
</div>
|
||||
|
||||
{product.stock_status === "instock" ? (
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="flex items-center border border-border">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="px-4 py-3"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-4 py-3">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="px-4 py-3"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-3 bg-red-50 text-red-600 text-center mb-8">
|
||||
Out of Stock
|
||||
{/* Size Selector */}
|
||||
{product.variants && product.variants.length > 1 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||
{locale === "EN" ? "Size" : "Veličina"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{product.variants.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
||||
v.id === variant?.id
|
||||
? "border-black bg-black text-white"
|
||||
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||
}`}
|
||||
>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/30">
|
||||
<div className="flex border-b border-border/30">
|
||||
{(["details", "ingredients", "usage"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-4 font-medium capitalize ${
|
||||
activeTab === tab
|
||||
? "border-b-2 border-foreground"
|
||||
: "text-foreground-muted"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="py-6 text-foreground-muted">
|
||||
{activeTab === "details" && (
|
||||
<p>{stripHtml(product.description)}</p>
|
||||
)}
|
||||
{activeTab === "ingredients" && (
|
||||
<p>Natural ingredients - Contact for detailed information.</p>
|
||||
)}
|
||||
{activeTab === "usage" && (
|
||||
<p>Apply to clean skin or hair. Use daily for best results.</p>
|
||||
)}
|
||||
{/* Quantity */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<span className="text-sm uppercase tracking-[0.1em] font-medium w-16">
|
||||
{locale === "EN" ? "Qty" : "Kol"}
|
||||
</span>
|
||||
<div className="flex items-center border-2 border-[#1a1a1a]">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-14 text-center text-base font-medium">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button - Action verb + value */}
|
||||
{isAvailable ? (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isAdding}
|
||||
className="w-full h-16 bg-black text-white text-[13px] uppercase tracking-[0.15em] font-semibold hover:bg-[#333333] active:bg-[#1a1a1a] transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed mb-6 hover:scale-[1.02] shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{isAdding
|
||||
? (locale === "EN" ? "Adding..." : "Dodavanje...")
|
||||
: (locale === "EN" ? "Transform My Hair & Skin" : "Transformiši kosu i kožu")
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full h-16 bg-[#f8f9fa] text-[#666666] flex items-center justify-center text-base uppercase tracking-[0.15em] mb-8">
|
||||
{locale === "EN" ? "Out of Stock" : "Nema na stanju"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free Shipping Note - with urgency */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<p className="text-sm text-[#666666]">
|
||||
{locale === "EN"
|
||||
? "Free shipping on orders over 3,000 RSD"
|
||||
: "Besplatna dostava za porudžbine preko 3.000 RSD"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 p-4 bg-[#f8f9fa] rounded-lg">
|
||||
<div className="text-center">
|
||||
<svg className="w-6 h-6 mx-auto mb-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<p className="text-xs text-[#666666]">
|
||||
{locale === "EN" ? "30-Day Guarantee" : "30-dnevna garancija"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<svg className="w-6 h-6 mx-auto mb-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<p className="text-xs text-[#666666]">
|
||||
{locale === "EN" ? "Secure Checkout" : "Sigurno plaćanje"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<svg className="w-6 h-6 mx-auto mb-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-8m15.357 8H15" />
|
||||
</svg>
|
||||
<p className="text-xs text-[#666666]">
|
||||
{locale === "EN" ? "Easy Returns" : "Lak povrat"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||
{locale === "EN" ? "Benefits" : "Prednosti"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{benefits.map((benefit, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-4 py-2 text-sm border border-[#e5e5e5] text-[#666666]"
|
||||
>
|
||||
{benefit.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Sections */}
|
||||
<div>
|
||||
<ExpandableSection title={locale === "EN" ? "Description" : "Opis"}>
|
||||
<div dangerouslySetInnerHTML={{ __html: localized.description }} />
|
||||
</ExpandableSection>
|
||||
|
||||
<ExpandableSection title={locale === "EN" ? "How to Use" : "Kako koristiti"}>
|
||||
<p>
|
||||
{locale === "EN"
|
||||
? "Apply a small amount to clean, damp hair or skin. Massage gently until absorbed. Use daily for best results."
|
||||
: "Nanesite malu količinu na čistu, vlažnu kosu ili kožu. Nežno masirajte dok se ne upije. Koristite svakodnevno za najbolje rezultate."
|
||||
}
|
||||
</p>
|
||||
</ExpandableSection>
|
||||
|
||||
<ExpandableSection title={locale === "EN" ? "Ingredients" : "Sastojci"}>
|
||||
<p>
|
||||
{locale === "EN"
|
||||
? "100% Pure Natural Oil. No additives, preservatives, or artificial fragrances."
|
||||
: "100% čisto prirodno ulje. Bez dodataka, konzervansa ili veštačkih mirisa."
|
||||
}
|
||||
</p>
|
||||
</ExpandableSection>
|
||||
</div>
|
||||
|
||||
{/* SKU */}
|
||||
{variant?.sku && (
|
||||
<p className="text-xs text-[#999999] mt-8">
|
||||
SKU: {variant.sku}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedProducts.length > 0 && (
|
||||
<section className="py-12 px-4 bg-background-ice">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h2 className="text-2xl font-serif text-center mb-8">
|
||||
You May Also Like
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{relatedProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
{/* Customer Reviews */}
|
||||
<ProductReviews locale={locale} productName={localized.name} />
|
||||
|
||||
{/* As Featured In - Full Width */}
|
||||
<AsSeenIn />
|
||||
|
||||
{/* Before/After Gallery */}
|
||||
<BeforeAfterGallery />
|
||||
|
||||
{/* Related Products */}
|
||||
{relatedProducts && relatedProducts.length > 0 && (
|
||||
<section className="py-20 lg:py-28 bg-[#f8f9fa]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
{locale === "EN" ? "You May Also Like" : "Možda će vam se svideti"}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium">
|
||||
{locale === "EN" ? "Similar Products" : "Slični proizvodi"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6 lg:gap-8">
|
||||
{relatedProducts.filter(p => p && p.id).slice(0, 4).map((relatedProduct, index) => (
|
||||
<div key={relatedProduct.id} className="w-full sm:w-[calc(50%-12px)] lg:w-[calc(25%-18px)]">
|
||||
<ProductCard
|
||||
product={relatedProduct}
|
||||
index={index}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Product Benefits */}
|
||||
<ProductBenefits locale={locale} />
|
||||
|
||||
{/* Trust Badges */}
|
||||
<TrustBadges />
|
||||
|
||||
{/* How It Works */}
|
||||
<HowItWorks />
|
||||
|
||||
{/* Newsletter */}
|
||||
<NewsletterSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
175
src/components/product/ProductReviews.tsx
Normal file
175
src/components/product/ProductReviews.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface ProductReviewsProps {
|
||||
locale?: string;
|
||||
productName?: string;
|
||||
}
|
||||
|
||||
const reviews = [
|
||||
{ id: 1, name: "Ana M.", location: "Belgrade", text: "Manoon Anti-age Serum transformed my skin in just 2 weeks!", rating: 5 },
|
||||
{ id: 2, name: "Milica P.", location: "Novi Sad", text: "The best day serum I've ever used. My wrinkles are visibly reduced.", rating: 5 },
|
||||
{ id: 3, name: "Jelena K.", location: "Belgrade", text: "Manoon night serum is pure magic. Wake up with glowing skin every morning.", rating: 5 },
|
||||
{ id: 4, name: "Stefan R.", location: "Subotica", text: "The Anti-age Set is worth every dinar. My wife and I both use it.", rating: 5 },
|
||||
{ id: 5, name: "Marija T.", location: "Kragujevac", text: "Finally found a serum that actually works! Manoon delivers on its promises.", rating: 5 },
|
||||
{ id: 6, name: "Nikola V.", location: "Niš", text: "My fine lines are disappearing. This day serum is incredible.", rating: 5 },
|
||||
{ id: 7, name: "Ivana L.", location: "Belgrade", text: "Manoon morning glow serum smells divine and works even better.", rating: 5 },
|
||||
{ id: 8, name: "Dejan M.", location: "Novi Sad", text: "The night serum has transformed my skincare routine completely.", rating: 5 },
|
||||
{ id: 9, name: "Sanja B.", location: "Kragujevac", text: "My skin looks 10 years younger after using Manoon for a month.", rating: 5 },
|
||||
{ id: 10, name: "Marko J.", location: "Subotica", text: "The anti-age set makes a perfect gift. My mother loves it!", rating: 5 },
|
||||
{ id: 11, name: "Petra D.", location: "Niš", text: "The texture of Manoon serum is so luxurious. Worth every penny.", rating: 5 },
|
||||
{ id: 12, name: "Luka G.", location: "Belgrade", text: "Day serum absorbs instantly. No greasy feeling at all!", rating: 5 },
|
||||
{ id: 13, name: "Maja S.", location: "Novi Sad", text: "My esthetician asked what I'm using. Manoon is now my secret!", rating: 5 },
|
||||
{ id: 14, name: "Vladimir P.", location: "Kragujevac", text: "The night serum works while I sleep. Wake up to visibly smoother skin.", rating: 5 },
|
||||
{ id: 15, name: "Katarina N.", location: "Subotica", text: "The Anti-age Set arrived beautifully packaged. Perfect for gifting.", rating: 5 },
|
||||
{ id: 16, name: "Bojan R.", location: "Niš", text: "Been using Manoon for 3 months. My wrinkles are noticeably reduced.", rating: 5 },
|
||||
{ id: 17, name: "Tamara F.", location: "Belgrade", text: "The day serum provides the perfect base under makeup.", rating: 5 },
|
||||
{ id: 18, name: "Aleksandar K.", location: "Novi Sad", text: "Finally a Serbian brand that competes with luxury international brands!", rating: 5 },
|
||||
{ id: 19, name: "Natalia M.", location: "Kragujevac", text: "My sensitive skin loves Manoon. No irritation at all.", rating: 5 },
|
||||
{ id: 20, name: "Filip T.", location: "Subotica", text: "The anti-age serum is lightweight yet incredibly effective.", rating: 5 },
|
||||
{ id: 21, name: "Andrea L.", location: "Niš", text: "Manoon night serum is my evening ritual. Skin looks amazing!", rating: 5 },
|
||||
{ id: 22, name: "Ognjen P.", location: "Belgrade", text: "My friends keep asking what changed in my skincare routine.", rating: 5 },
|
||||
{ id: 23, name: "Mila J.", location: "Novi Sad", text: "The Anti-age Set includes everything you need. Great value!", rating: 5 },
|
||||
{ id: 24, name: "Dragan S.", location: "Kragujevac", text: "Even my husband noticed the difference. He now uses the day serum too!", rating: 5 },
|
||||
{ id: 25, name: "Jovana V.", location: "Subotica", text: "The morning glow serum gives the most beautiful luminosity.", rating: 5 },
|
||||
{ id: 26, name: "Stefan M.", location: "Niš", text: "Manoon products are now essential in my daily routine.", rating: 5 },
|
||||
{ id: 27, name: "Ana R.", location: "Belgrade", text: "The night serum helped clear my complexion. Skin looks so healthy!", rating: 5 },
|
||||
{ id: 28, name: "Nenad L.", location: "Novi Sad", text: "Anti-aging results visible within weeks. Highly recommend Manoon!", rating: 5 },
|
||||
{ id: 29, name: "Sofija D.", location: "Kragujevac", text: "The texture is divine. Feels like a luxury spa treatment at home.", rating: 5 },
|
||||
{ id: 30, name: "Velibor K.", location: "Subotica", text: "My crow's feet have diminished significantly. Thank you Manoon!", rating: 5 },
|
||||
{ id: 31, name: "Irena M.", location: "Niš", text: "The Anti-age Set makes the perfect birthday gift for my mother.", rating: 5 },
|
||||
{ id: 32, name: "Radoslav P.", location: "Belgrade", text: "Professional quality serum at an honest price. Serbian excellence!", rating: 5 },
|
||||
{ id: 33, name: "Jelena B.", location: "Novi Sad", text: "My skin has never been this hydrated. Day serum is amazing!", rating: 5 },
|
||||
{ id: 34, name: "Dimitrije S.", location: "Kragujevac", text: "The night serum is worth its weight in gold. Pure luxury!", rating: 5 },
|
||||
{ id: 35, name: "Minela G.", location: "Subotica", text: "Manoon lives up to the hype. My skin looks refreshed and young.", rating: 5 },
|
||||
{ id: 36, name: "Zoran T.", location: "Niš", text: "I've tried many serums. Manoon is by far the most effective.", rating: 5 },
|
||||
{ id: 37, name: "Mirjana F.", location: "Belgrade", text: "The Anti-age Set transformed my mother's skincare routine completely.", rating: 5 },
|
||||
{ id: 38, name: "Ivan J.", location: "Novi Sad", text: "Fast-acting serum with real results. I recommend Manoon to everyone.", rating: 5 },
|
||||
{ id: 39, name: "Kristina P.", location: "Kragujevac", text: "The morning glow serum gives such a beautiful dewy finish.", rating: 5 },
|
||||
{ id: 40, name: "Bratislav L.", location: "Subotica", text: "Noticeable results in just 2 weeks. This serum is the real deal!", rating: 5 },
|
||||
{ id: 41, name: "Zorica M.", location: "Niš", text: "The night serum erased years from my face. Absolutely miraculous!", rating: 5 },
|
||||
{ id: 42, name: "Patrik N.", location: "Belgrade", text: "Premium quality Serbian skincare that rivals international luxury brands.", rating: 5 },
|
||||
{ id: 43, name: "Simona K.", location: "Novi Sad", text: "Manoon Anti-age Serum is the best investment in my skin ever.", rating: 5 },
|
||||
{ id: 44, name: "Mladen D.", location: "Kragujevac", text: "The day serum absorbs in seconds. No waiting around!", rating: 5 },
|
||||
{ id: 45, name: "Ljiljana R.", location: "Subotica", text: "Gifting the Anti-age Set to my sisters. They loved it!", rating: 5 },
|
||||
{ id: 46, name: "Tomislav V.", location: "Niš", text: "My wrinkles are visibly reduced after using Manoon for a month.", rating: 5 },
|
||||
{ id: 47, name: "Emilija S.", location: "Belgrade", text: "The night serum leaves my skin so soft and renewed every morning.", rating: 5 },
|
||||
{ id: 48, name: "Andrija P.", location: "Novi Sad", text: "Manoon day serum is perfect under sunscreen. Essential duo!", rating: 5 },
|
||||
{ id: 49, name: "Miona L.", location: "Kragujevac", text: "My skin looks radiant and youthful. Couldn't be happier with Manoon!", rating: 5 },
|
||||
{ id: 50, name: "Slavko M.", location: "Subotica", text: "The Anti-age Set delivers visible results. True Serbian quality!", rating: 5 },
|
||||
];
|
||||
|
||||
function ReviewCard({ review }: { review: typeof reviews[0] }) {
|
||||
return (
|
||||
<div className="flex-shrink-0 w-80 bg-white p-6 rounded-2xl shadow-sm border border-[#f0ede8] mx-3">
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 fill-gold text-gold" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[#444444] text-sm leading-relaxed mb-4">"{review.text}"</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#1a1a1a] flex items-center justify-center text-white text-sm font-medium">
|
||||
{review.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{review.name}</p>
|
||||
<p className="text-xs text-[#888888]">{review.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductReviews({ locale = "SR", productName = "this product" }: ProductReviewsProps) {
|
||||
return (
|
||||
<section className="py-16 bg-[#faf9f7] overflow-hidden">
|
||||
<div className="container mx-auto px-4 mb-8">
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
|
||||
Customer Reviews
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-medium">
|
||||
What Customers Say
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mt-4">
|
||||
<span className="text-5xl font-bold text-[#1a1a1a]">4.9</span>
|
||||
<div>
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-5 h-5 fill-gold text-gold" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-[#666666] mt-1">Based on 50 reviews</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scrolling Reviews Marquee */}
|
||||
<div className="relative">
|
||||
{/* Left gradient fade */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-20 bg-gradient-to-r from-[#faf9f7] to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-20 bg-gradient-to-l from-[#faf9f7] to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* First row - left to right */}
|
||||
<div className="flex overflow-hidden mb-4">
|
||||
<motion.div
|
||||
className="flex items-center gap-0"
|
||||
animate={{
|
||||
x: [0, -50 + "%"],
|
||||
}}
|
||||
transition={{
|
||||
x: {
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
duration: 120,
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{[...reviews, ...reviews].map((review, index) => (
|
||||
<ReviewCard key={`first-${index}-${review.id}`} review={review} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Second row - right to left */}
|
||||
<div className="flex overflow-hidden">
|
||||
<motion.div
|
||||
className="flex items-center gap-0"
|
||||
animate={{
|
||||
x: [-50 + "%", 0],
|
||||
}}
|
||||
transition={{
|
||||
x: {
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
duration: 120,
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{[...reviews.slice(25), ...reviews.slice(0, 25), ...reviews.slice(25), ...reviews.slice(0, 25)].map((review, index) => (
|
||||
<ReviewCard key={`second-${index}-${review.id}`} review={review} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
63
src/components/providers/ErrorBoundary.tsx
Normal file
63
src/components/providers/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Ignore browser extension errors
|
||||
if (error.message?.includes('tron') ||
|
||||
error.message?.includes('chrome-extension') ||
|
||||
error.stack?.includes('chrome-extension')) {
|
||||
console.warn('Browser extension error ignored:', error.message);
|
||||
// Reset error state to continue rendering
|
||||
this.setState({ hasError: false });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
// Check if it's an extension error
|
||||
if (this.state.error?.message?.includes('tron') ||
|
||||
this.state.error?.stack?.includes('chrome-extension')) {
|
||||
// Silently recover and render children
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-serif mb-4">Something went wrong</h2>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
className="px-6 py-3 bg-foreground text-white"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
45
src/lib/saleor/client.ts
Normal file
45
src/lib/saleor/client.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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) {
|
||||
return incoming;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: "cache-first",
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "cache-first",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default saleorClient;
|
||||
74
src/lib/saleor/fragments/Checkout.ts
Normal file
74
src/lib/saleor/fragments/Checkout.ts
Normal 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}
|
||||
`;
|
||||
81
src/lib/saleor/fragments/Product.ts
Normal file
81
src/lib/saleor/fragments/Product.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
84
src/lib/saleor/fragments/Variant.ts
Normal file
84
src/lib/saleor/fragments/Variant.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
36
src/lib/saleor/index.ts
Normal file
36
src/lib/saleor/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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,
|
||||
parseDescription,
|
||||
} from "./products";
|
||||
154
src/lib/saleor/mutations/Checkout.ts
Normal file
154
src/lib/saleor/mutations/Checkout.ts
Normal 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}
|
||||
`;
|
||||
151
src/lib/saleor/products.ts
Normal file
151
src/lib/saleor/products.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { saleorClient } from "./client";
|
||||
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products";
|
||||
import type { Product } from "@/types/saleor";
|
||||
|
||||
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
||||
|
||||
// GraphQL Response Types
|
||||
interface ProductsResponse {
|
||||
products?: {
|
||||
edges: Array<{ node: Product }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductResponse {
|
||||
product?: Product | null;
|
||||
}
|
||||
|
||||
export async function getProducts(
|
||||
locale: string = "SR",
|
||||
first: number = 100
|
||||
): Promise<Product[]> {
|
||||
try {
|
||||
const { data } = await saleorClient.query<ProductsResponse>({
|
||||
query: GET_PRODUCTS,
|
||||
variables: {
|
||||
channel: CHANNEL,
|
||||
locale: locale.toUpperCase(),
|
||||
first,
|
||||
},
|
||||
});
|
||||
|
||||
return data?.products?.edges.map((edge) => 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<ProductResponse>({
|
||||
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);
|
||||
}
|
||||
|
||||
// Parse Saleor's JSON description format (EditorJS) to plain text/HTML
|
||||
export function parseDescription(description: string | null | undefined): string {
|
||||
if (!description) return "";
|
||||
|
||||
// If it's already plain text (not JSON), return as-is
|
||||
if (!description.startsWith("{")) {
|
||||
return description;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(description);
|
||||
|
||||
// Handle EditorJS format: { blocks: [{ data: { text: "..." } }] }
|
||||
if (parsed.blocks && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks
|
||||
.map((block: any) => {
|
||||
if (block.data?.text) {
|
||||
return block.data.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
// Fallback: return stringified if unknown format
|
||||
return description;
|
||||
} catch (e) {
|
||||
// If JSON parse fails, return original
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
const rawDescription = translation?.description || product.description;
|
||||
|
||||
return {
|
||||
name: translation?.name || product.name,
|
||||
slug: translation?.slug || product.slug,
|
||||
description: parseDescription(rawDescription),
|
||||
seoTitle: translation?.seoTitle || product.seoTitle,
|
||||
seoDescription: translation?.seoDescription || product.seoDescription,
|
||||
};
|
||||
}
|
||||
20
src/lib/saleor/queries/Checkout.ts
Normal file
20
src/lib/saleor/queries/Checkout.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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}
|
||||
`;
|
||||
51
src/lib/saleor/queries/Products.ts
Normal file
51
src/lib/saleor/queries/Products.ts
Normal 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}
|
||||
`;
|
||||
@@ -1,128 +0,0 @@
|
||||
import WooCommerceRestApi from "@woocommerce/woocommerce-rest-api";
|
||||
|
||||
// Lazy initialization - only create API client when needed
|
||||
let apiInstance: WooCommerceRestApi | null = null;
|
||||
|
||||
function getApi(): WooCommerceRestApi {
|
||||
if (!apiInstance) {
|
||||
const url = process.env.NEXT_PUBLIC_WOOCOMMERCE_URL;
|
||||
const consumerKey = process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY;
|
||||
const consumerSecret = process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET;
|
||||
|
||||
if (!url || !consumerKey || !consumerSecret) {
|
||||
throw new Error("WooCommerce API credentials not configured");
|
||||
}
|
||||
|
||||
apiInstance = new WooCommerceRestApi({
|
||||
url,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
version: "wc/v3",
|
||||
});
|
||||
}
|
||||
return apiInstance;
|
||||
}
|
||||
|
||||
export interface WooProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: string;
|
||||
regular_price: string;
|
||||
sale_price: string;
|
||||
description: string;
|
||||
short_description: string;
|
||||
status: "publish" | "draft" | "private";
|
||||
stock_status: "instock" | "outofstock";
|
||||
images: { id: number; src: string; alt: string }[];
|
||||
sku: string;
|
||||
categories: { id: number; name: string; slug: string }[];
|
||||
meta_data: { key: string; value: string }[];
|
||||
}
|
||||
|
||||
export interface WooCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
image: { src: string } | null;
|
||||
}
|
||||
|
||||
export async function getProducts(perPage = 100): Promise<WooProduct[]> {
|
||||
try {
|
||||
const api = getApi();
|
||||
const response = await api.get("products", { per_page: perPage });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching products:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProduct(id: number): Promise<WooProduct | null> {
|
||||
try {
|
||||
const api = getApi();
|
||||
const response = await api.get(`products/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching product ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string): Promise<WooProduct | null> {
|
||||
try {
|
||||
const api = getApi();
|
||||
const response = await api.get("products", { slug });
|
||||
return response.data[0] || null;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching product by slug ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategories(): Promise<WooCategory[]> {
|
||||
try {
|
||||
const api = getApi();
|
||||
const response = await api.get("product-categories", { per_page: 100 });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching categories:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductsByCategory(
|
||||
categoryId: number
|
||||
): Promise<WooProduct[]> {
|
||||
try {
|
||||
const api = getApi();
|
||||
const response = await api.get("products", {
|
||||
category: categoryId,
|
||||
per_page: 100,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching products for category ${categoryId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(price: string, currency = "RSD"): string {
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return "0 RSD";
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export function getProductImage(product: WooProduct): string {
|
||||
if (product.images && product.images.length > 0) {
|
||||
return product.images[0].src;
|
||||
}
|
||||
return "/placeholder-product.jpg";
|
||||
}
|
||||
|
||||
export default getApi;
|
||||
@@ -1,14 +0,0 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
const routing = defineRouting({
|
||||
locales: ['sr', 'en'],
|
||||
defaultLocale: 'sr',
|
||||
localePrefix: 'as-needed'
|
||||
});
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
price: string;
|
||||
quantity: number;
|
||||
image: string;
|
||||
sku: string;
|
||||
}
|
||||
|
||||
interface CartStore {
|
||||
items: CartItem[];
|
||||
isOpen: boolean;
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: number) => void;
|
||||
updateQuantity: (id: number, quantity: number) => void;
|
||||
toggleCart: () => void;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
clearCart: () => void;
|
||||
getTotal: () => number;
|
||||
getItemCount: () => number;
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
items: [],
|
||||
isOpen: false,
|
||||
|
||||
addItem: (item) => {
|
||||
const items = get().items;
|
||||
const existingItem = items.find((i) => i.id === item.id);
|
||||
|
||||
if (existingItem) {
|
||||
set({
|
||||
items: items.map((i) =>
|
||||
i.id === item.id
|
||||
? { ...i, quantity: i.quantity + item.quantity }
|
||||
: i
|
||||
),
|
||||
});
|
||||
} else {
|
||||
set({ items: [...items, item] });
|
||||
}
|
||||
set({ isOpen: true });
|
||||
},
|
||||
|
||||
removeItem: (id) => {
|
||||
set({ items: get().items.filter((i) => i.id !== id) });
|
||||
},
|
||||
|
||||
updateQuantity: (id, quantity) => {
|
||||
if (quantity <= 0) {
|
||||
set({ items: get().items.filter((i) => i.id !== id) });
|
||||
} else {
|
||||
set({
|
||||
items: get().items.map((i) =>
|
||||
i.id === id ? { ...i, quantity } : i
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
toggleCart: () => set({ isOpen: !get().isOpen }),
|
||||
openCart: () => set({ isOpen: true }),
|
||||
closeCart: () => set({ isOpen: false }),
|
||||
clearCart: () => set({ items: [] }),
|
||||
|
||||
getTotal: () => {
|
||||
return get().items.reduce((total, item) => {
|
||||
return total + parseFloat(item.price) * item.quantity;
|
||||
}, 0);
|
||||
},
|
||||
|
||||
getItemCount: () => {
|
||||
return get().items.reduce((count, item) => count + item.quantity, 0);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "manoonoils-cart",
|
||||
}
|
||||
)
|
||||
);
|
||||
321
src/stores/saleorCheckoutStore.ts
Normal file
321
src/stores/saleorCheckoutStore.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { saleorClient } from "@/lib/saleor/client";
|
||||
import {
|
||||
CHECKOUT_CREATE,
|
||||
CHECKOUT_LINES_ADD,
|
||||
CHECKOUT_LINES_UPDATE,
|
||||
CHECKOUT_LINES_DELETE,
|
||||
CHECKOUT_EMAIL_UPDATE,
|
||||
} from "@/lib/saleor/mutations/Checkout";
|
||||
import { GET_CHECKOUT } from "@/lib/saleor/queries/Checkout";
|
||||
import type { Checkout, CheckoutLine } from "@/types/saleor";
|
||||
|
||||
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
||||
|
||||
// GraphQL Response Types
|
||||
interface CheckoutCreateResponse {
|
||||
checkoutCreate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutLinesAddResponse {
|
||||
checkoutLinesAdd?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutLinesUpdateResponse {
|
||||
checkoutLinesUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutLinesDeleteResponse {
|
||||
checkoutLinesDelete?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutEmailUpdateResponse {
|
||||
checkoutEmailUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetCheckoutResponse {
|
||||
checkout?: Checkout;
|
||||
}
|
||||
|
||||
interface SaleorCheckoutStore {
|
||||
checkout: Checkout | null;
|
||||
checkoutToken: string | null;
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
initCheckout: () => Promise<void>;
|
||||
addLine: (variantId: string, quantity: number) => Promise<void>;
|
||||
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
||||
removeLine: (lineId: string) => Promise<void>;
|
||||
setEmail: (email: string) => Promise<void>;
|
||||
refreshCheckout: () => Promise<void>;
|
||||
toggleCart: () => void;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
clearError: () => void;
|
||||
|
||||
// Getters
|
||||
getLineCount: () => number;
|
||||
getTotal: () => number;
|
||||
getLines: () => CheckoutLine[];
|
||||
}
|
||||
|
||||
export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
checkout: null,
|
||||
checkoutToken: null,
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
initCheckout: async () => {
|
||||
const { checkoutToken } = get();
|
||||
|
||||
if (checkoutToken) {
|
||||
// Try to fetch existing checkout
|
||||
try {
|
||||
const { data } = await saleorClient.query<GetCheckoutResponse>({
|
||||
query: GET_CHECKOUT,
|
||||
variables: { token: checkoutToken },
|
||||
});
|
||||
|
||||
if (data?.checkout) {
|
||||
set({ checkout: data.checkout });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Checkout not found or expired, create new one
|
||||
}
|
||||
}
|
||||
|
||||
// Create new checkout
|
||||
try {
|
||||
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
|
||||
mutation: CHECKOUT_CREATE,
|
||||
variables: {
|
||||
input: {
|
||||
channel: CHANNEL,
|
||||
lines: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.checkoutCreate?.checkout) {
|
||||
set({
|
||||
checkout: data.checkoutCreate.checkout,
|
||||
checkoutToken: data.checkoutCreate.checkout.token,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
set({ error: e.message });
|
||||
}
|
||||
},
|
||||
|
||||
addLine: async (variantId: string, quantity: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
let { checkout, checkoutToken } = get();
|
||||
|
||||
// Initialize checkout if needed
|
||||
if (!checkout) {
|
||||
await get().initCheckout();
|
||||
checkout = get().checkout;
|
||||
checkoutToken = get().checkoutToken;
|
||||
}
|
||||
|
||||
if (!checkout) {
|
||||
throw new Error("Failed to initialize checkout");
|
||||
}
|
||||
|
||||
const { data } = await saleorClient.mutate<CheckoutLinesAddResponse>({
|
||||
mutation: CHECKOUT_LINES_ADD,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
lines: [{ variantId, quantity }],
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.checkoutLinesAdd?.checkout) {
|
||||
set({
|
||||
checkout: data.checkoutLinesAdd.checkout,
|
||||
isOpen: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} else if (data?.checkoutLinesAdd?.errors && data.checkoutLinesAdd.errors.length > 0) {
|
||||
throw new Error(data.checkoutLinesAdd.errors[0].message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
set({ error: e.message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateLine: async (lineId: string, quantity: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { checkout } = get();
|
||||
|
||||
if (!checkout) {
|
||||
throw new Error("No active checkout");
|
||||
}
|
||||
|
||||
if (quantity <= 0) {
|
||||
// Remove line if quantity is 0 or less
|
||||
await get().removeLine(lineId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await saleorClient.mutate<CheckoutLinesUpdateResponse>({
|
||||
mutation: CHECKOUT_LINES_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
lines: [{ lineId, quantity }],
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.checkoutLinesUpdate?.checkout) {
|
||||
set({
|
||||
checkout: data.checkoutLinesUpdate.checkout,
|
||||
isLoading: false,
|
||||
});
|
||||
} else if (data?.checkoutLinesUpdate?.errors && data.checkoutLinesUpdate.errors.length > 0) {
|
||||
throw new Error(data.checkoutLinesUpdate.errors[0].message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
set({ error: e.message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
removeLine: async (lineId: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { checkout } = get();
|
||||
|
||||
if (!checkout) {
|
||||
throw new Error("No active checkout");
|
||||
}
|
||||
|
||||
const { data } = await saleorClient.mutate<CheckoutLinesDeleteResponse>({
|
||||
mutation: CHECKOUT_LINES_DELETE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
lineIds: [lineId],
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.checkoutLinesDelete?.checkout) {
|
||||
set({
|
||||
checkout: data.checkoutLinesDelete.checkout,
|
||||
isLoading: false,
|
||||
});
|
||||
} else if (data?.checkoutLinesDelete?.errors && data.checkoutLinesDelete.errors.length > 0) {
|
||||
throw new Error(data.checkoutLinesDelete.errors[0].message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
set({ error: e.message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setEmail: async (email: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { checkout } = get();
|
||||
|
||||
if (!checkout) {
|
||||
throw new Error("No active checkout");
|
||||
}
|
||||
|
||||
const { data } = await saleorClient.mutate<CheckoutEmailUpdateResponse>({
|
||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.checkoutEmailUpdate?.checkout) {
|
||||
set({
|
||||
checkout: data.checkoutEmailUpdate.checkout,
|
||||
isLoading: false,
|
||||
});
|
||||
} else if (data?.checkoutEmailUpdate?.errors && data.checkoutEmailUpdate.errors.length > 0) {
|
||||
throw new Error(data.checkoutEmailUpdate.errors[0].message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
set({ error: e.message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshCheckout: async () => {
|
||||
const { checkoutToken } = get();
|
||||
|
||||
if (!checkoutToken) return;
|
||||
|
||||
try {
|
||||
const { data } = await saleorClient.query<GetCheckoutResponse>({
|
||||
query: GET_CHECKOUT,
|
||||
variables: { token: checkoutToken },
|
||||
});
|
||||
|
||||
if (data?.checkout) {
|
||||
set({ checkout: data.checkout });
|
||||
}
|
||||
} catch (e) {
|
||||
// Checkout might be expired
|
||||
set({ checkout: null, checkoutToken: null });
|
||||
}
|
||||
},
|
||||
|
||||
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
openCart: () => set({ isOpen: true }),
|
||||
closeCart: () => set({ isOpen: false }),
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
getLineCount: () => {
|
||||
const { checkout } = get();
|
||||
if (!checkout?.lines) return 0;
|
||||
return checkout.lines.reduce((count, line) => count + line.quantity, 0);
|
||||
},
|
||||
|
||||
getTotal: () => {
|
||||
const { checkout } = get();
|
||||
return checkout?.totalPrice?.gross?.amount || 0;
|
||||
},
|
||||
|
||||
getLines: () => {
|
||||
const { checkout } = get();
|
||||
return checkout?.lines || [];
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "manoonoils-saleor-checkout",
|
||||
partialize: (state) => ({
|
||||
checkoutToken: state.checkoutToken,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
191
src/types/saleor.ts
Normal file
191
src/types/saleor.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user