Compare commits
143 Commits
d977bc9a42
...
feature/bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f63c32f8 | ||
|
|
3d8a77dafa | ||
|
|
bfce7dcca0 | ||
|
|
8f780c3585 | ||
|
|
9a61564e3c | ||
|
|
28a6e58dba | ||
|
|
569a3e65fe | ||
|
|
1ba81a1fde | ||
|
|
df95e729fc | ||
|
|
b18ab349b6 | ||
|
|
855215badd | ||
|
|
f40e661bf3 | ||
|
|
080a9e4e21 | ||
|
|
44f4e548c8 | ||
|
|
5ae79716a3 | ||
|
|
922978bf80 | ||
|
|
930a9a7614 | ||
|
|
3d895f4d7a | ||
|
|
ab5b5d9848 | ||
|
|
8a76342b07 | ||
|
|
95c844ad2b | ||
|
|
22b0b2c31a | ||
|
|
5f0ef80fe7 | ||
|
|
9a72e46d39 | ||
|
|
8120f2b908 | ||
|
|
b7914303ee | ||
|
|
c40d91e35b | ||
|
|
5ee3ab6713 | ||
|
|
03becb6ce7 | ||
|
|
0a7c555549 | ||
|
|
74ab98ad2f | ||
|
|
ead03bc04f | ||
|
|
a5cd048a6e | ||
|
|
a4e7a07adb | ||
|
|
52b2eac5b5 | ||
|
|
bd95705d72 | ||
|
|
75b258330a | ||
|
|
4d078677cb | ||
|
|
b488671bc3 | ||
|
|
b70d46ff95 | ||
|
|
f95585af58 | ||
|
|
a84647db6c | ||
|
|
8244ba161b | ||
|
|
887cd7c610 | ||
|
|
513dcb7fea | ||
|
|
92b6c830e1 | ||
|
|
5bd1a0f167 | ||
|
|
bcc51ce282 | ||
|
|
f72f32fe60 | ||
|
|
ace1ac104e | ||
|
|
7f603c83e9 | ||
|
|
0e9ad28dcf | ||
|
|
70d6cfc9a7 | ||
|
|
f3d60d3c5b | ||
|
|
7ecd9c2e22 | ||
|
|
e9b95c44b9 | ||
|
|
8a418be7c3 | ||
|
|
ba25261a3c | ||
|
|
77e19d841b | ||
|
|
43d662b54e | ||
|
|
625bd727d3 | ||
|
|
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 | ||
|
|
0fab8b6d42 | ||
|
|
2c8cf68e89 | ||
|
|
c45aefde6e | ||
|
|
42793da45f | ||
|
|
8aa849f4ba | ||
|
|
40b80b1ad0 | ||
|
|
7d23176b6a | ||
|
|
5df87cbb9d | ||
|
|
0f5f009512 | ||
|
|
9cd8b19787 | ||
|
|
1bef68c360 | ||
|
|
8a720f5335 | ||
|
|
927dfc45e7 | ||
|
|
dc05b673a2 | ||
|
|
7883fc61c8 | ||
|
|
080b59a107 | ||
|
|
93647a9e05 |
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
|
||||||
24
.github/workflows/deploy.yml
vendored
24
.github/workflows/deploy.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: Deploy to Production
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master, main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Deploy to server
|
|
||||||
uses: appleboy/ssh-action@v0.1.0
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.HOST }}
|
|
||||||
username: root
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd /root/manoonoils-store
|
|
||||||
git pull origin master
|
|
||||||
npm run build
|
|
||||||
docker build -t manoonoils-store:latest .
|
|
||||||
docker stop manoonoils-store && docker rm manoonoils-store
|
|
||||||
docker run -d --name manoonoils-store -p 3000:3000 --env-file .env.local manoonoils-store:latest
|
|
||||||
docker network connect coolify manoonoils-store
|
|
||||||
135
ASSET_INVENTORY.md
Normal file
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
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS deps
|
# Copy package files
|
||||||
COPY package.json package-lock.json* ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install --prefer-offline --no-audit
|
||||||
|
|
||||||
FROM base AS builder
|
# Copy source and build
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM base AS runner
|
# Production stage
|
||||||
|
FROM node:20-slim AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
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 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"]
|
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
|
||||||
@@ -34,3 +34,8 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
|||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
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
|
||||||
142
k8s/deployment.yaml
Normal file
142
k8s/deployment.yaml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: storefront
|
||||||
|
namespace: manoonoils
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: storefront
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: storefront
|
||||||
|
spec:
|
||||||
|
initContainers:
|
||||||
|
- name: clone
|
||||||
|
image: alpine/git:latest
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch master \
|
||||||
|
http://gitea.gitea.svc.cluster.local:3000/unchained/manoon-headless.git \
|
||||||
|
/workspace
|
||||||
|
echo "Clone complete."
|
||||||
|
volumeMounts:
|
||||||
|
- name: workspace
|
||||||
|
mountPath: /workspace
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
- name: install
|
||||||
|
image: node:20-slim
|
||||||
|
workingDir: /workspace
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
npm install --prefer-offline --no-audit 2>&1
|
||||||
|
echo "Dependencies installed."
|
||||||
|
volumeMounts:
|
||||||
|
- name: workspace
|
||||||
|
mountPath: /workspace
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 2000m
|
||||||
|
memory: 3Gi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 1Gi
|
||||||
|
- name: build
|
||||||
|
image: node:20-slim
|
||||||
|
workingDir: /workspace
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
echo "Building Next.js app..."
|
||||||
|
npm run build
|
||||||
|
echo "Build complete!"
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
- name: NEXT_PUBLIC_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: node:20-slim
|
||||||
|
workingDir: /workspace
|
||||||
|
command:
|
||||||
|
- npm
|
||||||
|
- start
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
- name: PORT
|
||||||
|
value: "3000"
|
||||||
|
- name: HOSTNAME
|
||||||
|
value: "0.0.0.0"
|
||||||
|
- name: NEXT_PUBLIC_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: /favicon.ico
|
||||||
|
port: 3000
|
||||||
|
periodSeconds: 10
|
||||||
|
failureThreshold: 30
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /favicon.ico
|
||||||
|
port: 3000
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /favicon.ico
|
||||||
|
port: 3000
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
volumeMounts:
|
||||||
|
- name: workspace
|
||||||
|
mountPath: /workspace
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 2Gi
|
||||||
17
k8s/ingress.yaml
Normal file
17
k8s/ingress.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: storefront
|
||||||
|
namespace: manoonoils
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
- websecure
|
||||||
|
routes:
|
||||||
|
- match: Host(`dev.manoonoils.com`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: storefront
|
||||||
|
port: 3000
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
9
k8s/kustomization.yaml
Normal file
9
k8s/kustomization.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
images:
|
||||||
|
- name: ghcr.io/unchainedio/manoon-headless
|
||||||
|
newTag: 2c27fc6 # Updated by GitHub Actions
|
||||||
12
k8s/service.yaml
Normal file
12
k8s/service.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: storefront
|
||||||
|
namespace: manoonoils
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: storefront
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: 3000
|
||||||
|
type: ClusterIP
|
||||||
466
mautic-abandoned-cart.md
Normal file
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
|
||||||
51
middleware.ts
Normal file
51
middleware.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, getPathWithoutLocale, buildLocalePath, isValidLocale } from "@/lib/i18n/locales";
|
||||||
|
import type { Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
|
const OLD_SERBIAN_PATHS = ["products", "about", "contact", "checkout"];
|
||||||
|
|
||||||
|
function detectLocale(cookieLocale: string | undefined, acceptLanguage: string): Locale {
|
||||||
|
if (cookieLocale && isValidLocale(cookieLocale)) {
|
||||||
|
return cookieLocale;
|
||||||
|
}
|
||||||
|
if (acceptLanguage.includes("en")) {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function middleware(request: NextRequest) {
|
||||||
|
const pathname = request.nextUrl.pathname;
|
||||||
|
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
|
||||||
|
const acceptLanguage = request.headers.get("accept-language") || "";
|
||||||
|
|
||||||
|
if (pathname === "/" || pathname === "") {
|
||||||
|
const locale = detectLocale(cookieLocale, acceptLanguage);
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = buildLocalePath(locale, "/");
|
||||||
|
return NextResponse.redirect(url, 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOldSerbianPath = OLD_SERBIAN_PATHS.some(
|
||||||
|
(path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isOldSerbianPath) {
|
||||||
|
const locale = detectLocale(cookieLocale, acceptLanguage);
|
||||||
|
const newPath = buildLocalePath(locale, pathname);
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = newPath;
|
||||||
|
return NextResponse.redirect(url, 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/",
|
||||||
|
"/(sr|en|de|fr)/:path*",
|
||||||
|
"/((?!api|_next|_vercel|.*\\..*).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
240
moumoujus-specification.md
Normal file
240
moumoujus-specification.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# MOUMOUJUS.COM - Complete Technical Specification
|
||||||
|
|
||||||
|
## 1. TECH STACK
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|------------|
|
||||||
|
| **Framework** | Next.js 15 (App Router) |
|
||||||
|
| **Language** | TypeScript |
|
||||||
|
| **Styling** | Tailwind CSS |
|
||||||
|
| **Build Tool** | Turbopack |
|
||||||
|
| **Hosting** | Vercel |
|
||||||
|
| **Fonts** | DM Sans (body), Cedrat Display (headings) |
|
||||||
|
| **Animations** | CSS Animations + Framer Motion (scroll animations) |
|
||||||
|
| **Push Notifications** | OneSignal |
|
||||||
|
| **Images** | WebP format, Next.js Image optimization |
|
||||||
|
|
||||||
|
## 2. COLOR PALETTE
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `bg-ice-blue` | `#E8F4F8` | Primary background, hero sections |
|
||||||
|
| `bg-ice-blue-light` | `#F0F7FA` | Secondary background, testimonials |
|
||||||
|
| `text-heading` | `#1A1A1A` | Headlines, primary text |
|
||||||
|
| `text-foreground` | `#4A4A4A` | Body text |
|
||||||
|
| `text-muted` | `#6B7280` | Labels, captions |
|
||||||
|
| `border-border-light` | `rgba(26,26,26,0.06)` | Subtle borders |
|
||||||
|
| `text-success` | `#10B981` | Success states, checkmarks |
|
||||||
|
| `white` | `#FFFFFF` | Cards, overlays |
|
||||||
|
|
||||||
|
## 3. TYPOGRAPHY
|
||||||
|
|
||||||
|
### Primary Font (Body): DM Sans
|
||||||
|
- Weights: 300, 400, 500, 600, 700
|
||||||
|
- Fallback: Arial, sans-serif
|
||||||
|
|
||||||
|
### Secondary Font (Headings): Cedrat Display
|
||||||
|
- Weights: 100, 300, 400, 700 + Italic variants
|
||||||
|
- Used for: `.font-serif` class (all headlines)
|
||||||
|
|
||||||
|
### Typography Scale
|
||||||
|
| Element | Size | Weight | Line Height | Tracking |
|
||||||
|
|---------|------|--------|-------------|----------|
|
||||||
|
| H1 (Hero) | `text-3xl` → `text-5xl` | 400 | `leading-[1.15]` | `tracking-tight` |
|
||||||
|
| H2 (Section) | `text-4xl` → `text-6xl` | 400 italic | `leading-[1.1]` | `tracking-tight` |
|
||||||
|
| H3 (Cards) | `text-lg` → `text-xl` | 500 | Normal | `tracking-wide` |
|
||||||
|
| Body | `text-base` | 400 | `leading-relaxed` | Normal |
|
||||||
|
| Label | `text-xs` | 500 | Normal | `tracking-[0.3em]` uppercase |
|
||||||
|
| Caption | `text-[11px]` | 500 | Normal | `tracking-wider` uppercase |
|
||||||
|
|
||||||
|
## 4. HEADER / ANNOUNCEMENT BAR
|
||||||
|
|
||||||
|
**Position:** Fixed top, full width, z-index 50
|
||||||
|
**Background:** `bg-ice-blue` (light blue)
|
||||||
|
**Height:** ~40px
|
||||||
|
|
||||||
|
### Marquee Animation
|
||||||
|
- **Content:** Repeating text: "CERAMIDE MOISTURISER FOR BARRIER REPAIR" with arrows (→)
|
||||||
|
- **Animation:** `animate-marquee` - infinite horizontal scroll left
|
||||||
|
- **Speed:** ~30 seconds per cycle
|
||||||
|
- **Icon:** Lucide ArrowRight
|
||||||
|
- **Gap between items:** `mx-10 lg:mx-16`
|
||||||
|
- **Typography:** `text-xs tracking-[0.12em] uppercase text-heading/70`
|
||||||
|
|
||||||
|
### Navigation Bar (Sticky)
|
||||||
|
**Height:** 64px
|
||||||
|
**Max Width:** 1400px
|
||||||
|
**Padding:** `px-6`
|
||||||
|
|
||||||
|
#### Left Side:
|
||||||
|
- Logo: SVG format
|
||||||
|
- Height: `h-5 lg:h-6`
|
||||||
|
|
||||||
|
#### Right Side Icons:
|
||||||
|
1. Account/User
|
||||||
|
2. Shopping Bag
|
||||||
|
3. Mobile Menu (Hamburger)
|
||||||
|
|
||||||
|
## 5. HERO SECTION
|
||||||
|
|
||||||
|
**Height:** `100vh` (full viewport)
|
||||||
|
**Position:** `relative`, `overflow-hidden`
|
||||||
|
|
||||||
|
### Background
|
||||||
|
**Image:** Full cover hero image
|
||||||
|
**Overlay:** None visible
|
||||||
|
|
||||||
|
### Mobile Hero Text (Bottom Center)
|
||||||
|
**Position:** Absolute, flex justify-end
|
||||||
|
**Headline:**
|
||||||
|
- Font: Cedrat Display, italic
|
||||||
|
- Size: `text-3xl`
|
||||||
|
- Color: White
|
||||||
|
- Text: "Barrier Repair, Real Results"
|
||||||
|
|
||||||
|
### Desktop Floating Product Card
|
||||||
|
**Position:** `absolute left-10 lg:left-20 top-24 lg:top-28`
|
||||||
|
**Width:** `300px` → `320px`
|
||||||
|
**Background:** `bg-white/95 backdrop-blur-md`
|
||||||
|
**Border Radius:** `rounded-[4px]`
|
||||||
|
**Shadow:** `shadow-lg`
|
||||||
|
|
||||||
|
#### Card Content:
|
||||||
|
1. Product Image
|
||||||
|
2. Title: "The Mantle" + 5-star rating
|
||||||
|
3. Subtitle: Product description
|
||||||
|
4. Tech Badge: "STRATA-3™ Technology"
|
||||||
|
5. Price + "Add to Cart" button
|
||||||
|
|
||||||
|
## 6. BRAND MARQUEE
|
||||||
|
|
||||||
|
**Background:** White
|
||||||
|
**Border:** `border-y border-border-light`
|
||||||
|
**Animation:** `animate-marquee-slow`
|
||||||
|
**Logos:** ~12+ SVG logos in grayscale
|
||||||
|
|
||||||
|
## 7. PHILOSOPHY / MANIFESTO SECTION
|
||||||
|
|
||||||
|
### Sticky Phone Mockup (Left Side)
|
||||||
|
**Phone Frame:**
|
||||||
|
- Width: `360px`, Height: `700px`
|
||||||
|
- Rounded: `rounded-[4px]`
|
||||||
|
- Background: Gradient
|
||||||
|
|
||||||
|
**Rotating Border:**
|
||||||
|
- Dashed circle
|
||||||
|
- Animation: `spin 60s linear infinite`
|
||||||
|
|
||||||
|
### Scrolling Content (Right Side)
|
||||||
|
|
||||||
|
**Section 1: Featured Product**
|
||||||
|
- Label: "Featured"
|
||||||
|
- Headline: "The Mantle" (italic serif)
|
||||||
|
- Body text + CTA button
|
||||||
|
|
||||||
|
**Section 2: The Science**
|
||||||
|
- Headline: "You have needs, we have answers"
|
||||||
|
- 4 accordion items with icons:
|
||||||
|
1. Skin Barrier Repair & Hydration
|
||||||
|
2. Environmental Protection with Ectoin
|
||||||
|
3. Anti-Ageing with Peptides & Exosomes
|
||||||
|
4. Best Moisturiser for Sensitive Skin
|
||||||
|
|
||||||
|
## 8. TRANSFORMATION / STATS SECTION
|
||||||
|
|
||||||
|
**Background:** `bg-ice-blue`
|
||||||
|
**Stats Grid (4 columns):**
|
||||||
|
| Stat | Value |
|
||||||
|
|------|-------|
|
||||||
|
| 92% | improved hydration |
|
||||||
|
| 87% | reduction in fine lines |
|
||||||
|
| 95% | smoother skin texture |
|
||||||
|
| 89% | calmer skin |
|
||||||
|
|
||||||
|
## 9. STRATA-3™ TECHNOLOGY SECTION
|
||||||
|
|
||||||
|
**Three Levels (1-2-3):**
|
||||||
|
1. **Cellular** - Ectoin and exosomes
|
||||||
|
2. **Tissue** - Peptides and stem cells
|
||||||
|
3. **Surface** - Triple ceramide complex
|
||||||
|
|
||||||
|
## 10. HAPPY FACE GUARANTEE
|
||||||
|
|
||||||
|
**Headline:** "Happy Face Guarantee"
|
||||||
|
**Content:** Full refund policy description
|
||||||
|
**Icon:** Shield with checkmark
|
||||||
|
|
||||||
|
## 11. BEFORE/AFTER TESTIMONIALS
|
||||||
|
|
||||||
|
**Background:** `bg-ice-blue-light`
|
||||||
|
**Card Style:**
|
||||||
|
- White background, rounded corners
|
||||||
|
- 5-star rating
|
||||||
|
- Quote (italic serif)
|
||||||
|
- Author name + skin type
|
||||||
|
- "Verified purchase" badge
|
||||||
|
|
||||||
|
## 12. FAQ SECTION
|
||||||
|
|
||||||
|
Accordion style with dashed borders
|
||||||
|
Sample questions about product usage, shipping, etc.
|
||||||
|
|
||||||
|
## 13. EMAIL SIGNUP / DISCOUNT
|
||||||
|
|
||||||
|
**Headline:** "Get 11% off your first order"
|
||||||
|
**Form:** Email input + Subscribe button
|
||||||
|
|
||||||
|
## 14. FOOTER
|
||||||
|
|
||||||
|
**5 Columns:**
|
||||||
|
1. Who is Moumoujus?
|
||||||
|
2. About
|
||||||
|
3. Shop
|
||||||
|
4. Help
|
||||||
|
5. Connect
|
||||||
|
|
||||||
|
**Bottom Bar:** Copyright, legal links, payment icons
|
||||||
|
|
||||||
|
## 15. ANIMATIONS
|
||||||
|
|
||||||
|
### Scroll Animations
|
||||||
|
- Fade In Up: opacity 0→1, translateY 20px→0
|
||||||
|
- Duration: 600-800ms
|
||||||
|
- Stagger: 100-150ms
|
||||||
|
|
||||||
|
### Hover Effects
|
||||||
|
- Button: Text slide effect
|
||||||
|
- Links: Color transition + underline
|
||||||
|
- Cards: Subtle lift
|
||||||
|
|
||||||
|
### Marquees
|
||||||
|
```css
|
||||||
|
animation: marquee 30s linear infinite;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16. IMAGES NEEDED
|
||||||
|
|
||||||
|
- Logo (SVG)
|
||||||
|
- Hero background (WebP)
|
||||||
|
- Product images
|
||||||
|
- Before/After photos
|
||||||
|
- Testimonial videos
|
||||||
|
|
||||||
|
## 17. RESPONSIVE BREAKPOINTS
|
||||||
|
|
||||||
|
- Mobile: < 640px
|
||||||
|
- Tablet: 640px+
|
||||||
|
- Desktop: 1024px+
|
||||||
|
- Wide: 1280px+
|
||||||
|
|
||||||
|
## 18. COMPONENTS TO BUILD
|
||||||
|
|
||||||
|
1. Marquee
|
||||||
|
2. StickyPhone
|
||||||
|
3. ProductCard
|
||||||
|
4. TestimonialCard
|
||||||
|
5. Accordion
|
||||||
|
6. AnimatedButton
|
||||||
|
7. StarRating
|
||||||
|
8. BeforeAfter
|
||||||
|
9. NewsletterForm
|
||||||
@@ -17,6 +17,16 @@ const nextConfig: NextConfig = {
|
|||||||
hostname: "minio-api.nodecrew.me",
|
hostname: "minio-api.nodecrew.me",
|
||||||
pathname: "/**",
|
pathname: "/**",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "api.manoonoils.com",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**.saleor.cloud",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
549
package-lock.json
generated
549
package-lock.json
generated
@@ -8,12 +8,16 @@
|
|||||||
"name": "manoonoils-store",
|
"name": "manoonoils-store",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@woocommerce/woocommerce-rest-api": "^1.0.2",
|
"@apollo/client": "^4.1.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.4",
|
"framer-motion": "^12.34.4",
|
||||||
|
"graphql": "^16.13.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -40,6 +44,48 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -509,6 +555,15 @@
|
|||||||
"tslib": "^2.8.1"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -2683,19 +2738,52 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@woocommerce/woocommerce-rest-api": {
|
"node_modules/@wry/caches": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@woocommerce/woocommerce-rest-api/-/woocommerce-rest-api-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz",
|
||||||
"integrity": "sha512-G+0VwM0MINF83KnT7Rg/htm9EEYADWvDPT/UWEJdZ0de1vXvsPrr4M1ksKaxgKHO8qIJViRrIHCtrui2JoVA+Q==",
|
"integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.8",
|
"tslib": "^2.3.0"
|
||||||
"create-hmac": "^1.1.7",
|
|
||||||
"oauth-1.0a": "^2.2.6",
|
|
||||||
"url-parse": "^1.4.7"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"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": {
|
"node_modules/acorn": {
|
||||||
@@ -2948,16 +3036,11 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"possible-typed-array-names": "^1.0.0"
|
"possible-typed-array-names": "^1.0.0"
|
||||||
@@ -2979,17 +3062,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -3081,6 +3153,7 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.0",
|
"call-bind-apply-helpers": "^1.0.0",
|
||||||
@@ -3099,6 +3172,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -3112,6 +3186,7 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -3171,26 +3246,21 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3211,18 +3281,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -3237,39 +3295,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3388,6 +3413,7 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0",
|
"es-define-property": "^1.0.0",
|
||||||
@@ -3419,15 +3445,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -3454,6 +3471,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -3565,6 +3583,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3574,6 +3593,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3611,6 +3631,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -3623,6 +3644,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -4243,30 +4265,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-callable": "^1.2.7"
|
"is-callable": "^1.2.7"
|
||||||
@@ -4278,22 +4281,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.34.4",
|
"version": "12.34.4",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.4.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.4.tgz",
|
||||||
@@ -4325,6 +4312,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -4385,6 +4373,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -4409,6 +4398,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -4496,6 +4486,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4511,6 +4502,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -4538,6 +4553,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0"
|
"es-define-property": "^1.0.0"
|
||||||
@@ -4566,6 +4582,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4578,6 +4595,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -4589,25 +4607,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -4685,12 +4689,6 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -4816,6 +4814,7 @@
|
|||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5072,6 +5071,7 @@
|
|||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"which-typed-array": "^1.1.16"
|
"which-typed-array": "^1.1.16"
|
||||||
@@ -5133,6 +5133,7 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
@@ -5604,6 +5605,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.577.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
|
||||||
|
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -5618,22 +5628,12 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5658,27 +5658,6 @@
|
|||||||
"node": ">=8.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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -5903,17 +5882,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
|
||||||
"version": "0.5.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
|
||||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -5974,12 +5942,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -6103,6 +6065,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -6240,6 +6214,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -6284,12 +6259,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -6302,12 +6271,6 @@
|
|||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -6318,12 +6281,6 @@
|
|||||||
"node": ">=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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -6373,33 +6330,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -6444,12 +6374,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6502,19 +6426,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -6539,6 +6450,16 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/safe-array-concat": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||||
@@ -6559,26 +6480,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -6634,6 +6535,7 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.1.4",
|
"define-data-property": "^1.1.4",
|
||||||
@@ -6678,26 +6580,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
@@ -6885,21 +6767,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -7085,6 +6952,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||||
@@ -7154,20 +7031,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -7243,6 +7106,7 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||||
"integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
|
"integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.3",
|
"call-bound": "^1.0.3",
|
||||||
@@ -7456,16 +7320,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/use-intl": {
|
||||||
"version": "4.8.3",
|
"version": "4.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz",
|
||||||
@@ -7487,12 +7341,6 @@
|
|||||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7580,6 +7428,7 @@
|
|||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"available-typed-arrays": "^1.0.7",
|
"available-typed-arrays": "^1.0.7",
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@woocommerce/woocommerce-rest-api": "^1.0.2",
|
"@apollo/client": "^4.1.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.4",
|
"framer-motion": "^12.34.4",
|
||||||
|
"graphql": "^16.13.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
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)
|
||||||
130
src/app/[locale]/about/page.tsx
Normal file
130
src/app/[locale]/about/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import Header from "@/components/layout/Header";
|
||||||
|
import Footer from "@/components/layout/Footer";
|
||||||
|
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||||
|
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
|
interface AboutPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: AboutPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
return {
|
||||||
|
title: metadata.about.title,
|
||||||
|
description: metadata.about.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
setRequestLocale(validLocale);
|
||||||
|
const t = await getTranslations("About");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<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">
|
||||||
|
{t("subtitle")}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-medium tracking-tight">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={metadata.about.productionAlt}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="py-16 md:py-24">
|
||||||
|
<div className="container">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="mb-16">
|
||||||
|
<p className="text-xl md:text-2xl text-[#1a1a1a] leading-relaxed mb-8">
|
||||||
|
{t("intro")}
|
||||||
|
</p>
|
||||||
|
<p className="text-[#666666] leading-relaxed">
|
||||||
|
{t("intro2")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{t("naturalIngredients")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#666666] text-sm leading-relaxed">
|
||||||
|
{t("naturalIngredientsDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-[#f8f9fa]">
|
||||||
|
<h3 className="text-lg font-medium mb-3">
|
||||||
|
{t("crueltyFree")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#666666] text-sm leading-relaxed">
|
||||||
|
{t("crueltyFreeDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-[#f8f9fa]">
|
||||||
|
<h3 className="text-lg font-medium mb-3">
|
||||||
|
{t("sustainablePackaging")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#666666] text-sm leading-relaxed">
|
||||||
|
{t("sustainablePackagingDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-[#f8f9fa]">
|
||||||
|
<h3 className="text-lg font-medium mb-3">
|
||||||
|
{t("handcraftedQuality")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#666666] text-sm leading-relaxed">
|
||||||
|
{t("handcraftedQualityDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center py-12 border-t border-b border-[#e5e5e5]">
|
||||||
|
<span className="text-caption text-[#666666] mb-4 block">
|
||||||
|
{t("mission")}
|
||||||
|
</span>
|
||||||
|
<blockquote className="text-2xl md:text-3xl font-medium tracking-tight">
|
||||||
|
“{t("missionQuote")}”
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2 className="text-2xl font-medium mb-6">
|
||||||
|
{t("handmadeTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#666666] leading-relaxed mb-6">
|
||||||
|
{t("handmadeText1")}
|
||||||
|
</p>
|
||||||
|
<p className="text-[#666666] leading-relaxed">
|
||||||
|
{t("handmadeText2")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<div className="pt-16">
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
src/app/[locale]/checkout/page.tsx
Normal file
384
src/app/[locale]/checkout/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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 t = useTranslations("Checkout");
|
||||||
|
const locale = useLocale();
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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(t("errorNoCheckout"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||||
|
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||||
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
shippingAddress: {
|
||||||
|
...shippingAddress,
|
||||||
|
country: "RS",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
||||||
|
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(t("errorCreatingOrder"));
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : null;
|
||||||
|
setError(errorMessage || t("errorOccurred"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (orderComplete) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
<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">{t("orderConfirmed")}</h1>
|
||||||
|
<p className="text-foreground-muted">{t("thankYou")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orderNumber && (
|
||||||
|
<div className="bg-background-ice p-6 rounded-lg mb-6">
|
||||||
|
<p className="text-sm text-foreground-muted mb-1">{t("orderNumber")}</p>
|
||||||
|
<p className="text-2xl font-serif">#{orderNumber}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-foreground-muted mb-8">
|
||||||
|
{t("confirmationEmail")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/products`}
|
||||||
|
className="inline-block px-8 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
|
||||||
|
>
|
||||||
|
{t("continueShoppingBtn")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<div className="pt-16">
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
<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">{t("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">
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="border-b border-border pb-6">
|
||||||
|
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("firstName")}</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">{t("lastName")}</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">{t("streetAddress")}</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={t("streetAddressOptional")}
|
||||||
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("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">{t("postalCode")}</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">{t("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>
|
||||||
|
|
||||||
|
<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>{t("billingAddressSame")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background-ice p-6 rounded-lg h-fit">
|
||||||
|
<h2 className="text-xl font-serif mb-6">{t("orderSummary")}</h2>
|
||||||
|
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<p className="text-foreground-muted">{t("yourCartEmpty")}</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
|
||||||
|
sizes="64px"
|
||||||
|
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">
|
||||||
|
{t("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">{t("subtotal")}</span>
|
||||||
|
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
|
||||||
|
<span>{t("total")}</span>
|
||||||
|
<span>{formatPrice(total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="pt-16">
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/app/[locale]/contact/page.tsx
Normal file
192
src/app/[locale]/contact/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
|
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 t = useTranslations("Contact");
|
||||||
|
const locale = useLocale();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<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">
|
||||||
|
{t("subtitle")}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-medium tracking-tight mb-4">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#666666]">
|
||||||
|
{t("getInTouchDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-medium mb-6">
|
||||||
|
{t("getInTouch")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#666666] mb-8 leading-relaxed">
|
||||||
|
{t("getInTouchDesc")}
|
||||||
|
</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">{t("email")}</h3>
|
||||||
|
<p className="text-[#666666] text-sm">hello@manoonoils.com</p>
|
||||||
|
<p className="text-[#999999] text-xs mt-1">{t("emailReply")}</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">
|
||||||
|
<Truck className="w-5 h-5 text-[#666666]" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-1">{t("shippingTitle")}</h3>
|
||||||
|
<p className="text-[#666666] text-sm">{t("freeShipping")}</p>
|
||||||
|
<p className="text-[#999999] text-xs mt-1">{t("deliveryTime")}</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">{t("location")}</h3>
|
||||||
|
<p className="text-[#666666] text-sm">{t("locationDesc")}</p>
|
||||||
|
<p className="text-[#999999] text-xs mt-1">{t("worldwideShipping")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">{t("thankYou")}</h3>
|
||||||
|
<p className="text-[#666666]">
|
||||||
|
{t("thankYouDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||||
|
{t("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={t("namePlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
||||||
|
{t("emailField")}
|
||||||
|
</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={t("emailPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium mb-2">
|
||||||
|
{t("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={t("messagePlaceholder")}
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{t("sendMessage")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{t("faqTitle")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[
|
||||||
|
{ q: t("faq1q"), a: t("faq1a") },
|
||||||
|
{ q: t("faq2q"), a: t("faq2a") },
|
||||||
|
{ q: t("faq3q"), a: t("faq3a") },
|
||||||
|
{ q: t("faq4q"), a: t("faq4a") },
|
||||||
|
].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 locale={locale} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/app/[locale]/layout.tsx
Normal file
51
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
|
||||||
|
|
||||||
|
const languages: Record<string, string> = {};
|
||||||
|
for (const loc of SUPPORTED_LOCALES) {
|
||||||
|
const prefix = loc === DEFAULT_LOCALE ? "" : `/${loc}`;
|
||||||
|
languages[loc] = `${baseUrl}${prefix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: `${baseUrl}${localePrefix}`,
|
||||||
|
languages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
src/app/[locale]/page.tsx
Normal file
228
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import Header from "@/components/layout/Header";
|
||||||
|
import Footer from "@/components/layout/Footer";
|
||||||
|
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 ProductReviews from "@/components/product/ProductReviews";
|
||||||
|
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||||
|
import ProblemSection from "@/components/home/ProblemSection";
|
||||||
|
import HowItWorks from "@/components/home/HowItWorks";
|
||||||
|
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||||
|
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
setRequestLocale(validLocale);
|
||||||
|
return {
|
||||||
|
title: metadata.home.title,
|
||||||
|
description: metadata.home.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
setRequestLocale(validLocale);
|
||||||
|
const t = await getTranslations("Home");
|
||||||
|
const tBenefits = await getTranslations("Benefits");
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
|
||||||
|
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||||
|
let products: any[] = [];
|
||||||
|
try {
|
||||||
|
products = await getProducts(saleorLocale);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Failed to fetch products during build");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
const featuredProducts = filteredProducts.slice(0, 4);
|
||||||
|
const hasProducts = featuredProducts.length > 0;
|
||||||
|
|
||||||
|
const basePath = `/${validLocale}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<HeroVideo locale={locale} />
|
||||||
|
|
||||||
|
<AsSeenIn />
|
||||||
|
|
||||||
|
<ProductReviews />
|
||||||
|
|
||||||
|
<TrustBadges />
|
||||||
|
|
||||||
|
<ProblemSection />
|
||||||
|
|
||||||
|
<BeforeAfterGallery />
|
||||||
|
|
||||||
|
<div id="main-content" className="scroll-mt-[72px] lg:scroll-mt-[72px]">
|
||||||
|
{hasProducts && (
|
||||||
|
<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">
|
||||||
|
{t("collection")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-medium mb-4">
|
||||||
|
{t("premiumOils")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#666666] max-w-xl mx-auto">
|
||||||
|
{t("oilsDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={locale} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-12">
|
||||||
|
<a
|
||||||
|
href={`${basePath}/products`}
|
||||||
|
className="inline-block text-sm uppercase tracking-[0.1em] border-b border-black pb-1 hover:text-[#666666] hover:border-[#666666] transition-colors"
|
||||||
|
>
|
||||||
|
{t("viewAll")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HowItWorks />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{t("ourStory")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-medium mb-6">
|
||||||
|
{t("handmadeWithLove")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#666666] mb-6 leading-relaxed">
|
||||||
|
{t("storyText1")}
|
||||||
|
</p>
|
||||||
|
<p className="text-[#666666] mb-8 leading-relaxed">
|
||||||
|
{t("storyText2")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`${basePath}/about`}
|
||||||
|
className="inline-block text-sm uppercase tracking-[0.1em] border-b border-black pb-1 hover:text-[#666666] hover:border-[#666666] transition-colors"
|
||||||
|
>
|
||||||
|
{t("learnMore")}
|
||||||
|
</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={metadata.home.productionAlt}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-24 px-4 sm:px-6 lg:px-8 bg-gradient-to-b from-white to-[#faf9f7]">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium">
|
||||||
|
{t("whyChooseUs")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-medium text-[#1a1a1a]">
|
||||||
|
{t("manoonDifference")}
|
||||||
|
</h2>
|
||||||
|
<div className="w-24 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-6 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: tBenefits("natural"),
|
||||||
|
description: tBenefits("naturalDesc"),
|
||||||
|
icon: (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#7eb89e"/>
|
||||||
|
<path stroke="#7eb89e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tBenefits("handcrafted"),
|
||||||
|
description: tBenefits("handcraftedDesc"),
|
||||||
|
icon: (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path stroke="#c9a962" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" 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: tBenefits("sustainable"),
|
||||||
|
description: tBenefits("sustainableDesc"),
|
||||||
|
icon: (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path stroke="#e8967a" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" d="M12.75 3.03v.568c0 .334.148.65.405.864l1.068.89c.442.369.535 1.01.216 1.49l-.51.766a2.25 2.25 0 01-1.161.886l-.143.048a1.107 1.107 0 00-.57 1.664c.369.555.169 1.307-.427 1.605L9 13.125l.423 1.059a.956.956 0 11-1.652.928l-.714-.093a1.125 1.125 0 00-1.906.172L4.5 15.75l-.612.153M12.75 3.031l.002-.004m0 0a8.955 8.955 0 00-4.943.834 8.974 8.974 0 004.943.834m4.943-.834a8.955 8.955 0 00-4.943-.834c2.687 0 5.18.948 7.161 2.664a8.974 8.974 0 014.943-.834z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((benefit, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative text-center p-8 bg-white rounded-3xl shadow-lg border border-[#f0ede8] hover:shadow-2xl hover:border-[#c9a962]/30 transition-all duration-500 group"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-md border border-[#e8e4dc] group-hover:border-[#c9a962]/50 transition-colors duration-300">
|
||||||
|
{benefit.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-[#1a1a1a] mb-3">{benefit.title}</h3>
|
||||||
|
<p className="text-sm text-[#666666] leading-relaxed">{benefit.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{t("stayConnected")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-medium mb-6">
|
||||||
|
{t("joinCommunity")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/70 mb-10 mx-auto text-lg">
|
||||||
|
{t("newsletterText")}
|
||||||
|
</p>
|
||||||
|
<form className="flex flex-col sm:flex-row items-stretch justify-center max-w-md mx-auto gap-0">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
className="flex-1 min-w-0 px-5 !h-16 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 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"
|
||||||
|
>
|
||||||
|
{t("subscribe")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/app/[locale]/products/[slug]/page.tsx
Normal file
125
src/app/[locale]/products/[slug]/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { getProductBySlug, getProducts, getLocalizedProduct, getBundleProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
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 { routing } from "@/i18n/routing";
|
||||||
|
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||||
|
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
|
interface ProductPageProps {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const locales = routing.locales;
|
||||||
|
const params: Array<{ locale: string; slug: string }> = [];
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
try {
|
||||||
|
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
||||||
|
const products = await getProducts(saleorLocale, 100);
|
||||||
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
filteredProducts.forEach((product: Product) => {
|
||||||
|
params.push({ locale, slug: product.slug });
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: ProductPageProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
const saleorLocale = validLocale === "sr" ? "SR" : "EN";
|
||||||
|
const product = await getProductBySlug(slug, saleorLocale);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return {
|
||||||
|
title: metadata.productNotFound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const localized = getLocalizedProduct(product, saleorLocale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: localized.name,
|
||||||
|
description: localized.seoDescription || localized.description?.slice(0, 160),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
setRequestLocale(validLocale);
|
||||||
|
const t = await getTranslations("Product");
|
||||||
|
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||||
|
const product = await getProductBySlug(slug, saleorLocale);
|
||||||
|
|
||||||
|
const basePath = `/${validLocale}`;
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
<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">
|
||||||
|
{t("notFound")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#666666] mb-8">
|
||||||
|
{t("notFoundDesc")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`${basePath}/products`}
|
||||||
|
className="inline-block px-8 py-3 bg-black text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
||||||
|
>
|
||||||
|
{t("browseProducts")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let relatedProducts: Product[] = [];
|
||||||
|
let bundleProducts: Product[] = [];
|
||||||
|
try {
|
||||||
|
const allProducts = await getProducts(saleorLocale, 50);
|
||||||
|
relatedProducts = filterOutBundles(allProducts)
|
||||||
|
.filter((p: Product) => p.id !== product.id)
|
||||||
|
.slice(0, 4);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allBundleProducts = await getBundleProducts(saleorLocale, 50);
|
||||||
|
bundleProducts = allBundleProducts.filter((p) => {
|
||||||
|
const bundleAttr = p.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr || bundleAttr.values.length === 0) return false;
|
||||||
|
return bundleAttr.values.some((val) => {
|
||||||
|
return val.name === product.name || p.name.includes(product.name.split(" - ")[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<ProductDetail
|
||||||
|
product={product}
|
||||||
|
relatedProducts={relatedProducts}
|
||||||
|
bundleProducts={bundleProducts}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/[locale]/products/page.tsx
Normal file
106
src/app/[locale]/products/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import Header from "@/components/layout/Header";
|
||||||
|
import Footer from "@/components/layout/Footer";
|
||||||
|
import ProductCard from "@/components/product/ProductCard";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||||
|
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
|
interface ProductsPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: ProductsPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
return {
|
||||||
|
title: metadata.products.title,
|
||||||
|
description: metadata.products.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
setRequestLocale(validLocale);
|
||||||
|
const t = await getTranslations("Products");
|
||||||
|
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||||
|
const allProducts = await getProducts(saleorLocale);
|
||||||
|
|
||||||
|
const products = filterOutBundles(allProducts);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header locale={locale} />
|
||||||
|
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<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">
|
||||||
|
{t("collection")}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-medium">
|
||||||
|
{t("allProducts")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-[#666666]">
|
||||||
|
{t("productsCount", { count: products.length })}
|
||||||
|
</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">{t("featured")}</option>
|
||||||
|
<option value="newest">{t("newest")}</option>
|
||||||
|
<option value="price-low">{t("priceLow")}</option>
|
||||||
|
<option value="price-high">{t("priceHigh")}</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 className="py-12 md:py-16">
|
||||||
|
<div className="container">
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p className="text-[#666666] mb-4">
|
||||||
|
{t("noProducts")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#999999]">
|
||||||
|
{t("checkBack")}
|
||||||
|
</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={validLocale}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="pt-16">
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
|
|
||||||
export default function ContactPage() {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSubmitted(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen pt-16 md:pt-20">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<p className="text-foreground-muted text-center mb-12">
|
|
||||||
Have questions? We'd love to hear from you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
|
|
||||||
>
|
|
||||||
Send Message
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
|
|
||||||
export default function ContactPage() {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSubmitted(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen pt-16 md:pt-20">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<p className="text-foreground-muted text-center mb-12">
|
|
||||||
Have questions? We'd love to hear from you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
|
|
||||||
>
|
|
||||||
Send Message
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
|
||||||
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function Homepage() {
|
|
||||||
const products = await getProducts();
|
|
||||||
const publishedProducts = products.filter((p) => p.status === "publish").slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="relative h-[80vh] flex items-center justify-center bg-gradient-to-b from-white to-background-ice">
|
|
||||||
<div className="text-center px-4">
|
|
||||||
<h1 className="text-5xl md:text-7xl font-serif mb-6">
|
|
||||||
ManoonOils
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl md:text-2xl text-foreground-muted mb-8">
|
|
||||||
Premium Natural Oils for Hair & Skin
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/products"
|
|
||||||
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium hover:bg-opacity-90 transition-all"
|
|
||||||
>
|
|
||||||
Shop Now
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Products Section */}
|
|
||||||
{publishedProducts.length > 0 && (
|
|
||||||
<section className="py-20 px-4">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<h2 className="text-4xl font-serif text-center mb-12">Our Products</h2>
|
|
||||||
<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} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* About Teaser */}
|
|
||||||
<section className="py-20 px-4 bg-background-ice">
|
|
||||||
<div className="max-w-3xl mx-auto text-center">
|
|
||||||
<h2 className="text-3xl font-serif mb-6">Natural & Pure</h2>
|
|
||||||
<p className="text-lg text-foreground-muted mb-8">
|
|
||||||
Our oils are crafted with love using only the finest natural ingredients.
|
|
||||||
</p>
|
|
||||||
<a href="/about" className="text-foreground border-b border-foreground pb-1">
|
|
||||||
Learn More
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
|
||||||
try {
|
|
||||||
const products = await getProducts();
|
|
||||||
return products.map((product) => ({
|
|
||||||
slug: product.slug || product.id.toString(),
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
||||||
const { slug } = await params;
|
|
||||||
let product = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const products = await getProducts();
|
|
||||||
product = products.find((p) => (p.slug || p.id.toString()) === slug);
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen">
|
|
||||||
<Header />
|
|
||||||
<div className="pt-24 text-center">
|
|
||||||
<h1 className="text-2xl">Product not found</h1>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = product.images?.[0]?.src || '/placeholder.jpg';
|
|
||||||
const price = product.sale_price || product.price;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Products - ManoonOils",
|
|
||||||
description: "Browse our collection of premium natural oils for hair and skin care.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ProductsPage() {
|
|
||||||
const products = await getProducts();
|
|
||||||
|
|
||||||
const publishedProducts = products.filter((p) => p.status === "publish");
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,36 +1,75 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
/* ============================================
|
||||||
--background: #f0f4f8;
|
MANOONOILS DESIGN SYSTEM
|
||||||
--background-ice: #e8f0f5;
|
Tailwind 4 compatible - uses CSS layers
|
||||||
--foreground: #1a1a1a;
|
============================================ */
|
||||||
--foreground-muted: #666666;
|
|
||||||
--accent: #a8c5d8;
|
|
||||||
--accent-dark: #7ba3bc;
|
|
||||||
--white: #ffffff;
|
|
||||||
--border: #d1d9e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
/* Colors - reference CSS variables */
|
||||||
--color-background-ice: var(--background-ice);
|
--color-white: var(--color-white);
|
||||||
--color-foreground: var(--foreground);
|
--color-background: var(--color-background);
|
||||||
--color-foreground-muted: var(--foreground-muted);
|
--color-background-alt: var(--color-background-alt);
|
||||||
--color-accent: var(--accent);
|
--color-foreground: var(--color-foreground);
|
||||||
--color-accent-dark: var(--accent-dark);
|
--color-foreground-muted: var(--color-foreground-muted);
|
||||||
--color-white: var(--white);
|
--color-foreground-subtle: var(--color-foreground-subtle);
|
||||||
--color-border: var(--border);
|
--color-accent: var(--color-accent);
|
||||||
--font-display: var(--font-cedrat);
|
--color-accent-dark: var(--color-accent-dark);
|
||||||
--font-body: var(--font-dm-sans);
|
--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';
|
CSS VARIABLES
|
||||||
src: url('https://fonts.gstatic.com/s/cedratdisplay/v16/0nkoC9_pK3CvS5lZuZ7MAUmK5w.woff2') format('woff2');
|
============================================ */
|
||||||
font-weight: 400 900;
|
|
||||||
font-display: swap;
|
: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-face {
|
||||||
font-family: 'DM Sans';
|
font-family: 'DM Sans';
|
||||||
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhS2f3ZGMZpg.woff2') format('woff2');
|
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhS2f3ZGMZpg.woff2') format('woff2');
|
||||||
@@ -38,18 +77,294 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
@font-face {
|
||||||
box-sizing: border-box;
|
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);
|
BASE STYLES (in Tailwind base layer)
|
||||||
color: var(--foreground);
|
============================================ */
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
@layer base {
|
||||||
-moz-osx-font-smoothing: grayscale;
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
/* ============================================
|
||||||
font-family: 'Cedrat Display', serif;
|
COMPONENTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
REDUCED MOTION
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/app/icon.png
Normal file
BIN
src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -1,13 +1,23 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import ErrorBoundary from "@/components/providers/ErrorBoundary";
|
||||||
|
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
default: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||||
template: "%s | ManoonOils",
|
template: "%s | ManoonOils",
|
||||||
},
|
},
|
||||||
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
|
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||||
robots: "index, follow",
|
robots: "index, follow",
|
||||||
|
alternates: {
|
||||||
|
canonical: baseUrl,
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
|
||||||
|
),
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||||
@@ -16,15 +26,23 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
{children}
|
<ErrorBoundary>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,67 +1,18 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
import { redirect } from "next/navigation";
|
||||||
import Header from "@/components/layout/Header";
|
import { cookies, headers } from "next/headers";
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
|
||||||
|
|
||||||
export const metadata = {
|
export default async function RootPage() {
|
||||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
const headersList = await headers();
|
||||||
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
|
const cookieStore = await cookies();
|
||||||
};
|
const acceptLanguage = headersList.get("accept-language") || "";
|
||||||
|
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
|
||||||
|
|
||||||
export default async function Homepage() {
|
let locale = "sr";
|
||||||
const products = await getProducts();
|
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) {
|
||||||
const publishedProducts = products.filter((p) => p.status === "publish").slice(0, 4);
|
locale = cookieLocale;
|
||||||
|
} else if (acceptLanguage.includes("en")) {
|
||||||
|
locale = "en";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
redirect(`/${locale}`);
|
||||||
<main className="min-h-screen">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="relative h-[80vh] flex items-center justify-center bg-gradient-to-b from-white to-background-ice">
|
|
||||||
<div className="text-center px-4">
|
|
||||||
<h1 className="text-5xl md:text-7xl font-serif mb-6">
|
|
||||||
ManoonOils
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl md:text-2xl text-foreground-muted mb-8">
|
|
||||||
Premium Natural Oils for Hair & Skin
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/products"
|
|
||||||
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium hover:bg-opacity-90 transition-all"
|
|
||||||
>
|
|
||||||
Shop Now
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Products Section */}
|
|
||||||
{publishedProducts.length > 0 && (
|
|
||||||
<section className="py-20 px-4">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<h2 className="text-4xl font-serif text-center mb-12">Our Products</h2>
|
|
||||||
<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} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* About Teaser */}
|
|
||||||
<section className="py-20 px-4 bg-background-ice">
|
|
||||||
<div className="max-w-3xl mx-auto text-center">
|
|
||||||
<h2 className="text-3xl font-serif mb-6">Natural & Pure</h2>
|
|
||||||
<p className="text-lg text-foreground-muted mb-8">
|
|
||||||
Our oils are crafted with love using only the finest natural ingredients.
|
|
||||||
</p>
|
|
||||||
<a href="/about" className="text-foreground border-b border-foreground pb-1">
|
|
||||||
Learn More
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
|
||||||
try {
|
|
||||||
const products = await getProducts();
|
|
||||||
return products.map((product) => ({
|
|
||||||
slug: product.slug || product.id.toString(),
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
||||||
const { slug } = await params;
|
|
||||||
let product = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const products = await getProducts();
|
|
||||||
product = products.find((p) => (p.slug || p.id.toString()) === slug);
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen">
|
|
||||||
<Header />
|
|
||||||
<div className="pt-24 text-center">
|
|
||||||
<h1 className="text-2xl">Product not found</h1>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = product.images?.[0]?.src || '/placeholder.jpg';
|
|
||||||
const price = product.sale_price || product.price;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Products - ManoonOils",
|
|
||||||
description: "Browse our collection of premium natural oils for hair and skin care.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ProductsPage() {
|
|
||||||
const products = await getProducts();
|
|
||||||
|
|
||||||
const publishedProducts = products.filter((p) => p.status === "publish");
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,109 @@
|
|||||||
import { MetadataRoute } from "next";
|
import { MetadataRoute } from "next";
|
||||||
import { getProducts } from "@/lib/woocommerce";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
|
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
|
||||||
|
|
||||||
const products = await getProducts();
|
interface SitemapEntry {
|
||||||
|
url: string;
|
||||||
|
lastModified: Date;
|
||||||
|
changeFrequency: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
||||||
|
priority: number;
|
||||||
|
alternates?: {
|
||||||
|
languages?: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const productUrls = products
|
export default async function sitemap(): Promise<SitemapEntry[]> {
|
||||||
.filter((p) => p.status === "publish")
|
let products: any[] = [];
|
||||||
.map((product) => ({
|
try {
|
||||||
url: `${baseUrl}/products/${product.slug}`,
|
products = await getProducts("SR", 100);
|
||||||
lastModified: new Date(),
|
} catch (e) {
|
||||||
changeFrequency: "weekly" as const,
|
console.log("Failed to fetch products for sitemap during build");
|
||||||
priority: 0.8,
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
return [
|
const staticPages: SitemapEntry[] = [
|
||||||
{
|
{
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/products`,
|
url: `${baseUrl}/products`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/products` : `${baseUrl}/${locale}/products`])
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/about`,
|
url: `${baseUrl}/about`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/about` : `${baseUrl}/${locale}/about`])
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/contact`,
|
url: `${baseUrl}/contact`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/contact` : `${baseUrl}/${locale}/contact`])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/checkout`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.5,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/checkout` : `${baseUrl}/${locale}/checkout`])
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
...productUrls,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
|
||||||
|
const productUrls: SitemapEntry[] = [];
|
||||||
|
|
||||||
|
for (const product of filteredProducts) {
|
||||||
|
const hreflangs: Record<string, string> = {};
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
|
||||||
|
hreflangs[locale] = `${baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const localePrefix = locale === "sr" ? "" : `/${locale}`;
|
||||||
|
productUrls.push({
|
||||||
|
url: `${baseUrl}${localePrefix}/products/${product.slug}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.8,
|
||||||
|
alternates: {
|
||||||
|
languages: hreflangs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...staticPages, ...productUrls];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,190 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCartStore } from "@/stores/cartStore";
|
import { X, Minus, Plus, Trash2, ShoppingBag } from "lucide-react";
|
||||||
import { formatPrice } from "@/lib/woocommerce";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
|
import { formatPrice } from "@/lib/saleor";
|
||||||
|
|
||||||
export default function CartDrawer() {
|
export default function CartDrawer() {
|
||||||
const { items, isOpen, closeCart, removeItem, updateQuantity, getTotal } = useCartStore();
|
const t = useTranslations("Cart");
|
||||||
|
const locale = useLocale();
|
||||||
|
const {
|
||||||
|
checkout,
|
||||||
|
isOpen,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
closeCart,
|
||||||
|
removeLine,
|
||||||
|
updateLine,
|
||||||
|
getTotal,
|
||||||
|
getLineCount,
|
||||||
|
getLines,
|
||||||
|
initCheckout,
|
||||||
|
clearError,
|
||||||
|
} = useSaleorCheckoutStore();
|
||||||
|
|
||||||
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
|
const lineCount = getLineCount();
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized) {
|
||||||
|
initCheckout();
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [initialized]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<motion.div
|
<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%" }}
|
initial={{ x: "100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "100%" }}
|
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">
|
<div className="flex items-center justify-between px-6 py-5 border-b border-[#e5e5e5]">
|
||||||
<h2 className="text-xl font-serif">Your Cart</h2>
|
<h2 className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("yourCart")} ({lineCount})
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
className="p-2"
|
className="p-2 -mr-2 hover:bg-black/5 rounded-full transition-colors"
|
||||||
aria-label="Close cart"
|
aria-label={t("closeCart")}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<X className="w-5 h-5" strokeWidth={1.5} />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<AnimatePresence>
|
||||||
{items.length === 0 ? (
|
{error && (
|
||||||
<div className="text-center py-12">
|
<motion.div
|
||||||
<p className="text-foreground-muted mb-6">Your cart is empty</p>
|
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"
|
||||||
|
>
|
||||||
|
{t("dismiss")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<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">{t("yourCartEmpty")}</p>
|
||||||
|
<p className="text-sm text-[#999999] mb-8 text-center">
|
||||||
|
{t("looksLikeEmpty")}
|
||||||
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/en/products"
|
href={`/${locale}/products`}
|
||||||
onClick={closeCart}
|
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
|
{t("startShopping")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{items.map((item) => (
|
{lines.map((line) => (
|
||||||
<div key={item.id} className="flex gap-4">
|
<div key={line.id} className="flex gap-4">
|
||||||
<div className="w-20 h-20 bg-background-ice relative flex-shrink-0">
|
<div className="w-24 h-24 bg-[#f8f9fa] relative flex-shrink-0 overflow-hidden">
|
||||||
{item.image && (
|
{line.variant.product.media[0]?.url ? (
|
||||||
<Image
|
<Image
|
||||||
src={item.image}
|
src={line.variant.product.media[0].url}
|
||||||
alt={item.name}
|
alt={line.variant.product.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
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>
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-serif text-sm">{item.name}</h3>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-foreground-muted text-sm mt-1">
|
<h3 className="text-sm font-medium truncate">
|
||||||
{formatPrice(item.price)}
|
{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>
|
</p>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
onClick={() => removeLine(line.id)}
|
||||||
className="w-8 h-8 border border-border flex items-center justify-center"
|
disabled={isLoading}
|
||||||
|
className="p-2 text-[#999999] hover:text-red-500 transition-colors"
|
||||||
|
aria-label={t("removeItem")}
|
||||||
>
|
>
|
||||||
-
|
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||||
</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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,18 +194,58 @@ export default function CartDrawer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{lines.length > 0 && (
|
||||||
<div className="p-6 border-t border-border/30">
|
<div className="border-t border-[#e5e5e5] bg-white">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="p-6 space-y-3">
|
||||||
<span className="font-serif">Subtotal</span>
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-serif text-lg">{formatPrice(total.toString())}</span>
|
<span className="text-[#666666]">{t("subtotal")}</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[#666666]">{t("shipping")}</span>
|
||||||
|
<span className="text-[#666666]">
|
||||||
|
{checkout?.shippingPrice?.gross?.amount
|
||||||
|
? formatPrice(checkout.shippingPrice.gross.amount)
|
||||||
|
: t("calculatedAtCheckout")
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#e5e5e5] my-4" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm uppercase tracking-[0.05em] font-medium">{t("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">
|
||||||
|
{t("freeShippingOver", { amount: formatPrice(5000) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6 space-y-3">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/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 ? t("processing") : t("checkout")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={closeCart}
|
||||||
|
className="block w-full py-3 text-center text-sm text-[#666666] hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
{t("continueShopping")}
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
34
src/components/home/AnnouncementBar.tsx
Normal file
34
src/components/home/AnnouncementBar.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AnnouncementBar() {
|
||||||
|
const items = [
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 right-0 z-50 bg-[#E8F4F8] overflow-hidden">
|
||||||
|
<div className="flex animate-marquee whitespace-nowrap py-2 will-change-transform">
|
||||||
|
{items.map((text, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-4 mx-5 shrink-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs tracking-[0.12em] uppercase text-[#1A1A1A]/70 font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-[#1A1A1A]/50" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/home/AsSeenIn.tsx
Normal file
84
src/components/home/AsSeenIn.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const t = useTranslations("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-[#c9a962] font-bold"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-32 bg-gradient-to-r from-[#1a1a1a] to-transparent z-10 pointer-events-none" />
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-[#1a1a1a] to-transparent z-10 pointer-events-none" />
|
||||||
|
|
||||||
|
<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",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaLogos.map((logo, index) => (
|
||||||
|
<LogoItem key={`first-${index}`} name={logo.name} />
|
||||||
|
))}
|
||||||
|
{mediaLogos.map((logo, index) => (
|
||||||
|
<LogoItem key={`second-${index}`} name={logo.name} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
src/components/home/BeforeAfterGallery.tsx
Normal file
231
src/components/home/BeforeAfterGallery.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
|
|
||||||
|
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 t = useTranslations("BeforeAfterGallery");
|
||||||
|
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">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative aspect-[4/3] rounded-2xl overflow-hidden shadow-2xl cursor-ew-resize select-none"
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={result.afterImg}
|
||||||
|
alt="After"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 overflow-hidden"
|
||||||
|
style={{ width: `${sliderPosition}%` }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={result.beforeImg}
|
||||||
|
alt="Before"
|
||||||
|
className="absolute inset-0 h-full object-cover"
|
||||||
|
style={{ width: `${100 / (sliderPosition / 100)}%`, maxWidth: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{t("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">
|
||||||
|
{t("after")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-yellow-400 text-yellow-400" 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>
|
||||||
|
|
||||||
|
<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">{t("verified")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BeforeAfterGallery() {
|
||||||
|
const t = useTranslations("BeforeAfterGallery");
|
||||||
|
const locale = useLocale();
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const goToPrev = () => {
|
||||||
|
setSelectedIndex(prev => prev === 0 ? results.length - 1 : prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNext = () => {
|
||||||
|
setSelectedIndex(prev => prev === results.length - 1 ? 0 : prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
{t("realResults")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-medium mb-4">
|
||||||
|
{t("seeTransformation")}
|
||||||
|
</h2>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="hidden md: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>
|
||||||
|
|
||||||
|
<div className="md:hidden relative max-w-md mx-auto">
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
key={selectedIndex}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<BeforeAfterSlider result={results[selectedIndex]} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={goToPrev}
|
||||||
|
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={goToNext}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-2 mt-6">
|
||||||
|
{results.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setSelectedIndex(index)}
|
||||||
|
className={`w-2 h-2 rounded-full transition-all ${
|
||||||
|
selectedIndex === index ? "bg-black w-4" : "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
aria-label={`Go to ${index + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={`/${locale}/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"
|
||||||
|
>
|
||||||
|
{t("startTransformation")}
|
||||||
|
</a>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/home/FeaturesSection.tsx
Normal file
86
src/components/home/FeaturesSection.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Droplet, Shield, Clock, Heart } from "lucide-react";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Droplet,
|
||||||
|
title: "Deep Hydration & Nourishment",
|
||||||
|
description:
|
||||||
|
"Our cold-pressed oils penetrate deep into hair and skin, delivering essential fatty acids and vitamins for lasting moisture without greasiness.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: "Natural Protection",
|
||||||
|
description:
|
||||||
|
"Rich in antioxidants, our oils shield your hair and skin from environmental stressors, UV damage, and pollution.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
title: "Anti-Ageing Benefits",
|
||||||
|
description:
|
||||||
|
"Packed with vitamin E and essential nutrients that promote collagen production and cellular renewal for youthful skin and healthy hair.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Heart,
|
||||||
|
title: "Gentle for All Types",
|
||||||
|
description:
|
||||||
|
"100% natural, cruelty-free formulas suitable for sensitive skin and all hair types. No synthetic fragrances or harsh chemicals.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FeaturesSection() {
|
||||||
|
return (
|
||||||
|
<section className="py-24 lg:py-32 bg-white">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 lg:gap-20">
|
||||||
|
{/* Left Content */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<span className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block">
|
||||||
|
The Science
|
||||||
|
</span>
|
||||||
|
<h2 className="font-serif italic text-4xl lg:text-5xl xl:text-6xl text-[#1A1A1A] tracking-tight leading-[1.1] mb-6">
|
||||||
|
You have needs,
|
||||||
|
<br />
|
||||||
|
we have answers
|
||||||
|
</h2>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Right Features List */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||||
|
className="border-b border-dashed border-[#1A1A1A]/10 py-6 first:pt-0 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-5">
|
||||||
|
<div className="shrink-0 text-[#1A1A1A]/70 mt-0.5">
|
||||||
|
<feature.icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[#1A1A1A] font-medium text-base tracking-wide mb-1.5">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#4A4A4A] text-sm leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
|
const locale = useLocale();
|
||||||
return (
|
return (
|
||||||
<section className="relative h-screen min-h-[600px] flex items-center justify-center overflow-hidden">
|
<section className="relative h-screen min-h-[600px] flex items-center justify-center overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background-ice/50 to-background" />
|
<div className="absolute inset-0 bg-gradient-to-b from-background-ice/50 to-background" />
|
||||||
@@ -48,7 +50,7 @@ export default function Hero() {
|
|||||||
transition={{ duration: 0.8, delay: 0.8 }}
|
transition={{ duration: 0.8, delay: 0.8 }}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/en/products"
|
href={`/${locale}/products`}
|
||||||
className="inline-block px-10 py-4 bg-foreground text-white text-lg tracking-wide hover:bg-accent-dark transition-colors duration-300"
|
className="inline-block px-10 py-4 bg-foreground text-white text-lg tracking-wide hover:bg-accent-dark transition-colors duration-300"
|
||||||
>
|
>
|
||||||
Shop Now
|
Shop Now
|
||||||
|
|||||||
152
src/components/home/HeroVideo.tsx
Normal file
152
src/components/home/HeroVideo.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface HeroVideoProps {
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
|
||||||
|
const t = useTranslations("Home.hero");
|
||||||
|
const localePath = `/${locale}`;
|
||||||
|
|
||||||
|
const scrollToContent = () => {
|
||||||
|
const element = document.getElementById("main-content");
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative min-h-screen w-full overflow-hidden">
|
||||||
|
{/* Background Image with Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop')`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 min-h-screen flex flex-col items-center justify-center text-center text-white px-4 py-20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
|
className="max-w-4xl mx-auto"
|
||||||
|
>
|
||||||
|
{/* 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-yellow-400 text-yellow-400" 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">
|
||||||
|
{t("lovedBy")}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{t("transformHeadline")}
|
||||||
|
<br />
|
||||||
|
<span className="text-white/90">{t("withNaturalOils")}</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"
|
||||||
|
>
|
||||||
|
{t("subtitleText")}
|
||||||
|
</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={`${localePath}/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"
|
||||||
|
>
|
||||||
|
{t("ctaButton")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`${localePath}/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"
|
||||||
|
>
|
||||||
|
{t("learnStory")}
|
||||||
|
</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>{t("moneyBack")}</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>{t("freeShipping")}</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>{t("crueltyFree")}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/home/HowItWorks.tsx
Normal file
109
src/components/home/HowItWorks.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
|
|
||||||
|
export default function HowItWorks() {
|
||||||
|
const t = useTranslations("HowItWorks");
|
||||||
|
const locale = useLocale();
|
||||||
|
const steps = t.raw("steps") as Array<{ title: string; description: string }>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 bg-gradient-to-b from-white to-[#faf9f7]">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-20"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium">
|
||||||
|
{t("title")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-4xl md:text-5xl font-medium text-[#1a1a1a]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</h2>
|
||||||
|
<div className="w-24 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-6 rounded-full" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-16 max-w-6xl mx-auto">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="relative text-center group"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.15 }}
|
||||||
|
>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className="hidden md:block absolute top-16 left-[55%] w-[90%] h-[2px]">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-[#c9a962]/40 to-transparent rounded-full" />
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-y-0 left-0 w-2 bg-[#FFD700] rounded-full"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
whileInView={{ scaleX: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.5 + index * 0.2 }}
|
||||||
|
style={{ originX: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative p-8 bg-white rounded-3xl shadow-lg border border-[#f0ede8] hover:shadow-2xl hover:border-[#c9a962]/30 transition-all duration-500">
|
||||||
|
<div className="absolute -top-5 left-1/2 -translate-x-1/2">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#c9a962] to-[#FFD700] flex items-center justify-center shadow-lg">
|
||||||
|
<span className="text-white text-lg font-bold">0{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-20 h-20 mx-auto mt-4 mb-6 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center border border-[#e8e4dc] group-hover:border-[#c9a962]/50 transition-colors duration-300">
|
||||||
|
{index === 0 && (
|
||||||
|
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="#c9a962" strokeWidth="1.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" 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.09z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{index === 1 && (
|
||||||
|
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="#c9a962" strokeWidth="1.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||||
|
)}
|
||||||
|
{index === 2 && (
|
||||||
|
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="#FFD700">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-[#1a1a1a] mb-3">{step.title}</h3>
|
||||||
|
<p className="text-[#666666] text-sm leading-relaxed max-w-xs mx-auto">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="text-center mt-20"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/${locale}/products`}
|
||||||
|
className="group relative inline-flex items-center gap-3 px-12 py-5 bg-gradient-to-r from-[#1a1a1a] to-[#333333] text-white text-[13px] uppercase tracking-[0.2em] font-semibold hover:from-[#c9a962] hover:to-[#FFD700] transition-all duration-500 rounded-full shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<span>{t("startTransformation")}</span>
|
||||||
|
<svg className="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/components/home/NewHero.tsx
Normal file
181
src/components/home/NewHero.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Star, ShoppingBag } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
|
import type { Product } from "@/types/saleor";
|
||||||
|
import { getProductPrice, getProductImage, formatPrice, parseDescription } from "@/lib/saleor";
|
||||||
|
|
||||||
|
interface NewHeroProps {
|
||||||
|
featuredProduct?: Product;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-[#E8F4F8]/30 to-white/80" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-[#E8F4F8]/50 via-transparent to-[#E8F4F8]/30" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Hero Text */}
|
||||||
|
<div className="relative z-10 flex flex-col justify-end items-center text-center p-6 pb-12 lg:hidden">
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="font-serif italic text-3xl text-[#1A1A1A] leading-[1.15] tracking-tight"
|
||||||
|
>
|
||||||
|
Natural Oils,
|
||||||
|
<br />
|
||||||
|
Real Results
|
||||||
|
</motion.h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Floating Product Card */}
|
||||||
|
<div className="hidden lg:block absolute left-10 xl:left-20 top-32 z-10">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -30 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
|
className="bg-white/95 backdrop-blur-md w-[320px] xl:w-[360px] rounded-[4px] overflow-hidden shadow-lg"
|
||||||
|
>
|
||||||
|
{featuredProduct ? (
|
||||||
|
<>
|
||||||
|
{/* Product Image */}
|
||||||
|
<div className="relative aspect-square bg-[#E8F4F8]">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={featuredProduct.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{/* Title & Rating */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-lg font-medium text-[#1A1A1A]">
|
||||||
|
{featuredProduct.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className="w-3.5 h-3.5 fill-amber-400 text-amber-400"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-[#4A4A4A]/70 mt-1 line-clamp-2">
|
||||||
|
{parseDescription(featuredProduct.description).slice(0, 100) ||
|
||||||
|
"Premium natural oil for hair and skin care"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tech Badge */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-[#1A1A1A]/6">
|
||||||
|
<p className="text-xs font-medium text-[#1A1A1A] tracking-wide">
|
||||||
|
COLD-PRESSED TECHNOLOGY
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#4A4A4A]/60 mt-0.5 leading-relaxed">
|
||||||
|
Pure extraction method preserving all nutrients and benefits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price & CTA */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[#1A1A1A]/6">
|
||||||
|
<div>
|
||||||
|
<span className="text-lg font-medium text-[#1A1A1A]">
|
||||||
|
{price}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[#4A4A4A]/60 ml-2">50ml</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
className="inline-flex items-center gap-2 bg-[#1A1A1A] text-white px-4 py-2 text-sm font-medium hover:bg-[#1A1A1A]/90 transition-colors"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-4 h-4" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-[#4A4A4A]/70">Loading featured product...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side Content */}
|
||||||
|
<div className="hidden lg:flex flex-1 items-center justify-end pr-10 xl:pr-20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.5 }}
|
||||||
|
className="max-w-xl text-right"
|
||||||
|
>
|
||||||
|
<span className="inline-block text-xs tracking-[0.3em] text-[#6B7280] mb-6">
|
||||||
|
PREMIUM NATURAL OILS
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="font-serif italic text-5xl xl:text-6xl text-[#1A1A1A] tracking-tight leading-[1.1] mb-6">
|
||||||
|
ManoonOils
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-[#4A4A4A] font-light mb-8 leading-relaxed">
|
||||||
|
Discover our premium collection of natural oils for hair and skin
|
||||||
|
care. Handmade with love using only the finest ingredients.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/products`}
|
||||||
|
className="inline-block bg-[#1A1A1A] text-white px-8 py-4 text-sm tracking-wide hover:bg-[#1A1A1A]/90 transition-colors"
|
||||||
|
>
|
||||||
|
Shop Collection
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/about`}
|
||||||
|
className="inline-block border border-[#1A1A1A] text-[#1A1A1A] px-8 py-4 text-sm tracking-wide hover:bg-[#1A1A1A] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Our Story
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile CTA */}
|
||||||
|
<div className="lg:hidden relative z-10 px-6 pb-12">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/products`}
|
||||||
|
className="block w-full bg-[#1A1A1A] text-white text-center py-4 text-sm tracking-wide"
|
||||||
|
>
|
||||||
|
Shop Now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/home/NewsletterSection.tsx
Normal file
91
src/components/home/NewsletterSection.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export default function NewsletterSection() {
|
||||||
|
const t = useTranslations("Newsletter");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus("success");
|
||||||
|
setEmail("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-t border-[#1A1A1A]/[0.06] py-14 lg:py-20 bg-white">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6">
|
||||||
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="font-serif italic text-4xl lg:text-5xl xl:text-[3.5rem] text-[#1A1A1A] tracking-tight leading-[1.1] mb-6"
|
||||||
|
>
|
||||||
|
{t("stayConnected")}
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
|
className="text-[#4A4A4A] mb-8"
|
||||||
|
>
|
||||||
|
{t("newsletterText")}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.form
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
required
|
||||||
|
className="flex-1 px-4 py-4 h-14 border border-[#1A1A1A]/10 rounded-[4px] text-base focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center justify-center gap-2 bg-[#1A1A1A] text-white px-6 py-3 text-sm font-medium hover:bg-[#1A1A1A]/90 transition-colors rounded-[4px]"
|
||||||
|
>
|
||||||
|
{t("subscribe")}
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
|
||||||
|
{status === "success" && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-sm text-emerald-600 mt-4"
|
||||||
|
>
|
||||||
|
Hvala vam! Proverite email za vaš kod za popust.
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
className="text-xs text-[#4A4A4A]/60 mt-4"
|
||||||
|
>
|
||||||
|
Prijavom prihvatate našu Politiku privatnosti. Možete se odjaviti bilo kada.
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/home/ProblemSection.tsx
Normal file
70
src/components/home/ProblemSection.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function ProblemSection() {
|
||||||
|
const t = useTranslations("ProblemSection");
|
||||||
|
const problems = t.raw("problems") as Array<{ problem: string; description: string }>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 bg-gradient-to-b from-[#fefcfb] to-[#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.3em] text-[#c9a962] mb-4 block font-medium">
|
||||||
|
{t("title")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-medium mb-6 leading-tight text-[#1a1a1a]">
|
||||||
|
{t("subtitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#666666] text-lg max-w-xl mx-auto">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
<div className="w-16 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-8 rounded-full" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto mt-16">
|
||||||
|
{problems.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="relative text-center p-8 bg-white rounded-3xl shadow-lg border border-[#f0ede8] hover:shadow-2xl hover:border-[#c9a962]/30 transition-all duration-500 group"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-20 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] rounded-b-full opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-md border border-[#e8e4dc] group-hover:border-[#c9a962]/50 transition-colors duration-300">
|
||||||
|
{index === 0 && (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none" strokeWidth="1.5">
|
||||||
|
<path stroke="#c9a962" strokeLinecap="round" strokeLinejoin="round" 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>
|
||||||
|
)}
|
||||||
|
{index === 1 && (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none" strokeWidth="1.5">
|
||||||
|
<path stroke="#e8967a" strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{index === 2 && (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none" strokeWidth="1.5">
|
||||||
|
<path stroke="#7eb89e" strokeLinecap="round" strokeLinejoin="round" 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#1a1a1a] 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";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { WooProduct } from "@/lib/woocommerce";
|
import type { Product } from "@/types/saleor";
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
import ProductCard from "@/components/product/ProductCard";
|
||||||
|
|
||||||
interface ProductShowcaseProps {
|
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;
|
if (!products || products.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 px-4">
|
<section className="py-20 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="container">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -21,15 +22,16 @@ export default function ProductShowcase({ products }: ProductShowcaseProps) {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl md:text-5xl font-serif mb-4">Our Products</h2>
|
<span className="text-caption text-[#666666] mb-4 block">Our Collection</span>
|
||||||
<p className="text-foreground-muted max-w-2xl mx-auto">
|
<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
|
Discover our premium collection of natural oils for hair and skin care
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</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) => (
|
{products.map((product, index) => (
|
||||||
<ProductCard key={product.id} product={product} index={index} />
|
<ProductCard key={product.id} product={product} index={index} locale={locale} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
100
src/components/home/StatsSection.tsx
Normal file
100
src/components/home/StatsSection.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: "92%", label: "reported improved hair shine in 2 weeks" },
|
||||||
|
{ value: "87%", label: "saw visible reduction in dry skin" },
|
||||||
|
{ value: "95%", label: "noticed smoother, healthier hair texture" },
|
||||||
|
{ value: "89%", label: "experienced softer, more nourished skin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StatsSection() {
|
||||||
|
return (
|
||||||
|
<section className="relative z-20 py-24 lg:py-32 bg-[#E8F4F8]">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||||
|
{/* Left Content */}
|
||||||
|
<div>
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block"
|
||||||
|
>
|
||||||
|
Our Philosophy
|
||||||
|
</motion.span>
|
||||||
|
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
|
className="font-serif italic text-4xl lg:text-5xl text-[#1A1A1A] tracking-tight leading-[1.1] mb-6"
|
||||||
|
>
|
||||||
|
Transformation
|
||||||
|
<br />
|
||||||
|
starts here
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="text-[#4A4A4A] leading-relaxed mb-10"
|
||||||
|
>
|
||||||
|
Every ManoonOils product is built on a simple promise: only
|
||||||
|
ingredients that serve a purpose. Our cold-pressed oils deliver
|
||||||
|
real nourishment for hair and skin, without the noise.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 + index * 0.1 }}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<span className="font-serif text-[72px] leading-none text-[#1A1A1A]/[0.07] select-none absolute top-0 -left-2">
|
||||||
|
{stat.value.replace("%", "")}
|
||||||
|
</span>
|
||||||
|
<div className="relative pt-6">
|
||||||
|
<span className="text-3xl font-medium text-[#1A1A1A]">
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-[#4A4A4A]/80 mt-1 leading-snug">
|
||||||
|
{stat.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Image */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="relative aspect-[4/5] rounded-[4px] overflow-hidden bg-[#F0F7FA]"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="https://minio-api.nodecrew.me/manoon-media/2024/10/manoon3-resized-1.jpg"
|
||||||
|
alt="ManoonOils Luksuzni Set"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/home/TestimonialsSection.tsx
Normal file
78
src/components/home/TestimonialsSection.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Star, Check } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function TestimonialsSection() {
|
||||||
|
const t = useTranslations("Testimonials");
|
||||||
|
|
||||||
|
const reviews = t.raw("reviews") as Array<{
|
||||||
|
name: string;
|
||||||
|
skinType: string;
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 lg:py-32 bg-[#F0F7FA]">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-center mb-16"
|
||||||
|
>
|
||||||
|
<span className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block">
|
||||||
|
Testimonials
|
||||||
|
</span>
|
||||||
|
<h2 className="font-serif italic text-4xl lg:text-5xl text-[#1A1A1A] tracking-tight">
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{reviews.map((review, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||||
|
className="bg-white rounded-[6px] border border-[#1A1A1A]/[0.06] p-9 flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1 mb-5">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className="w-4 h-4 fill-amber-400 text-amber-400"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-serif italic text-base lg:text-lg text-[#1A1A1A] leading-relaxed flex-1 mb-6">
|
||||||
|
“{review.text}”
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-[#1A1A1A]/[0.06]">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[#1A1A1A]">
|
||||||
|
{review.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#4A4A4A]/70">
|
||||||
|
{review.skinType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex items-center gap-1 text-[10px] tracking-wider uppercase text-emerald-600 font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
{t("verified")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/home/TrustBadges.tsx
Normal file
118
src/components/home/TrustBadges.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function TrustBadges() {
|
||||||
|
const t = useTranslations("TrustBadges");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
|
<svg className="w-6 h-6 text-yellow-400" 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>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight">
|
||||||
|
4.9/5
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#1a1a1a] mt-1">
|
||||||
|
{t("averageRating")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
|
{t("basedOnReviews")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.1 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#c9a962" 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>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight">
|
||||||
|
50,000+
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#1a1a1a] mt-1">
|
||||||
|
{t("happyCustomers")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
|
{t("worldwide")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.2 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#7eb89e" 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>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight">
|
||||||
|
100%
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#1a1a1a] mt-1">
|
||||||
|
{t("naturalIngredients")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
|
{t("noAdditives")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#e8967a" 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>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight">
|
||||||
|
Free
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#1a1a1a] mt-1">
|
||||||
|
{t("freeShipping")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
|
{t("ordersOver")}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,64 +1,172 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
|
|
||||||
export default function Footer() {
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Instagram, Facebook } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Footer({ locale = "sr" }: FooterProps) {
|
||||||
|
const t = useTranslations("Footer");
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const localePath = `/${locale}`;
|
||||||
|
|
||||||
|
const footerLinks = {
|
||||||
|
shop: [
|
||||||
|
{ label: t("allProducts"), href: `${localePath}/products` },
|
||||||
|
{ label: t("hairCare"), href: `${localePath}/products` },
|
||||||
|
{ label: t("skinCare"), href: `${localePath}/products` },
|
||||||
|
{ label: t("giftSets"), href: `${localePath}/products` },
|
||||||
|
],
|
||||||
|
about: [
|
||||||
|
{ label: t("ourStory"), href: `${localePath}/about` },
|
||||||
|
{ label: t("process"), href: `${localePath}/about` },
|
||||||
|
{ label: t("sustainability"), href: `${localePath}/about` },
|
||||||
|
],
|
||||||
|
help: [
|
||||||
|
{ label: t("faq"), href: `${localePath}/contact` },
|
||||||
|
{ label: t("shipping"), href: `${localePath}/contact` },
|
||||||
|
{ label: t("returns"), href: `${localePath}/contact` },
|
||||||
|
{ label: t("contactUs"), href: `${localePath}/contact` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-background-ice border-t border-border/30">
|
<footer className="bg-white border-t border-[#e5e5e5]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="container py-16 lg:py-20">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-8">
|
||||||
<div className="md:col-span-2">
|
<div className="lg:col-span-4">
|
||||||
<h3 className="text-2xl font-serif mb-4">ManoonOils</h3>
|
<Link href={localePath} className="inline-block mb-6">
|
||||||
<p className="text-foreground-muted max-w-md">
|
<Image
|
||||||
Premium natural oils for hair and skin care. Crafted with love for your daily beauty routine.
|
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">
|
||||||
|
{t("brandDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div>
|
<div className="lg:col-span-8">
|
||||||
<h4 className="font-serif mb-4">Quick Links</h4>
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-8">
|
||||||
<ul className="space-y-2">
|
<div className="flex flex-col">
|
||||||
<li>
|
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||||
<Link href="/en/products" className="text-foreground-muted hover:text-foreground transition-colors">
|
{t("shop")}
|
||||||
Products
|
</h4>
|
||||||
</Link>
|
<ul className="space-y-3">
|
||||||
</li>
|
{footerLinks.shop.map((link) => (
|
||||||
<li>
|
<li key={link.label}>
|
||||||
<Link href="/en/about" className="text-foreground-muted hover:text-foreground transition-colors">
|
<Link
|
||||||
About Us
|
href={link.href}
|
||||||
</Link>
|
className="text-sm text-[#666666] hover:text-black transition-colors"
|
||||||
</li>
|
>
|
||||||
<li>
|
{link.label}
|
||||||
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
</Link>
|
||||||
Contact
|
</li>
|
||||||
</Link>
|
))}
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-col">
|
||||||
<h4 className="font-serif mb-4">Customer Service</h4>
|
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||||
<ul className="space-y-2">
|
{t("about")}
|
||||||
<li>
|
</h4>
|
||||||
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
<ul className="space-y-3">
|
||||||
Shipping Info
|
{footerLinks.about.map((link) => (
|
||||||
</Link>
|
<li key={link.label}>
|
||||||
</li>
|
<Link
|
||||||
<li>
|
href={link.href}
|
||||||
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
className="text-sm text-[#666666] hover:text-black transition-colors"
|
||||||
Returns
|
>
|
||||||
</Link>
|
{link.label}
|
||||||
</li>
|
</Link>
|
||||||
<li>
|
</li>
|
||||||
<a href="https://manoonoils.com" className="text-foreground-muted hover:text-foreground transition-colors">
|
))}
|
||||||
WooCommerce Store
|
</ul>
|
||||||
</a>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
<div className="flex flex-col">
|
||||||
|
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||||
|
{t("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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/30 mt-12 pt-8 text-center text-foreground-muted text-sm">
|
<div className="border-t border-[#e5e5e5]">
|
||||||
<p>© {currentYear} ManoonOils. All rights reserved.</p>
|
<div className="container py-6">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-[#999999]">
|
||||||
|
© {currentYear} ManoonOils. {t("allRights")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-xs text-[#999999]">
|
||||||
|
<strong>{t("madeWith")} ❤️ by{" "}
|
||||||
|
<a
|
||||||
|
href="https://nodecrew.me"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#c9a962] hover:text-[#b8944f] transition-colors"
|
||||||
|
>
|
||||||
|
Nodecrew
|
||||||
|
</a></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[#999999]">{t("weAccept")}</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,65 +1,181 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import Image from "next/image";
|
||||||
import { useCartStore } from "@/stores/cartStore";
|
import { usePathname } from "next/navigation";
|
||||||
import { formatPrice } from "@/lib/woocommerce";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import MobileMenu from "./MobileMenu";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
|
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
||||||
import CartDrawer from "@/components/cart/CartDrawer";
|
import CartDrawer from "@/components/cart/CartDrawer";
|
||||||
|
import { SUPPORTED_LOCALES, LOCALE_COOKIE, LOCALE_CONFIG, isValidLocale, getPathWithoutLocale, buildLocalePath } from "@/lib/i18n/locales";
|
||||||
|
import type { Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
export default function Header() {
|
interface HeaderProps {
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ locale = "sr" }: HeaderProps) {
|
||||||
|
const t = useTranslations("Header");
|
||||||
|
const pathname = usePathname();
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const { items, toggleCart } = useCartStore();
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
||||||
|
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
||||||
|
|
||||||
const itemCount = items.reduce((count, item) => count + item.quantity, 0);
|
const itemCount = getLineCount();
|
||||||
|
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setLangDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchLocale = (newLocale: string) => {
|
||||||
|
if (newLocale === locale) {
|
||||||
|
setLangDropdownOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidLocale(newLocale)) {
|
||||||
|
setLangDropdownOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.cookie = `${LOCALE_COOKIE}=${newLocale}; path=/; max-age=31536000`;
|
||||||
|
const pathWithoutLocale = getPathWithoutLocale(pathname);
|
||||||
|
const newPath = buildLocalePath(newLocale as Locale, pathWithoutLocale);
|
||||||
|
window.location.replace(newPath);
|
||||||
|
setLangDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initCheckout();
|
||||||
|
}, [initCheckout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrolled(window.scrollY > 50);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileMenuOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: `/${locale}/products`, label: t("products") },
|
||||||
|
{ href: `/${locale}/about`, label: t("about") },
|
||||||
|
{ href: `/${locale}/contact`, label: t("contact") },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-md">
|
<header
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||||
<div className="flex items-center justify-between h-16 md:h-20">
|
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] px-4 lg:px-6">
|
||||||
|
<button
|
||||||
|
className="lg:hidden p-2 -ml-2 hover:bg-black/5 rounded-full transition-colors"
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
aria-label={t("openMenu")}
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Link href={`/${locale}`} 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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-black/5 rounded-full transition-colors flex items-center gap-1"
|
||||||
|
onClick={() => setLangDropdownOpen(!langDropdownOpen)}
|
||||||
|
aria-label="Select language"
|
||||||
|
>
|
||||||
|
<Globe className="w-5 h-5" strokeWidth={1.5} />
|
||||||
|
<span className="text-sm">{currentLocale.flag}</span>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{langDropdownOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="absolute right-0 top-full mt-1 bg-white border border-[#e5e5e5] shadow-lg rounded-md overflow-hidden z-50"
|
||||||
|
>
|
||||||
|
{SUPPORTED_LOCALES.map((loc) => (
|
||||||
|
<button
|
||||||
|
key={loc}
|
||||||
|
onClick={() => switchLocale(loc)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 transition-colors w-full text-left ${
|
||||||
|
loc === locale ? "bg-black/5 font-medium" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{LOCALE_CONFIG[loc].flag}</span>
|
||||||
|
<span>{LOCALE_CONFIG[loc].label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="md:hidden p-2"
|
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
|
||||||
onClick={() => setMobileMenuOpen(true)}
|
aria-label={t("account")}
|
||||||
aria-label="Open menu"
|
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<User className="w-5 h-5" strokeWidth={1.5} />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Link href="/" className="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src="/manoon-logo.jpg"
|
|
||||||
alt="ManoonOils"
|
|
||||||
className="h-8 w-[124px] md:h-10 md:w-[154px]"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
|
||||||
<Link href="/products" className="text-foreground hover:text-accent-dark transition-colors">
|
|
||||||
Products
|
|
||||||
</Link>
|
|
||||||
<Link href="/about" className="text-foreground hover:text-accent-dark transition-colors">
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
<Link href="/contact" className="text-foreground hover:text-accent-dark transition-colors">
|
|
||||||
Contact
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="p-2 relative"
|
className="p-2 hover:bg-black/5 rounded-full transition-colors relative"
|
||||||
onClick={toggleCart}
|
onClick={toggleCart}
|
||||||
aria-label="Open cart"
|
aria-label={t("openCart")}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<ShoppingBag className="w-5 h-5" strokeWidth={1.5} />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
|
||||||
</svg>
|
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 bg-accent-dark text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
|
<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}
|
{itemCount > 99 ? "99+" : itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -69,7 +185,74 @@ export default function Header() {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<MobileMenu onClose={() => setMobileMenuOpen(false)} />
|
<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">
|
||||||
|
<div className="flex items-center justify-between h-[72px]">
|
||||||
|
<Link href={`/${locale}`} 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={t("closeMenu")}
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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} />
|
||||||
|
{t("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} />
|
||||||
|
{t("account")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
|||||||
163
src/components/product/BundleSelector.tsx
Normal file
163
src/components/product/BundleSelector.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Product } from "@/types/saleor";
|
||||||
|
import { getProductPrice, formatPrice } from "@/lib/saleor";
|
||||||
|
|
||||||
|
interface BundleSelectorProps {
|
||||||
|
baseProduct: Product;
|
||||||
|
bundleProducts: Product[];
|
||||||
|
selectedVariantId: string | null;
|
||||||
|
onSelectVariant: (variantId: string, quantity: number, price: number) => void;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BundleOption {
|
||||||
|
product: Product;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
pricePerUnit: number;
|
||||||
|
savings: number;
|
||||||
|
isBase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BundleSelector({
|
||||||
|
baseProduct,
|
||||||
|
bundleProducts,
|
||||||
|
selectedVariantId,
|
||||||
|
onSelectVariant,
|
||||||
|
locale,
|
||||||
|
}: BundleSelectorProps) {
|
||||||
|
const t = useTranslations("Bundle");
|
||||||
|
|
||||||
|
if (bundleProducts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseVariant = baseProduct.variants?.[0];
|
||||||
|
const basePrice = baseVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
|
||||||
|
const options: BundleOption[] = [];
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
product: baseProduct,
|
||||||
|
quantity: 1,
|
||||||
|
price: basePrice,
|
||||||
|
pricePerUnit: basePrice,
|
||||||
|
savings: 0,
|
||||||
|
isBase: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
bundleProducts.forEach((bundle) => {
|
||||||
|
const variant = bundle.variants?.[0];
|
||||||
|
if (!variant?.pricing?.price?.gross?.amount) return;
|
||||||
|
|
||||||
|
const price = variant.pricing.price.gross.amount;
|
||||||
|
const quantityMatch = bundle.name.match(/(\d+)x/i);
|
||||||
|
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
|
||||||
|
const pricePerUnit = price / quantity;
|
||||||
|
const savings = (basePrice * quantity) - price;
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
product: bundle,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
pricePerUnit,
|
||||||
|
savings,
|
||||||
|
isBase: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
options.sort((a, b) => a.quantity - b.quantity);
|
||||||
|
|
||||||
|
const formatPriceWithLocale = (amount: number, currency: string = "RSD") => {
|
||||||
|
const localeMap: Record<string, string> = {
|
||||||
|
sr: "sr-RS",
|
||||||
|
en: "en-US",
|
||||||
|
de: "de-DE",
|
||||||
|
fr: "fr-FR",
|
||||||
|
};
|
||||||
|
const numLocale = localeMap[locale] || "sr-RS";
|
||||||
|
return new Intl.NumberFormat(numLocale, {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("selectBundle")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{options.map((option) => {
|
||||||
|
const variantId = option.isBase
|
||||||
|
? baseVariant?.id
|
||||||
|
: option.product.variants?.[0]?.id;
|
||||||
|
const isSelected = selectedVariantId === variantId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={option.product.id}
|
||||||
|
onClick={() => variantId && onSelectVariant(variantId, option.quantity, option.price)}
|
||||||
|
className={`w-full p-4 border-2 transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
|
isSelected
|
||||||
|
? "border-white bg-white"
|
||||||
|
: "border-[#999999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="w-2.5 h-2.5 rounded-full bg-black"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{option.isBase ? t("singleUnit") : t("xSet", { count: option.quantity })}
|
||||||
|
</span>
|
||||||
|
{!option.isBase && option.savings > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-green-500">
|
||||||
|
{t("save", { amount: formatPriceWithLocale(option.savings) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`font-bold ${isSelected ? "text-white" : "text-black"}`}>
|
||||||
|
{formatPriceWithLocale(option.price)}
|
||||||
|
</div>
|
||||||
|
{!option.isBase && (
|
||||||
|
<div className={`text-xs ${isSelected ? "text-white/70" : "text-[#666666]"}`}>
|
||||||
|
{formatPriceWithLocale(option.pricePerUnit)} {t("perUnit")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/components/product/ProductBenefits.tsx
Normal file
96
src/components/product/ProductBenefits.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface ProductBenefitsProps {
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductBenefits({ locale = "sr" }: ProductBenefitsProps) {
|
||||||
|
const t = useTranslations("ProductBenefits");
|
||||||
|
|
||||||
|
const benefits = [
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
|
<path stroke="#c9a962" 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.09z" />
|
||||||
|
<path stroke="#c9a962" strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
title: t("pureNatural"),
|
||||||
|
description: t("pureNaturalDesc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#e8967a"/>
|
||||||
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" stroke="#c9a962" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
title: t("crueltyFree"),
|
||||||
|
description: t("crueltyFreeDesc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#7eb89e"/>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" stroke="#c9a962" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
title: t("madeWithLove"),
|
||||||
|
description: t("madeWithLoveDesc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<svg className="w-10 h-10" viewBox="0 0 24 24" fill="none">
|
||||||
|
<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" fill="#c9a962"/>
|
||||||
|
<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" stroke="#b8944f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
title: t("visibleResults"),
|
||||||
|
description: t("visibleResultsDesc"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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-[#c9a962] mb-3 block font-medium">
|
||||||
|
{t("whyChoose")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-medium">
|
||||||
|
{t("manoonDifference")}
|
||||||
|
</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-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm border border-[#e8e4dc]">
|
||||||
|
{benefit.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-medium mb-2 text-[#1a1a1a]">{benefit.title}</h3>
|
||||||
|
<p className="text-sm text-[#666666] leading-relaxed">{benefit.description}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,24 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Product } from "@/types/saleor";
|
||||||
|
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||||
|
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: WooProduct;
|
product: Product;
|
||||||
index?: number;
|
index?: number;
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product, index = 0 }: ProductCardProps) {
|
export default function ProductCard({ product, index = 0, locale = "sr" }: ProductCardProps) {
|
||||||
|
const t = useTranslations("ProductCard");
|
||||||
const image = getProductImage(product);
|
const image = getProductImage(product);
|
||||||
|
const price = getProductPrice(product);
|
||||||
|
const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
|
||||||
|
const localized = getLocalizedProduct(product, saleorLocale);
|
||||||
|
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -20,30 +29,50 @@ export default function ProductCard({ product, index = 0 }: ProductCardProps) {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
>
|
>
|
||||||
<Link href={`/products/${product.slug}`} className="group block">
|
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
|
||||||
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden mb-4">
|
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||||
{image && (
|
{image ? (
|
||||||
<Image
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt={product.name}
|
alt={localized.name}
|
||||||
fill
|
className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
{product.stock_status === "outofstock" && (
|
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
||||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
<span className="text-sm">{t("noImage")}</span>
|
||||||
<span className="text-white font-medium">Out of Stock</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isAvailable && (
|
||||||
|
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] text-[#666666]">
|
||||||
|
{t("outOfStock")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("quickAdd")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-serif text-lg mb-1 group-hover:text-accent-dark transition-colors">
|
<div className="text-center">
|
||||||
{product.name}
|
<h3 className="text-[15px] font-medium text-[#1a1a1a] mb-1 group-hover:text-[#666666] transition-colors line-clamp-1">
|
||||||
</h3>
|
{localized.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p className="text-foreground-muted">
|
<p className="text-[14px] text-[#666666]">
|
||||||
{product.price ? formatPrice(product.price) : "Contact for price"}
|
{price || t("contactForPrice")}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,182 +1,495 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion } from "framer-motion";
|
import Link from "next/link";
|
||||||
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useCartStore } from "@/stores/cartStore";
|
import { ChevronDown, Star, Minus, Plus } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Product } from "@/types/saleor";
|
||||||
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
|
import { getProductPrice, getProductPriceAmount, getLocalizedProduct, formatPrice } from "@/lib/saleor";
|
||||||
|
import { getTranslatedShortDescription, getTranslatedBenefits } from "@/lib/i18n/productText";
|
||||||
|
import { isValidLocale } from "@/lib/i18n/locales";
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
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";
|
||||||
|
import BundleSelector from "@/components/product/BundleSelector";
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
product: WooProduct;
|
product: Product;
|
||||||
relatedProducts: WooProduct[];
|
relatedProducts: Product[];
|
||||||
|
bundleProducts?: Product[];
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductDetail({ product, relatedProducts }: ProductDetailProps) {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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-yellow-400 text-yellow-400' : 'text-gray-300'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="text-sm text-[#666666] ml-1">({count >= 1000 ? '1000+' : count})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetail({ product, relatedProducts, bundleProducts = [], locale = "sr" }: ProductDetailProps) {
|
||||||
|
const t = useTranslations("ProductDetail");
|
||||||
|
const tProduct = useTranslations("Product");
|
||||||
const [selectedImage, setSelectedImage] = useState(0);
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [activeTab, setActiveTab] = useState<"details" | "ingredients" | "usage">("details");
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const addItem = useCartStore((state) => state.addItem);
|
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||||
|
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||||
|
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||||
|
|
||||||
const images = product.images?.length > 0
|
useEffect(() => {
|
||||||
? product.images
|
const interval = setInterval(() => {
|
||||||
: [{ id: 0, src: "/placeholder-product.jpg", alt: product.name }];
|
setUrgencyIndex(prev => (prev + 1) % 3);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
const urgencyMessages = [
|
||||||
addItem({
|
{ icon: "🚀", text: t("urgency1") },
|
||||||
id: product.id,
|
{ icon: "🛒", text: t("urgency2") },
|
||||||
name: product.name,
|
{ icon: "👀", text: t("urgency3") },
|
||||||
price: product.price || product.regular_price,
|
];
|
||||||
quantity,
|
|
||||||
image: images[0]?.src || "",
|
const localized = getLocalizedProduct(product, locale);
|
||||||
sku: product.sku || "",
|
const baseVariant = product.variants?.[0];
|
||||||
});
|
const selectedVariantId = selectedBundleVariantId || baseVariant?.id;
|
||||||
|
|
||||||
|
const selectedVariant = selectedVariantId === baseVariant?.id
|
||||||
|
? baseVariant
|
||||||
|
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
|
||||||
|
|
||||||
|
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 (!selectedVariantId) return;
|
||||||
|
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
await addLine(selectedVariantId, 1);
|
||||||
|
openCart();
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripHtml = (html: string) => {
|
const handleSelectVariant = (variantId: string, qty: number, price: number) => {
|
||||||
return html.replace(/<[^>]*>/g, "");
|
setSelectedBundleVariantId(variantId);
|
||||||
|
setQuantity(qty);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAvailable = (selectedVariant?.quantityAvailable ?? 0) > 0;
|
||||||
|
|
||||||
|
const selectedPrice = selectedVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
const price = selectedPrice > 0
|
||||||
|
? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: selectedVariant?.pricing?.price?.gross?.currency || "RSD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(selectedPrice)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const priceAmount = selectedPrice;
|
||||||
|
const originalPrice = priceAmount > 0 ? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RSD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Math.round(priceAmount * 1.30)) : null;
|
||||||
|
|
||||||
|
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
|
||||||
|
|
||||||
|
const metadataBenefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',');
|
||||||
|
const benefits = getTranslatedBenefits(metadataBenefits, validLocale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="py-12 md:py-20 px-4">
|
<section className="min-h-screen" id="product-detail">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
<div className="container py-5">
|
||||||
<motion.div
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
initial={{ opacity: 0, x: -20 }}
|
<Link href={`/${validLocale}`} className="text-[#666666] hover:text-black transition-colors">
|
||||||
animate={{ opacity: 1, x: 0 }}
|
{t("home")}
|
||||||
transition={{ duration: 0.6 }}
|
</Link>
|
||||||
>
|
<span className="text-[#999999]">/</span>
|
||||||
<div className="relative aspect-square bg-background-ice mb-4">
|
<span className="text-[#1a1a1a]">{localized.name}</span>
|
||||||
{images[selectedImage] && (
|
</nav>
|
||||||
<Image
|
</div>
|
||||||
src={images[selectedImage].src}
|
</div>
|
||||||
alt={images[selectedImage].alt || product.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="container py-12 lg:py-16">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="flex flex-col md:flex-row gap-4"
|
||||||
|
>
|
||||||
{images.length > 1 && (
|
{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) => (
|
{images.map((image, index) => (
|
||||||
<button
|
<button
|
||||||
key={image.id}
|
key={image.id}
|
||||||
onClick={() => setSelectedImage(index)}
|
onClick={() => setSelectedImage(index)}
|
||||||
className={`relative w-20 h-20 flex-shrink-0 ${
|
className={`relative aspect-square w-full overflow-hidden border-2 transition-colors ${
|
||||||
selectedImage === index ? "ring-2 ring-foreground" : ""
|
selectedImage === index
|
||||||
|
? "border-black"
|
||||||
|
: "border-transparent hover:border-[#999999]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image
|
<img
|
||||||
src={image.src}
|
src={image.url}
|
||||||
alt={image.alt || product.name}
|
alt={image.alt || localized.name}
|
||||||
fill
|
className="w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1">
|
||||||
|
<img
|
||||||
|
src={images[selectedImage].url}
|
||||||
|
alt={images[selectedImage].alt || localized.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedImage(prev => prev === 0 ? images.length - 1 : prev - 1)}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 hover:bg-white rounded-full flex items-center justify-center shadow-md transition-all hover:scale-110 md:hidden"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedImage(prev => prev === images.length - 1 ? 0 : prev + 1)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 hover:bg-white rounded-full flex items-center justify-center shadow-md transition-all hover:scale-110 md:hidden"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 md:hidden">
|
||||||
|
{images.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setSelectedImage(index)}
|
||||||
|
className={`w-2 h-2 rounded-full transition-all ${
|
||||||
|
selectedImage === index ? "bg-white w-4" : "bg-white/50"
|
||||||
|
}`}
|
||||||
|
aria-label={`Go to image ${index + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="lg:pl-8"
|
||||||
>
|
>
|
||||||
<h1 className="text-3xl md:text-4xl font-serif mb-4">
|
<motion.div
|
||||||
{product.name}
|
key={urgencyIndex}
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 10 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 rounded-lg mb-4 text-sm font-medium text-left"
|
||||||
|
>
|
||||||
|
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
|
||||||
|
{urgencyMessages[urgencyIndex].text}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
|
||||||
|
{localized.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-2xl text-foreground-muted mb-6">
|
<p className="text-[#666666] leading-relaxed mb-4">
|
||||||
{product.price ? formatPrice(product.price) : "Contact for price"}
|
{shortDescription}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="prose prose-sm max-w-none mb-8 text-foreground-muted">
|
<div className="flex items-center justify-start gap-2 mb-6">
|
||||||
<p>{stripHtml(product.short_description || product.description.slice(0, 200))}</p>
|
<span className="relative flex h-3 w-3">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||||
|
</span>
|
||||||
|
<span className="text-red-600 text-sm font-medium">{t("stocksRunningOut")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.stock_status === "instock" ? (
|
{originalPrice && priceAmount > 0 && (
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<div className="mb-4">
|
||||||
<div className="flex items-center border border-border">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<button
|
<span className="text-xl text-[#666666] line-through">
|
||||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
{originalPrice}
|
||||||
className="px-4 py-3"
|
</span>
|
||||||
>
|
<span className="bg-[#b91c1c] text-white text-xs font-bold px-2 py-1 rounded">
|
||||||
-
|
-30%
|
||||||
</button>
|
</span>
|
||||||
<span className="px-4 py-3">{quantity}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setQuantity(quantity + 1)}
|
|
||||||
className="px-4 py-3"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-3xl font-bold text-[#b91c1c]">
|
||||||
<button
|
{price}
|
||||||
onClick={handleAddToCart}
|
</span>
|
||||||
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
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-border/30">
|
{!originalPrice && (
|
||||||
<div className="flex border-b border-border/30">
|
<div className="flex items-center justify-between mb-8">
|
||||||
{(["details", "ingredients", "usage"] as const).map((tab) => (
|
<span className="text-3xl font-medium">
|
||||||
<button
|
{price || tProduct("outOfStock")}
|
||||||
key={tab}
|
</span>
|
||||||
onClick={() => setActiveTab(tab)}
|
<StarRating rating={5} count={1000} />
|
||||||
className={`flex-1 py-4 font-medium capitalize ${
|
|
||||||
activeTab === tab
|
|
||||||
? "border-b-2 border-foreground"
|
|
||||||
: "text-foreground-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="py-6 text-foreground-muted">
|
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||||
{activeTab === "details" && (
|
|
||||||
<p>{stripHtml(product.description)}</p>
|
{bundleProducts.length > 0 ? (
|
||||||
)}
|
<BundleSelector
|
||||||
{activeTab === "ingredients" && (
|
baseProduct={product}
|
||||||
<p>Natural ingredients - Contact for detailed information.</p>
|
bundleProducts={bundleProducts}
|
||||||
)}
|
selectedVariantId={selectedBundleVariantId || baseVariant?.id || null}
|
||||||
{activeTab === "usage" && (
|
onSelectVariant={handleSelectVariant}
|
||||||
<p>Apply to clean skin or hair. Use daily for best results.</p>
|
locale={validLocale}
|
||||||
)}
|
/>
|
||||||
|
) : (
|
||||||
|
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">
|
||||||
|
{t("size")}
|
||||||
|
</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 === baseVariant?.id
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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
|
||||||
|
? t("adding")
|
||||||
|
: t("transformHairSkin")
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-16 bg-[#f8f9fa] text-[#666666] flex items-center justify-center text-base uppercase tracking-[0.15em] mb-8">
|
||||||
|
{t("outOfStock")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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]">
|
||||||
|
{t("freeShipping")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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]">
|
||||||
|
{t("guarantee")}
|
||||||
|
</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]">
|
||||||
|
{t("secureCheckout")}
|
||||||
|
</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]">
|
||||||
|
{t("easyReturns")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("benefits")}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ExpandableSection title={t("description")}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: localized.description }} />
|
||||||
|
</ExpandableSection>
|
||||||
|
|
||||||
|
<ExpandableSection title={t("howToUse")}>
|
||||||
|
<p>{t("howToUseText")}</p>
|
||||||
|
</ExpandableSection>
|
||||||
|
|
||||||
|
<ExpandableSection title={t("ingredients")}>
|
||||||
|
<p>{t("ingredientsText")}</p>
|
||||||
|
</ExpandableSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVariant?.sku && (
|
||||||
|
<p className="text-xs text-[#999999] mt-8">
|
||||||
|
SKU: {selectedVariant.sku}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{relatedProducts.length > 0 && (
|
<ProductReviews locale={locale} productName={localized.name} />
|
||||||
<section className="py-12 px-4 bg-background-ice">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
<AsSeenIn />
|
||||||
<h2 className="text-2xl font-serif text-center mb-8">
|
|
||||||
You May Also Like
|
<BeforeAfterGallery />
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
{relatedProducts && relatedProducts.length > 0 && (
|
||||||
{relatedProducts.map((product, index) => (
|
<section className="py-20 lg:py-28 bg-[#f8f9fa]">
|
||||||
<ProductCard key={product.id} product={product} index={index} />
|
<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">
|
||||||
|
{t("youMayAlsoLike")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-medium">
|
||||||
|
{t("similarProducts")}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ProductBenefits key={locale} locale={locale} />
|
||||||
|
|
||||||
|
<TrustBadges />
|
||||||
|
|
||||||
|
<HowItWorks />
|
||||||
|
|
||||||
|
<NewsletterSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
134
src/components/product/ProductReviews.tsx
Normal file
134
src/components/product/ProductReviews.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface Review {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
text: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductReviewsProps {
|
||||||
|
locale?: string;
|
||||||
|
productName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewCard({ review }: { review: Review }) {
|
||||||
|
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-yellow-400 text-yellow-400" 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>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<p className="text-sm font-medium">{review.name}</p>
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-[#888888]">{review.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductReviews(_props: ProductReviewsProps) {
|
||||||
|
const t = useTranslations("ProductReviews");
|
||||||
|
const reviews = t.raw("reviews") as Review[];
|
||||||
|
|
||||||
|
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">
|
||||||
|
{t("customerReviews")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-medium">
|
||||||
|
{t("whatCustomersSay")}
|
||||||
|
</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-yellow-400 text-yellow-400" 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">{t("basedOnReviews")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-20 bg-gradient-to-r from-[#faf9f7] to-transparent z-10 pointer-events-none" />
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-20 bg-gradient-to-l from-[#faf9f7] to-transparent z-10 pointer-events-none" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { SUPPORTED_LOCALES, isValidLocale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
export default async function LocaleProvider({
|
export default async function LocaleProvider({
|
||||||
children,
|
children,
|
||||||
@@ -11,8 +12,7 @@ export default async function LocaleProvider({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
locale: string;
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
const locales = ["en", "sr"];
|
if (!isValidLocale(locale)) notFound();
|
||||||
if (!locales.includes(locale)) notFound();
|
|
||||||
|
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
|||||||
51
src/components/ui/Marquee.tsx
Normal file
51
src/components/ui/Marquee.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MarqueeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
speed?: "slow" | "normal" | "fast";
|
||||||
|
pauseOnHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Marquee({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
speed = "normal",
|
||||||
|
pauseOnHover = false,
|
||||||
|
}: MarqueeProps) {
|
||||||
|
const speedClass = {
|
||||||
|
slow: "animate-marquee-slow",
|
||||||
|
normal: "animate-marquee",
|
||||||
|
fast: "animate-marquee-fast",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex overflow-hidden",
|
||||||
|
pauseOnHover && "hover:[animation-play-state:paused]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center whitespace-nowrap will-change-transform",
|
||||||
|
speedClass[speed]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center whitespace-nowrap will-change-transform",
|
||||||
|
speedClass[speed]
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
src/i18n/messages/de.json
Normal file
375
src/i18n/messages/de.json
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
{
|
||||||
|
"Navigation": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"products": "Produkte",
|
||||||
|
"about": "Über uns",
|
||||||
|
"contact": "Kontakt"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Premium Natürliche Öle",
|
||||||
|
"subtitle": "Für Haar- und Hautpflege",
|
||||||
|
"lovedBy": "Von 50.000+ Kunden weltweit geliebt",
|
||||||
|
"transformHeadline": "Transformieren Sie Ihr Haar & Haut",
|
||||||
|
"withNaturalOils": "mit 100% Natürlichen Ölen",
|
||||||
|
"subtitleText": "Kaltgepresste, biologische Öle mit Liebe handgefertigt. Keine Zusatzstoffe, keine Konservierungsstoffe - nur die reinste Güte der Natur für Ihr tägliches Schönheitsritual.",
|
||||||
|
"ctaButton": "Mein Haar & Haut transformieren",
|
||||||
|
"learnStory": "Unsere Geschichte entdecken",
|
||||||
|
"moneyBack": "30-Tage Geld-zurück",
|
||||||
|
"freeShipping": "Kostenloser Versand über 3.000 RSD",
|
||||||
|
"crueltyFree": "Tierversuchsfrei"
|
||||||
|
},
|
||||||
|
"collection": "Unsere Kollektion",
|
||||||
|
"premiumOils": "Premium Natürliche Öle",
|
||||||
|
"oilsDescription": "Kaltgepresste, reine und natürliche Öle für Ihre tägliche Schönheitsroutine",
|
||||||
|
"viewAll": "Alle Produkte ansehen",
|
||||||
|
"ourStory": "Unsere Geschichte",
|
||||||
|
"handmadeWithLove": "Mit Liebe handgefertigt",
|
||||||
|
"storyText1": "Jede Flasche ManoonOils wird mit Sorgfalt unter Verwendung traditioneller Methoden hergestellt, die von Generation zu Generation weitergegeben werden. Wir beziehen nur die feinsten biologischen Zutaten, um Ihnen Öle zu bringen, die Haar und Haut pflegen.",
|
||||||
|
"storyText2": "Unser Engagement für Reinheit bedeutet keine Zusatzstoffe, keine Konservierungsstoffe - nur die Güte der Natur in ihrer potentesten Form.",
|
||||||
|
"learnMore": "Mehr erfahren",
|
||||||
|
"whyChooseUs": "Warum uns wählen",
|
||||||
|
"manoonDifference": "Der Manoon Unterschied",
|
||||||
|
"stayConnected": "Bleiben Sie verbunden",
|
||||||
|
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
||||||
|
"newsletterText": "Abonnieren Sie, um exklusive Angebote, Schönheitstipps zu erhalten und als Erster über neue Produkte informiert zu werden.",
|
||||||
|
"emailPlaceholder": "Geben Sie Ihre E-Mail ein",
|
||||||
|
"subscribe": "Abonnieren"
|
||||||
|
},
|
||||||
|
"Benefits": {
|
||||||
|
"natural": "100% Natürlich",
|
||||||
|
"naturalDesc": "Reine, kaltgepresste Öle ohne Zusatzstoffe oder Konservierungsstoffe. Nur die Güte der Natur.",
|
||||||
|
"handcrafted": "Handgefertigt",
|
||||||
|
"handcraftedDesc": "Jede Charge wird sorgfältig von Hand zubereitet, um höchste Qualität zu gewährleisten.",
|
||||||
|
"sustainable": "Nachhaltig",
|
||||||
|
"sustainableDesc": "Ethnisch beschaffte Zutaten und umweltfreundliche Verpackungen für einen besseren Planeten."
|
||||||
|
},
|
||||||
|
"Products": {
|
||||||
|
"collection": "Unsere Kollektion",
|
||||||
|
"allProducts": "Alle Produkte",
|
||||||
|
"productsCount": "{count} Produkte",
|
||||||
|
"featured": "Empfohlen",
|
||||||
|
"newest": "Neueste",
|
||||||
|
"priceLow": "Preis: Aufsteigend",
|
||||||
|
"priceHigh": "Preis: Absteigend",
|
||||||
|
"noProducts": "Keine Produkte verfügbar",
|
||||||
|
"checkBack": "Bitte schauen Sie später für neue Produkte vorbei."
|
||||||
|
},
|
||||||
|
"Product": {
|
||||||
|
"addToCart": "In den Warenkorb",
|
||||||
|
"outOfStock": "Nicht auf Lager",
|
||||||
|
"details": "Details",
|
||||||
|
"ingredients": "Zutaten",
|
||||||
|
"usage": "Anwendung",
|
||||||
|
"related": "Das könnte Ihnen auch gefallen",
|
||||||
|
"notFound": "Produkt nicht gefunden",
|
||||||
|
"notFoundDesc": "Das gesuchte Produkt existiert nicht oder wurde entfernt.",
|
||||||
|
"browseProducts": "Produkte durchsuchen"
|
||||||
|
},
|
||||||
|
"Cart": {
|
||||||
|
"title": "Ihr Warenkorb",
|
||||||
|
"yourCart": "Ihr Warenkorb",
|
||||||
|
"closeCart": "Warenkorb schließen",
|
||||||
|
"empty": "Ihr Warenkorb ist leer",
|
||||||
|
"emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.",
|
||||||
|
"continueShopping": "Weiter einkaufen",
|
||||||
|
"startShopping": "Einkauf starten",
|
||||||
|
"checkout": "Zur Kasse",
|
||||||
|
"subtotal": "Zwischensumme",
|
||||||
|
"shipping": "Versand",
|
||||||
|
"shippingCalc": "Wird an der Kasse berechnet",
|
||||||
|
"calculatedAtCheckout": "Wird an der Kasse berechnet",
|
||||||
|
"dismiss": "Schließen",
|
||||||
|
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
||||||
|
"looksLikeEmpty": "Es sieht so aus, als hätten Sie nichts hinzugefügt",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"freeShipping": "Kostenloser Versand bei Bestellungen über {amount}",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"removeItem": "Artikel entfernen",
|
||||||
|
"processes": "Wird bearbeitet...",
|
||||||
|
"cartEmpty": "Ihr Warenkorb ist leer",
|
||||||
|
"qty": "Menge"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "Über uns",
|
||||||
|
"subtitle": "Unsere Geschichte",
|
||||||
|
"intro": "ManoonOils wurde aus einer Leidenschaft für natürliche Schönheit und dem Glauben geboren, dass die beste Hautpflege von der Natur selbst kommt.",
|
||||||
|
"intro2": "Wir glauben an die Kraft natürlicher Inhaltsstoffe. Jedes Öl in unserer Kollektion wurde sorgfältig aufgrund seiner einzigartigen Eigenschaften und Vorteile ausgewählt. Von nährenden Ölen, die die Haarlebenskraft wiederherstellen, bis zu Seren, die die Haut verjüngen, stellen wir jedes Produkt mit Liebe und Liebe zum Detail her.",
|
||||||
|
"naturalIngredients": "Natürliche Inhaltsstoffe",
|
||||||
|
"naturalIngredientsDesc": "Wir verwenden nur die feinsten natürlichen Inhaltsstoffe, die ethisch und nachhaltig von vertrauenswürdigen Lieferanten weltweit beschafft werden.",
|
||||||
|
"crueltyFree": "Tierversuchsfrei",
|
||||||
|
"crueltyFreeDesc": "Unsere Produkte werden niemals an Tieren getestet. Wir glauben an Schönheit ohne Kompromisse.",
|
||||||
|
"sustainablePackaging": "Nachhaltige Verpackung",
|
||||||
|
"sustainablePackagingDesc": "Wir verwenden umweltfreundliche Verpackungsmaterialien und minimieren Abfall während unseres gesamten Produktionsprozesses.",
|
||||||
|
"handcraftedQuality": "Handwerkliche Qualität",
|
||||||
|
"handcraftedQualityDesc": "Jede Flasche wird in kleinen Chargen handgefertigt, um höchste Qualität und Frische zu gewährleisten.",
|
||||||
|
"mission": "Unsere Mission",
|
||||||
|
"missionQuote": "\"Hochwertige, natürliche Produkte anzubieten, die Ihre tägliche Schönheitsroutine verbessern.\"",
|
||||||
|
"handmadeTitle": "Mit Liebe handgefertigt",
|
||||||
|
"handmadeText1": "Jede Flasche ManoonOils wird mit Sorgfalt handgefertigt. Wir stellen unsere Produkte in kleinen Chargen her, um höchste Qualität und Frische zu gewährleisten. Wenn Sie ManoonOils verwenden, können Sie sicher sein, dass Sie etwas verwenden, das mit echter Sorgfalt und Fachwissen hergestellt wurde.",
|
||||||
|
"handmadeText2": "Unsere Reise begann mit einer einfachen Frage: Wie können wir Produkte herstellen, die sowohl Haar als auch Haut wirklich pflegen? Heute innovieren wir weiter, während wir unserem Engagement für natürliche, effektive Schönheitslösungen treu bleiben."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"title": "Kontakt",
|
||||||
|
"subtitle": "Kontakt aufnehmen",
|
||||||
|
"getInTouch": "Kontakt aufnehmen",
|
||||||
|
"getInTouchDesc": "Wir sind hier um zu helfen! Ob Sie Fragen zu unseren Produkten haben, Hilfe mit einer Bestellung benötigen oder einfach Hallo sagen möchten - wir würden uns freuen, von Ihnen zu hören.",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"emailReply": "Wir antworten innerhalb von 24 Stunden",
|
||||||
|
"shippingTitle": "Versand",
|
||||||
|
"freeShipping": "Kostenloser Versand über 3.000 RSD",
|
||||||
|
"deliveryTime": "Geliefert innerhalb von 2-5 Werktagen",
|
||||||
|
"location": "Standort",
|
||||||
|
"locationDesc": "Serbien",
|
||||||
|
"worldwideShipping": "Versand weltweit",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Ihr Name",
|
||||||
|
"emailField": "E-Mail",
|
||||||
|
"emailPlaceholder": "ihre@email.com",
|
||||||
|
"message": "Nachricht",
|
||||||
|
"messagePlaceholder": "Wie können wir Ihnen helfen?",
|
||||||
|
"sendMessage": "Nachricht senden",
|
||||||
|
"thankYou": "Vielen Dank!",
|
||||||
|
"thankYouDesc": "Ihre Nachricht wurde gesendet. Wir werden uns in Kürze bei Ihnen melden.",
|
||||||
|
"faqTitle": "Häufig gestellte Fragen",
|
||||||
|
"faq1q": "Wie lange dauert der Versand?",
|
||||||
|
"faq1a": "Bestellungen werden in der Regel innerhalb von 2-5 Werktagen für Inlandsversand geliefert. Sie erhalten eine Tracking-Nummer, sobald Ihre Bestellung versandt wurde.",
|
||||||
|
"faq2q": "Sind Ihre Produkte 100% natürlich?",
|
||||||
|
"faq2a": "Ja! Alle unsere Öle sind 100% natürlich, kaltgepresst und frei von jeglichen Zusatzstoffen, Konservierungsstoffen oder künstlichen Duftstoffen.",
|
||||||
|
"faq3q": "Wie ist Ihre Rückgaberichtlinie?",
|
||||||
|
"faq3a": "Wir akzeptieren Rücksendungen innerhalb von 14 Tagen nach Lieferung für ungeöffnete Produkte. Bitte kontaktieren Sie uns, wenn Sie Probleme mit Ihrer Bestellung haben.",
|
||||||
|
"faq4q": "Bieten Sie Großhandel an?",
|
||||||
|
"faq4a": "Ja, wir bieten Großhandelspreise für Bulk-Bestellungen. Bitte kontaktieren Sie uns unter hello@manoonoils.com für mehr Informationen."
|
||||||
|
},
|
||||||
|
"Footer": {
|
||||||
|
"quickLinks": "Schnelle Links",
|
||||||
|
"customerService": "Kundenservice",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"shipping": "Versand",
|
||||||
|
"returns": "Rückgabe",
|
||||||
|
"faq": "FAQ",
|
||||||
|
"followUs": "Folgen Sie uns",
|
||||||
|
"newsletter": "Newsletter",
|
||||||
|
"newsletterDesc": "Abonnieren Sie unseren Newsletter für exklusive Angebote und Updates.",
|
||||||
|
"copyright": "Alle Rechte vorbehalten.",
|
||||||
|
"allRights": "Alle Rechte vorbehalten.",
|
||||||
|
"shop": "Shop",
|
||||||
|
"allProducts": "Alle Produkte",
|
||||||
|
"hairCare": "Haarpflege",
|
||||||
|
"skinCare": "Hautpflege",
|
||||||
|
"giftSets": "Geschenksets",
|
||||||
|
"about": "Über uns",
|
||||||
|
"ourStory": "Unsere Geschichte",
|
||||||
|
"process": "Prozess",
|
||||||
|
"sustainability": "Nachhaltigkeit",
|
||||||
|
"help": "Hilfe",
|
||||||
|
"contactUs": "Kontaktieren Sie uns",
|
||||||
|
"brandDescription": "Premium natürliche Öle für Haar- und Hautpflege. Handgefertigt mit Liebe unter Verwendung traditioneller Methoden.",
|
||||||
|
"weAccept": "Wir akzeptieren:",
|
||||||
|
"madeWith": "Erstellt mit"
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"loading": "Laden...",
|
||||||
|
"error": "Ein Fehler ist aufgetreten",
|
||||||
|
"tryAgain": "Erneut versuchen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"previous": "Vorherige",
|
||||||
|
"search": "Suchen",
|
||||||
|
"noResults": "Keine Ergebnisse gefunden"
|
||||||
|
},
|
||||||
|
"Testimonials": {
|
||||||
|
"title": "Was unsere Kunden sagen",
|
||||||
|
"verified": "Verifizierter Kauf",
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"name": "Sarah M.",
|
||||||
|
"skinType": "Trockene, empfindliche Haut",
|
||||||
|
"text": "Ich habe im Laufe der Jahre unzählige Öle ausprobiert, aber ManoonOils ist anders. Meine Haut hat sich noch nie so genährt und gesund angefühlt. Das Arganöl ist jetzt ein Grundnahrungsmittel in meiner Routine."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "James K.",
|
||||||
|
"skinType": "Haarpflege-Enthusiast",
|
||||||
|
"text": "Endlich ein Öl gefunden, das meinen Frizz wirklich bändigt, ohne mein Haar fettig zu machen. Das Jojobaöl wirkt auch bei meinem Bart Wunder. Sehr empfehlenswert!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Emma L.",
|
||||||
|
"skinType": "Mischhaut",
|
||||||
|
"text": "War zuerst skeptisch, aber nach 3 Wochen Hagebuttenöl hat sich meine Hauttextur dramatisch verbessert. Die Qualität ist unübertroffen."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ProductReviews": {
|
||||||
|
"customerReviews": "Kundenbewertungen",
|
||||||
|
"whatCustomersSay": "Was Kunden sagen",
|
||||||
|
"basedOnReviews": "Basierend auf 1000+ Bewertungen",
|
||||||
|
"reviews": [
|
||||||
|
{ "id": 1, "name": "Ana M.", "location": "Belgrad", "text": "Manoon Anti-Age Serum hat meine Haut in nur 2 Wochen transformiert!", "rating": 5 },
|
||||||
|
{ "id": 2, "name": "Milica P.", "location": "Novi Sad", "text": "Das beste Tageserum, das ich je verwendet habe. Meine Falten sind sichtbar reduziert.", "rating": 5 },
|
||||||
|
{ "id": 3, "name": "Jelena K.", "location": "Belgrad", "text": "Manoon Nachtserum ist pure Magie. Aufwachen mit strahlender Haut jeden Morgen.", "rating": 5 },
|
||||||
|
{ "id": 4, "name": "Stefan R.", "location": "Subotica", "text": "Das Anti-Age Set ist jeden Dinar wert. Meine Frau und ich benutzen es beide.", "rating": 5 },
|
||||||
|
{ "id": 5, "name": "Marija T.", "location": "Kragujevac", "text": "Endlich ein Serum gefunden, das wirklich funktioniert! Manoon hält seine Versprechen.", "rating": 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TrustBadges": {
|
||||||
|
"averageRating": "Durchschnittliche Bewertung",
|
||||||
|
"basedOnReviews": "Basierend auf 1000+ Bewertungen",
|
||||||
|
"happyCustomers": "Zufriedene Kunden",
|
||||||
|
"worldwide": "Weltweit",
|
||||||
|
"naturalIngredients": "Natürliche Inhaltsstoffe",
|
||||||
|
"noAdditives": "Keine Zusatzstoffe",
|
||||||
|
"freeShipping": "Kostenloser Versand",
|
||||||
|
"ordersOver": "Bestellungen über 3.000 RSD"
|
||||||
|
},
|
||||||
|
"ProblemSection": {
|
||||||
|
"title": "Das Problem",
|
||||||
|
"subtitle": "Müde von Haar- & Hautprodukten, die nicht liefern?",
|
||||||
|
"description": "Sie verdienen mehr als Produkte voller aggressiver Chemikalien und leerer Versprechen",
|
||||||
|
"problems": [
|
||||||
|
{
|
||||||
|
"problem": "Trockenes, beschädigtes Haar",
|
||||||
|
"description": "Produkte hinterlassen Ihr Haar brüchig, frizzig und brechend trotz teurer Behandlungen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Verwirrende Inhaltsstoffe",
|
||||||
|
"description": "Sie können nicht aussprechen, was in Ihrer Hautpflege ist. Parabene, Sulfate, synthetische Duftstoffe - gefährliche Toxine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Keine echten Ergebnisse",
|
||||||
|
"description": "Unzählige Produkte versprechen Wunder, aber liefern nur leere Versprechen und verschwendetes Geld"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AsSeenIn": {
|
||||||
|
"title": "Wie gesehen in"
|
||||||
|
},
|
||||||
|
"BeforeAfterGallery": {
|
||||||
|
"realResults": "Echte Ergebnisse",
|
||||||
|
"seeTransformation": "Sehen Sie die Transformation",
|
||||||
|
"startTransformation": "Starten Sie Ihre Transformation",
|
||||||
|
"before": "VORHER",
|
||||||
|
"after": "NACHHER",
|
||||||
|
"verified": "Verifiziert",
|
||||||
|
"timeline": "Nach {weeks}"
|
||||||
|
},
|
||||||
|
"HowItWorks": {
|
||||||
|
"title": "Einfacher Prozess",
|
||||||
|
"subtitle": "Wie ManoonOils funktioniert",
|
||||||
|
"startTransformation": "Starten Sie Ihre Transformation",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"title": "Wählen Sie Ihr Öl",
|
||||||
|
"description": "Wählen Sie aus unserer Kollektion von reinen, kaltgepressten Ölen, die für Ihre spezifischen Haar- und Hautbedürfnisse formuliert sind."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Täglich anwenden",
|
||||||
|
"description": "Massieren Sie einige Tropfen in feuchtes Haar oder Haut. Unsere Öle ziehen sofort ein - nie fettig, immer pflegend."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ergebnisse sehen",
|
||||||
|
"description": "Erleben Sie Transformation in 4-6 Wochen. Glänzenderes Haar, strahlende Haut und Selbstvertrauen, das strahlt."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Header": {
|
||||||
|
"products": "Produkte",
|
||||||
|
"about": "Über uns",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"cart": "Warenkorb",
|
||||||
|
"account": "Konto",
|
||||||
|
"openMenu": "Menü öffnen",
|
||||||
|
"closeMenu": "Menü schließen",
|
||||||
|
"openCart": "Warenkorb öffnen"
|
||||||
|
},
|
||||||
|
"ProductCard": {
|
||||||
|
"noImage": "Kein Bild",
|
||||||
|
"outOfStock": "Nicht auf Lager",
|
||||||
|
"quickAdd": "Schnell hinzufügen",
|
||||||
|
"contactForPrice": "Preis anfragen"
|
||||||
|
},
|
||||||
|
"ProductDetail": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"outOfStock": "Nicht auf Lager",
|
||||||
|
"size": "Größe",
|
||||||
|
"qty": "Menge",
|
||||||
|
"adding": "Wird hinzugefügt...",
|
||||||
|
"transformHairSkin": "Mein Haar & Haut transformieren",
|
||||||
|
"freeShipping": "Kostenloser Versand bei Bestellungen über 3.000 RSD",
|
||||||
|
"guarantee": "30-Tage-Garantie",
|
||||||
|
"secureCheckout": "Sicheres Bezahlen",
|
||||||
|
"easyReturns": "Einfache Rückgabe",
|
||||||
|
"benefits": "Vorteile",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"howToUse": "Anwendung",
|
||||||
|
"howToUseText": "Eine kleine Menge auf saubere, feuchte Haut oder Haare auftragen. Sanft einmassieren, bis es eingezogen ist. Täglich für beste Ergebnisse verwenden.",
|
||||||
|
"ingredients": "Inhaltsstoffe",
|
||||||
|
"ingredientsText": "100% Reines Natürliches Öl. Keine Zusatzstoffe, Konservierungsstoffe oder künstliche Duftstoffe.",
|
||||||
|
"youMayAlsoLike": "Das könnte Ihnen auch gefallen",
|
||||||
|
"similarProducts": "Ähnliche Produkte",
|
||||||
|
"stocksRunningOut": "Vorräte gehen zur Neige!",
|
||||||
|
"urgency1": "Beeilen Sie sich! 500+ Artikel in den letzten 3 Tagen verkauft!",
|
||||||
|
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
|
||||||
|
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
|
||||||
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Paket wählen",
|
||||||
|
"singleUnit": "1 Stück",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Spare {amount}",
|
||||||
|
"perUnit": "pro Stück"
|
||||||
|
},
|
||||||
|
"Newsletter": {
|
||||||
|
"stayConnected": "Bleiben Sie verbunden",
|
||||||
|
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
||||||
|
"newsletterText": "Abonnieren Sie, um exklusive Angebote, Schönheitstipps zu erhalten und als Erster über neue Produkte informiert zu werden.",
|
||||||
|
"emailPlaceholder": "Geben Sie Ihre E-Mail ein",
|
||||||
|
"subscribe": "Abonnieren"
|
||||||
|
},
|
||||||
|
"ProductBenefits": {
|
||||||
|
"whyChoose": "Warum dieses Produkt wählen",
|
||||||
|
"manoonDifference": "Der Manoon Unterschied",
|
||||||
|
"pureNatural": "Rein & Natürlich",
|
||||||
|
"pureNaturalDesc": "100% natürliche Inhaltsstoffe ohne Zusatzstoffe oder Konservierungsstoffe",
|
||||||
|
"crueltyFree": "Tierversuchsfrei",
|
||||||
|
"crueltyFreeDesc": "Nie an Tieren getestet, ethisch beschaffte Inhaltsstoffe",
|
||||||
|
"madeWithLove": "Mit Liebe hergestellt",
|
||||||
|
"madeWithLoveDesc": "In kleinen Chargen handgefertigt für maximale Qualität",
|
||||||
|
"visibleResults": "Sichtbare Ergebnisse",
|
||||||
|
"visibleResultsDesc": "Erkennbare Verbesserungen in 4-6 Wochen"
|
||||||
|
},
|
||||||
|
"Checkout": {
|
||||||
|
"checkout": "Kasse",
|
||||||
|
"shippingAddress": "Lieferadresse",
|
||||||
|
"firstName": "Vorname",
|
||||||
|
"lastName": "Nachname",
|
||||||
|
"streetAddress": "Straße und Nummer",
|
||||||
|
"streetAddressOptional": "Wohnung, Suite, etc. (optional)",
|
||||||
|
"city": "Stadt",
|
||||||
|
"postalCode": "Postleitzahl",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"billingAddressSame": "Rechnungsadresse gleich Lieferadresse",
|
||||||
|
"billingAddress": "Rechnungsadresse",
|
||||||
|
"paymentMethod": "Zahlungsmethode",
|
||||||
|
"cashOnDelivery": "Nachnahme (COD)",
|
||||||
|
"cashOnDeliveryDesc": "Bezahlen Sie, wenn Ihre Bestellung an Ihre Tür geliefert wird.",
|
||||||
|
"processing": "Wird bearbeitet...",
|
||||||
|
"completeOrder": "Bestellung abschließen - {total}",
|
||||||
|
"orderSummary": "Bestellübersicht",
|
||||||
|
"qty": "Menge",
|
||||||
|
"subtotal": "Zwischensumme",
|
||||||
|
"shipping": "Versand",
|
||||||
|
"calculated": "Berechnet",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
||||||
|
"continueShopping": "Weiter einkaufen",
|
||||||
|
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||||
|
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
||||||
|
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||||
|
"orderConfirmed": "Bestellung bestätigt!",
|
||||||
|
"thankYou": "Vielen Dank für Ihren Einkauf.",
|
||||||
|
"orderNumber": "Bestellnummer",
|
||||||
|
"confirmationEmail": "Sie erhalten in Kürze eine Bestätigungs-E-Mail. Wir werden Sie kontaktieren, um Nachnahme zu arrangieren.",
|
||||||
|
"continueShoppingBtn": "Weiter einkaufen"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,26 +8,52 @@
|
|||||||
"Home": {
|
"Home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "Premium Natural Oils",
|
"title": "Premium Natural Oils",
|
||||||
"subtitle": "For hair and skin care"
|
"subtitle": "For hair and skin care",
|
||||||
|
"lovedBy": "Loved by 50,000+ customers worldwide",
|
||||||
|
"transformHeadline": "Transform Your Hair & Skin",
|
||||||
|
"withNaturalOils": "with 100% Natural Oils",
|
||||||
|
"subtitleText": "Cold-pressed, organic oils handcrafted with love. No additives, no preservatives—just nature's purest goodness for your daily beauty ritual.",
|
||||||
|
"ctaButton": "Transform My Hair & Skin",
|
||||||
|
"learnStory": "Learn Our Story",
|
||||||
|
"moneyBack": "30-Day Money Back",
|
||||||
|
"freeShipping": "Free Shipping Over 3,000 RSD",
|
||||||
|
"crueltyFree": "Cruelty Free"
|
||||||
},
|
},
|
||||||
"ticker": {
|
"collection": "Our Collection",
|
||||||
"text": "Free shipping on orders over 3000 RSD • Natural ingredients • Cruelty-free • Handmade with love"
|
"premiumOils": "Premium Natural Oils",
|
||||||
},
|
"oilsDescription": "Cold-pressed, pure, and natural oils for your daily beauty routine",
|
||||||
"products": {
|
"viewAll": "View All Products",
|
||||||
"title": "Our Products"
|
"ourStory": "Our Story",
|
||||||
},
|
"handmadeWithLove": "Handmade with Love",
|
||||||
"bestsellers": {
|
"storyText1": "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.",
|
||||||
"title": "Best Sellers"
|
"storyText2": "Our commitment to purity means no additives, no preservatives - just nature's goodness in its most potent form.",
|
||||||
},
|
"learnMore": "Learn More",
|
||||||
"story": {
|
"whyChooseUs": "Why Choose Us",
|
||||||
"title": "Our Story",
|
"manoonDifference": "The Manoon Difference",
|
||||||
"description": "ManoonOils was born from a passion for natural beauty..."
|
"stayConnected": "Stay Connected",
|
||||||
},
|
"joinCommunity": "Join Our Community",
|
||||||
"newsletter": {
|
"newsletterText": "Subscribe to receive exclusive offers, beauty tips, and be the first to know about new products.",
|
||||||
"title": "Stay Updated",
|
"emailPlaceholder": "Enter your email",
|
||||||
"placeholder": "Enter your email",
|
"subscribe": "Subscribe"
|
||||||
"button": "Subscribe"
|
},
|
||||||
}
|
"Benefits": {
|
||||||
|
"natural": "100% Natural",
|
||||||
|
"naturalDesc": "Pure, cold-pressed oils with no additives or preservatives. Just nature's goodness.",
|
||||||
|
"handcrafted": "Handcrafted",
|
||||||
|
"handcraftedDesc": "Each batch is carefully prepared by hand to ensure the highest quality.",
|
||||||
|
"sustainable": "Sustainable",
|
||||||
|
"sustainableDesc": "Ethically sourced ingredients and eco-friendly packaging for a better planet."
|
||||||
|
},
|
||||||
|
"Products": {
|
||||||
|
"collection": "Our Collection",
|
||||||
|
"allProducts": "All Products",
|
||||||
|
"productsCount": "{count} products",
|
||||||
|
"featured": "Featured",
|
||||||
|
"newest": "Newest",
|
||||||
|
"priceLow": "Price: Low to High",
|
||||||
|
"priceHigh": "Price: High to Low",
|
||||||
|
"noProducts": "No products available",
|
||||||
|
"checkBack": "Please check back later for new arrivals."
|
||||||
},
|
},
|
||||||
"Product": {
|
"Product": {
|
||||||
"addToCart": "Add to Cart",
|
"addToCart": "Add to Cart",
|
||||||
@@ -35,19 +61,361 @@
|
|||||||
"details": "Details",
|
"details": "Details",
|
||||||
"ingredients": "Ingredients",
|
"ingredients": "Ingredients",
|
||||||
"usage": "How to Use",
|
"usage": "How to Use",
|
||||||
"related": "You May Also Like"
|
"related": "You May Also Like",
|
||||||
|
"notFound": "Product not found",
|
||||||
|
"notFoundDesc": "The product you're looking for doesn't exist or has been removed.",
|
||||||
|
"browseProducts": "Browse Products"
|
||||||
},
|
},
|
||||||
"Cart": {
|
"Cart": {
|
||||||
"title": "Your Cart",
|
"title": "Your Cart",
|
||||||
"empty": "Your cart is empty",
|
"empty": "Your cart is empty",
|
||||||
|
"emptyDesc": "Looks like you haven't added anything to your cart yet.",
|
||||||
"continueShopping": "Continue Shopping",
|
"continueShopping": "Continue Shopping",
|
||||||
"checkout": "Checkout",
|
"checkout": "Checkout",
|
||||||
"subtotal": "Subtotal",
|
"subtotal": "Subtotal",
|
||||||
"remove": "Remove"
|
"shipping": "Shipping",
|
||||||
|
"shippingCalc": "Calculated at checkout",
|
||||||
|
"total": "Total",
|
||||||
|
"freeShipping": "Free shipping on orders over {amount}",
|
||||||
|
"remove": "Remove",
|
||||||
|
"processes": "Processing...",
|
||||||
|
"cartEmpty": "Your cart is empty"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "About Us",
|
||||||
|
"subtitle": "Our Story",
|
||||||
|
"intro": "ManoonOils was born from a passion for natural beauty and the belief that the best skincare comes from nature itself.",
|
||||||
|
"intro2": "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.",
|
||||||
|
"naturalIngredients": "Natural Ingredients",
|
||||||
|
"naturalIngredientsDesc": "We use only the finest natural ingredients, sourced ethically and sustainably from trusted suppliers around the world.",
|
||||||
|
"crueltyFree": "Cruelty-Free",
|
||||||
|
"crueltyFreeDesc": "Our products are never tested on animals. We believe in beauty without compromise.",
|
||||||
|
"sustainablePackaging": "Sustainable Packaging",
|
||||||
|
"sustainablePackagingDesc": "We use eco-friendly packaging materials and minimize waste throughout our production process.",
|
||||||
|
"handcraftedQuality": "Handcrafted Quality",
|
||||||
|
"handcraftedQualityDesc": "Every bottle is handcrafted in small batches to ensure the highest quality and freshness.",
|
||||||
|
"mission": "Our Mission",
|
||||||
|
"missionQuote": "\"To provide premium quality, natural products that enhance your daily beauty routine.\"",
|
||||||
|
"handmadeTitle": "Handmade with Love",
|
||||||
|
"handmadeText1": "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.",
|
||||||
|
"handmadeText2": "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."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"title": "Contact",
|
||||||
|
"subtitle": "Get in Touch",
|
||||||
|
"getInTouch": "Get in Touch",
|
||||||
|
"getInTouchDesc": "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.",
|
||||||
|
"email": "Email",
|
||||||
|
"emailReply": "We reply within 24 hours",
|
||||||
|
"shippingTitle": "Shipping",
|
||||||
|
"freeShipping": "Free shipping over £50",
|
||||||
|
"deliveryTime": "Delivered within 2-5 business days",
|
||||||
|
"location": "Location",
|
||||||
|
"locationDesc": "Serbia",
|
||||||
|
"worldwideShipping": "Shipping worldwide",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Your name",
|
||||||
|
"emailField": "Email",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
|
"message": "Message",
|
||||||
|
"messagePlaceholder": "How can we help you?",
|
||||||
|
"sendMessage": "Send Message",
|
||||||
|
"thankYou": "Thank You!",
|
||||||
|
"thankYouDesc": "Your message has been sent. We'll get back to you soon.",
|
||||||
|
"faqTitle": "Frequently Asked Questions",
|
||||||
|
"faq1q": "How long does shipping take?",
|
||||||
|
"faq1a": "Orders are typically delivered within 2-5 business days for domestic shipping. You'll receive a tracking number once your order ships.",
|
||||||
|
"faq2q": "Are your products 100% natural?",
|
||||||
|
"faq2a": "Yes! All our oils are 100% natural, cold-pressed, and free from any additives, preservatives, or artificial fragrances.",
|
||||||
|
"faq3q": "What is your return policy?",
|
||||||
|
"faq3a": "We accept returns within 14 days of delivery for unopened products. Please contact us if you have any issues with your order.",
|
||||||
|
"faq4q": "Do you offer wholesale?",
|
||||||
|
"faq4a": "Yes, we offer wholesale pricing for bulk orders. Please contact us at hello@manoonoils.com for more information."
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "An error occurred",
|
||||||
|
"tryAgain": "Try again",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"search": "Search",
|
||||||
|
"noResults": "No results found"
|
||||||
|
},
|
||||||
|
"Testimonials": {
|
||||||
|
"title": "What our customers say",
|
||||||
|
"verified": "Verified purchase",
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"name": "Sarah M.",
|
||||||
|
"skinType": "Dry, sensitive skin",
|
||||||
|
"text": "I've tried countless oils over the years, but ManoonOils is different. My skin has never felt this nourished and healthy. The argan oil is now a staple in my routine."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "James K.",
|
||||||
|
"skinType": "Hair care enthusiast",
|
||||||
|
"text": "Finally found an oil that actually tames my frizz without making my hair greasy. The jojoba oil works wonders for my beard too. Highly recommend!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Emma L.",
|
||||||
|
"skinType": "Combination skin",
|
||||||
|
"text": "Was skeptical at first but after 3 weeks of using the rosehip oil, my skin texture has improved dramatically. The quality is unmatched."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ProductReviews": {
|
||||||
|
"customerReviews": "Customer Reviews",
|
||||||
|
"whatCustomersSay": "What Customers Say",
|
||||||
|
"basedOnReviews": "Based on 1000+ reviews",
|
||||||
|
"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 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TrustBadges": {
|
||||||
|
"averageRating": "Average Rating",
|
||||||
|
"basedOnReviews": "Based on 1000+ reviews",
|
||||||
|
"happyCustomers": "Happy Customers",
|
||||||
|
"worldwide": "Worldwide",
|
||||||
|
"naturalIngredients": "Natural Ingredients",
|
||||||
|
"noAdditives": "No additives",
|
||||||
|
"freeShipping": "Free Shipping",
|
||||||
|
"ordersOver": "Orders over 3,000 RSD"
|
||||||
|
},
|
||||||
|
"ProblemSection": {
|
||||||
|
"title": "The Problem",
|
||||||
|
"subtitle": "Tired of Hair & Skin Products That Don't Deliver?",
|
||||||
|
"description": "You deserve better than products filled with harsh chemicals and empty promises",
|
||||||
|
"problems": [
|
||||||
|
{
|
||||||
|
"problem": "Dry, Damaged Hair",
|
||||||
|
"description": "Products leave your hair brittle, frizzy, and breaking despite expensive treatments"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Confusing Ingredients",
|
||||||
|
"description": "Can't pronounce what's in your skincare. parabens, sulfates, synthetic fragrances—dangerous toxins"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "No Real Results",
|
||||||
|
"description": "Countless products promise miracles but deliver nothing but empty promises and wasted money"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AsSeenIn": {
|
||||||
|
"title": "As Featured In"
|
||||||
|
},
|
||||||
|
"BeforeAfterGallery": {
|
||||||
|
"realResults": "Real Results",
|
||||||
|
"seeTransformation": "See the Transformation",
|
||||||
|
"startTransformation": "Start Your Transformation",
|
||||||
|
"before": "BEFORE",
|
||||||
|
"after": "AFTER",
|
||||||
|
"verified": "Verified",
|
||||||
|
"timeline": "After {weeks}"
|
||||||
|
},
|
||||||
|
"HowItWorks": {
|
||||||
|
"title": "Simple Process",
|
||||||
|
"subtitle": "How ManoonOils Works",
|
||||||
|
"startTransformation": "Start Your Transformation",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"title": "Choose Your Oil",
|
||||||
|
"description": "Select from our collection of pure, cold-pressed oils formulated for your specific hair and skin needs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Apply Daily",
|
||||||
|
"description": "Massage a few drops into damp hair or skin. Our oils absorb instantly—never greasy, always nourishing."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "See Results",
|
||||||
|
"description": "Experience transformation in 4-6 weeks. Shinier hair, radiant skin, and confidence that glows."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Header": {
|
||||||
|
"products": "Products",
|
||||||
|
"about": "About",
|
||||||
|
"contact": "Contact",
|
||||||
|
"cart": "Cart",
|
||||||
|
"account": "Account",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"openCart": "Open cart"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"quickLinks": "Quick Links",
|
"shop": "Shop",
|
||||||
"customerService": "Customer Service",
|
"allProducts": "All Products",
|
||||||
"copyright": "All rights reserved."
|
"hairCare": "Hair Care",
|
||||||
|
"skinCare": "Skin Care",
|
||||||
|
"giftSets": "Gift Sets",
|
||||||
|
"about": "About",
|
||||||
|
"ourStory": "Our Story",
|
||||||
|
"process": "Process",
|
||||||
|
"sustainability": "Sustainability",
|
||||||
|
"help": "Help",
|
||||||
|
"faq": "FAQ",
|
||||||
|
"shipping": "Shipping",
|
||||||
|
"returns": "Returns",
|
||||||
|
"contactUs": "Contact Us",
|
||||||
|
"brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.",
|
||||||
|
"weAccept": "We accept:",
|
||||||
|
"allRights": "All rights reserved.",
|
||||||
|
"madeWith": "Made with"
|
||||||
|
},
|
||||||
|
"ProductCard": {
|
||||||
|
"noImage": "No image",
|
||||||
|
"outOfStock": "Out of Stock",
|
||||||
|
"quickAdd": "Quick Add",
|
||||||
|
"contactForPrice": "Contact for price"
|
||||||
|
},
|
||||||
|
"ProductDetail": {
|
||||||
|
"home": "Home",
|
||||||
|
"outOfStock": "Out of Stock",
|
||||||
|
"size": "Size",
|
||||||
|
"qty": "Qty",
|
||||||
|
"adding": "Adding...",
|
||||||
|
"transformHairSkin": "Transform My Hair & Skin",
|
||||||
|
"freeShipping": "Free shipping on orders over 3,000 RSD",
|
||||||
|
"guarantee": "30-Day Guarantee",
|
||||||
|
"secureCheckout": "Secure Checkout",
|
||||||
|
"easyReturns": "Easy Returns",
|
||||||
|
"benefits": "Benefits",
|
||||||
|
"description": "Description",
|
||||||
|
"howToUse": "How to Use",
|
||||||
|
"howToUseText": "Apply a small amount to clean, damp hair or skin. Massage gently until absorbed. Use daily for best results.",
|
||||||
|
"ingredients": "Ingredients",
|
||||||
|
"ingredientsText": "100% Pure Natural Oil. No additives, preservatives, or artificial fragrances.",
|
||||||
|
"youMayAlsoLike": "You May Also Like",
|
||||||
|
"similarProducts": "Similar Products",
|
||||||
|
"stocksRunningOut": "Stocks are running out!",
|
||||||
|
"urgency1": "Hurry up! 500+ items sold in the last 3 days!",
|
||||||
|
"urgency2": "In the carts of 2.5K people - buy before its gone!",
|
||||||
|
"urgency3": "7,562 people viewed this product in the last 24 hours!"
|
||||||
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Select Package",
|
||||||
|
"singleUnit": "1 Unit",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Save {amount}",
|
||||||
|
"perUnit": "per unit"
|
||||||
|
},
|
||||||
|
"Newsletter": {
|
||||||
|
"stayConnected": "Stay Connected",
|
||||||
|
"joinCommunity": "Join Our Community",
|
||||||
|
"newsletterText": "Subscribe to receive exclusive offers, beauty tips, and be the first to know about new products.",
|
||||||
|
"emailPlaceholder": "Enter your email",
|
||||||
|
"subscribe": "Subscribe"
|
||||||
|
},
|
||||||
|
"ProductBenefits": {
|
||||||
|
"whyChoose": "Why Choose This Product",
|
||||||
|
"manoonDifference": "The Manoon Difference",
|
||||||
|
"pureNatural": "Pure & Natural",
|
||||||
|
"pureNaturalDesc": "100% natural ingredients with no additives or preservatives",
|
||||||
|
"crueltyFree": "Cruelty Free",
|
||||||
|
"crueltyFreeDesc": "Never tested on animals, ethically sourced ingredients",
|
||||||
|
"madeWithLove": "Made with Love",
|
||||||
|
"madeWithLoveDesc": "Handcrafted in small batches for maximum quality",
|
||||||
|
"visibleResults": "Visible Results",
|
||||||
|
"visibleResultsDesc": "See noticeable improvements in 4-6 weeks"
|
||||||
|
},
|
||||||
|
"Cart": {
|
||||||
|
"yourCart": "Your Cart",
|
||||||
|
"closeCart": "Close cart",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"yourCartEmpty": "Your cart is empty",
|
||||||
|
"looksLikeEmpty": "Looks like you haven't added anything to your cart yet.",
|
||||||
|
"startShopping": "Start Shopping",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"shipping": "Shipping",
|
||||||
|
"calculatedAtCheckout": "Calculated at checkout",
|
||||||
|
"total": "Total",
|
||||||
|
"freeShippingOver": "Free shipping on orders over {amount}",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"checkout": "Checkout",
|
||||||
|
"continueShopping": "Continue Shopping",
|
||||||
|
"removeItem": "Remove item"
|
||||||
|
},
|
||||||
|
"Checkout": {
|
||||||
|
"checkout": "Checkout",
|
||||||
|
"shippingAddress": "Shipping Address",
|
||||||
|
"firstName": "First Name",
|
||||||
|
"lastName": "Last Name",
|
||||||
|
"streetAddress": "Street Address",
|
||||||
|
"streetAddressOptional": "Apartment, suite, etc. (optional)",
|
||||||
|
"city": "City",
|
||||||
|
"postalCode": "Postal Code",
|
||||||
|
"phone": "Phone",
|
||||||
|
"billingAddressSame": "Billing address same as shipping",
|
||||||
|
"billingAddress": "Billing Address",
|
||||||
|
"paymentMethod": "Payment Method",
|
||||||
|
"cashOnDelivery": "Cash on Delivery (COD)",
|
||||||
|
"cashOnDeliveryDesc": "Pay when your order is delivered to your door.",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"completeOrder": "Complete Order - {total}",
|
||||||
|
"orderSummary": "Order Summary",
|
||||||
|
"qty": "Qty",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"shipping": "Shipping",
|
||||||
|
"calculated": "Calculated",
|
||||||
|
"total": "Total",
|
||||||
|
"yourCartEmpty": "Your cart is empty",
|
||||||
|
"continueShopping": "Continue Shopping",
|
||||||
|
"errorNoCheckout": "No active checkout. Please try again.",
|
||||||
|
"errorOccurred": "An error occurred during checkout.",
|
||||||
|
"errorCreatingOrder": "Failed to create order.",
|
||||||
|
"orderConfirmed": "Order Confirmed!",
|
||||||
|
"thankYou": "Thank you for your purchase.",
|
||||||
|
"orderNumber": "Order Number",
|
||||||
|
"confirmationEmail": "You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.",
|
||||||
|
"continueShoppingBtn": "Continue Shopping"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
375
src/i18n/messages/fr.json
Normal file
375
src/i18n/messages/fr.json
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
{
|
||||||
|
"Navigation": {
|
||||||
|
"home": "Accueil",
|
||||||
|
"products": "Produits",
|
||||||
|
"about": "À propos",
|
||||||
|
"contact": "Contact"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Huiles Naturelles Premium",
|
||||||
|
"subtitle": "Pour les soins capillaires et cutanés",
|
||||||
|
"lovedBy": "Apprécié par 50 000+ clients dans le monde",
|
||||||
|
"transformHeadline": "Transformez Vos Cheveux & Peau",
|
||||||
|
"withNaturalOils": "avec des Huiles 100% Naturelles",
|
||||||
|
"subtitleText": "Huiles biologiques cold-pressed, artisanales avec amour. Sans additifs, sans conservateurs - juste la pureté de la nature pour votre rituel beauté quotidien.",
|
||||||
|
"ctaButton": "Transformer Mes Cheveux & Ma Peau",
|
||||||
|
"learnStory": "Découvrir Notre Histoire",
|
||||||
|
"moneyBack": "30 Jours Satisfait",
|
||||||
|
"freeShipping": "Livraison Gratuite +3.000 RSD",
|
||||||
|
"crueltyFree": "Cruelty Free"
|
||||||
|
},
|
||||||
|
"collection": "Notre Collection",
|
||||||
|
"premiumOils": "Huiles Naturelles Premium",
|
||||||
|
"oilsDescription": "Huiles cold-pressed, pures et naturelles pour votre routine beauté quotidienne",
|
||||||
|
"viewAll": "Voir Tous Les Produits",
|
||||||
|
"ourStory": "Notre Histoire",
|
||||||
|
"handmadeWithLove": "Fait Main avec Amour",
|
||||||
|
"storyText1": "Chaque flacon de ManoonOils est crafted avec soin en utilisant des méthodes traditionnelles transmises de génération en génération. Nous aprovisonnons uniquement les meilleurs ingrédients biologiques pour vous apporter des huiles qui nourrissent les cheveux et la peau.",
|
||||||
|
"storyText2": "Notre engagement envers la pureté signifie aucun additif, aucun conservateur - juste la bonté de la nature dans sa forme la plus potente.",
|
||||||
|
"learnMore": "En Savoir Plus",
|
||||||
|
"whyChooseUs": "Pourquoi Nous Choisir",
|
||||||
|
"manoonDifference": "La Différence Manoon",
|
||||||
|
"stayConnected": "Restez Connectés",
|
||||||
|
"joinCommunity": "Rejoignez Notre Communauté",
|
||||||
|
"newsletterText": "Abonnez-vous pour recevoir des offres exclusives, des conseils beauté et être les premiers informés des nouveaux produits.",
|
||||||
|
"emailPlaceholder": "Entrez votre email",
|
||||||
|
"subscribe": "S'abonner"
|
||||||
|
},
|
||||||
|
"Benefits": {
|
||||||
|
"natural": "100% Naturel",
|
||||||
|
"naturalDesc": "Huiles pures cold-pressed sans additifs ni conservateurs. Juste la bonté de la nature.",
|
||||||
|
"handcrafted": "Artisanal",
|
||||||
|
"handcraftedDesc": "Chaque lot est soigneusement préparé à la main pour assurer la plus haute qualité.",
|
||||||
|
"sustainable": "Durable",
|
||||||
|
"sustainableDesc": "Ingrédients sourcés éthiquement et emballage écologique pour une meilleure planète."
|
||||||
|
},
|
||||||
|
"Products": {
|
||||||
|
"collection": "Notre Collection",
|
||||||
|
"allProducts": "Tous Les Produits",
|
||||||
|
"productsCount": "{count} produits",
|
||||||
|
"featured": "En Vedette",
|
||||||
|
"newest": "Nouveautés",
|
||||||
|
"priceLow": "Prix: Croissant",
|
||||||
|
"priceHigh": "Prix: Décroissant",
|
||||||
|
"noProducts": "Aucun produit disponible",
|
||||||
|
"checkBack": "Veuillez vérifier plus tard pour les nouveaux arrivages."
|
||||||
|
},
|
||||||
|
"Product": {
|
||||||
|
"addToCart": "Ajouter au Panier",
|
||||||
|
"outOfStock": "Rupture de Stock",
|
||||||
|
"details": "Détails",
|
||||||
|
"ingredients": "Ingrédients",
|
||||||
|
"usage": "Utilisation",
|
||||||
|
"related": "Vous Aimerez Aussi",
|
||||||
|
"notFound": "Produit non trouvé",
|
||||||
|
"notFoundDesc": "Le produit que vous recherchez n'existe pas ou a été supprimé.",
|
||||||
|
"browseProducts": "Parcourir les Produits"
|
||||||
|
},
|
||||||
|
"Cart": {
|
||||||
|
"title": "Votre Panier",
|
||||||
|
"yourCart": "Votre Panier",
|
||||||
|
"closeCart": "Fermer le panier",
|
||||||
|
"empty": "Votre panier est vide",
|
||||||
|
"emptyDesc": "Il semble que vous n'ayez pas encore ajouté d'articles à votre panier.",
|
||||||
|
"continueShopping": "Continuer les Achats",
|
||||||
|
"startShopping": "Commencer vos Achats",
|
||||||
|
"checkout": "Commander",
|
||||||
|
"subtotal": "Sous-total",
|
||||||
|
"shipping": "Livraison",
|
||||||
|
"shippingCalc": "Calculé à la caisse",
|
||||||
|
"calculatedAtCheckout": "Calculé à la caisse",
|
||||||
|
"dismiss": "Fermer",
|
||||||
|
"yourCartEmpty": "Votre panier est vide",
|
||||||
|
"looksLikeEmpty": "On dirait que vous n'avez rien ajouté",
|
||||||
|
"total": "Total",
|
||||||
|
"freeShipping": "Livraison gratuite sur les commandes de {amount}",
|
||||||
|
"remove": "Supprimer",
|
||||||
|
"removeItem": "Supprimer l'article",
|
||||||
|
"processes": "En cours...",
|
||||||
|
"cartEmpty": "Votre panier est vide",
|
||||||
|
"qty": "Qté"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "À Propos",
|
||||||
|
"subtitle": "Notre Histoire",
|
||||||
|
"intro": "ManoonOils est né d'une passion pour la beauté naturelle et de la conviction que les meilleurs soins cutanés viennent de la nature elle-même.",
|
||||||
|
"intro2": "Nous croyons au pouvoir des ingrédients naturels. Chaque huile de notre collection est soigneusement sélectionnée pour ses propriétés et bienfaits uniques. Des huiles nourrissantes qui restaurent la vitalité des cheveux aux sérums qui rajeunissent la peau, nous élaborons chaque produit avec amour et attention aux détails.",
|
||||||
|
"naturalIngredients": "Ingrédients Naturels",
|
||||||
|
"naturalIngredientsDesc": "Nous utilisons uniquement les meilleurs ingrédients naturels, sourcés de manière éthique et durable auprès de fournisseurs de confiance dans le monde entier.",
|
||||||
|
"crueltyFree": "Cruelty Free",
|
||||||
|
"crueltyFreeDesc": "Nos produits ne sont jamais testés sur les animaux. Nous croyons en la beauté sans compromis.",
|
||||||
|
"sustainablePackaging": "Emballage Durable",
|
||||||
|
"sustainablePackagingDesc": "Nous utilisons des matériaux d'emballage écologiques et minimisons les déchets tout au long de notre processus de production.",
|
||||||
|
"handcraftedQuality": "Qualité Artisanale",
|
||||||
|
"handcraftedQualityDesc": "Chaque flacon est fabriqué à la main en petites séries pour garantir la plus haute qualité et fraîcheur.",
|
||||||
|
"mission": "Notre Mission",
|
||||||
|
"missionQuote": "\"Fournir des produits premium de qualité, naturels qui améliorent votre routine beauté quotidienne.\"",
|
||||||
|
"handmadeTitle": "Fait Main avec Amour",
|
||||||
|
"handmadeText1": "Chaque flacon de ManoonOils est fabriqué à la main avec soin. Nous produisons nos produits en petites séries pour garantir la plus haute qualité et fraîcheur. Lorsque vous utilisez ManoonOils, vous pouvez être assuré d'utiliser quelque chose fabriqué avec un véritable souci et une expertise.",
|
||||||
|
"handmadeText2": "Notre voyage a commencé par une question simple: comment pouvons-nous créer des produits qui truly nourrissent à la fois les cheveux et la peau? Aujourd'hui, nous continuons à innover tout en restant fidèles à notre engagement envers des solutions beauté naturelles et efficaces."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"title": "Contact",
|
||||||
|
"subtitle": "Contactez-nous",
|
||||||
|
"getInTouch": "Contactez-nous",
|
||||||
|
"getInTouchDesc": "Nous sommes là pour aider! Que vous ayez des questions sur nos produits, besoin d'aide avec une commande, ou simplement souhaitiez dire bonjour, nous aimerions avoir de vos nouvelles.",
|
||||||
|
"email": "Email",
|
||||||
|
"emailReply": "Nous répondons dans les 24 heures",
|
||||||
|
"shippingTitle": "Livraison",
|
||||||
|
"freeShipping": "Livraison gratuite +3.000 RSD",
|
||||||
|
"deliveryTime": "Livré dans 2-5 jours ouvrables",
|
||||||
|
"location": "Localisation",
|
||||||
|
"locationDesc": "Serbie",
|
||||||
|
"worldwideShipping": "Livraison dans le monde entier",
|
||||||
|
"name": "Nom",
|
||||||
|
"namePlaceholder": "Votre nom",
|
||||||
|
"emailField": "Email",
|
||||||
|
"emailPlaceholder": "votre@email.com",
|
||||||
|
"message": "Message",
|
||||||
|
"messagePlaceholder": "Comment pouvons-nous vous aider?",
|
||||||
|
"sendMessage": "Envoyer le message",
|
||||||
|
"thankYou": "Merci!",
|
||||||
|
"thankYouDesc": "Votre message a été envoyé. Nous vous répondrons bientôt.",
|
||||||
|
"faqTitle": "Questions Fréquemment Posées",
|
||||||
|
"faq1q": "Combien de temps dure la livraison?",
|
||||||
|
"faq1a": "Les commandes sont généralement livrées sous 2-5 jours ouvrables pour la livraison nationale. Vous recevrez un numéro de suivi dès que votre commande sera expédiée.",
|
||||||
|
"faq2q": "Vos produits sont-ils 100% naturels?",
|
||||||
|
"faq2a": "Oui! Toutes nos huiles sont 100% naturelles, cold-pressed et exemptes de tout additif, conservateur ou parfum artificiel.",
|
||||||
|
"faq3q": "Quelle est votre politique de retour?",
|
||||||
|
"faq3a": "Nous acceptons les retours dans les 14 jours suivant la livraison pour les produits non ouverts. Veuillez nous contacter si vous avez des problèmes avec votre commande.",
|
||||||
|
"faq4q": "Offrez-vous des ventes en gros?",
|
||||||
|
"faq4a": "Oui, nous offrons des prix de gros pour les commandes en grande quantité. Veuillez nous contacter à hello@manoonoils.com pour plus d'informations."
|
||||||
|
},
|
||||||
|
"Footer": {
|
||||||
|
"quickLinks": "Liens Rapides",
|
||||||
|
"customerService": "Service Client",
|
||||||
|
"contact": "Contact",
|
||||||
|
"shipping": "Livraison",
|
||||||
|
"returns": "Retours",
|
||||||
|
"faq": "FAQ",
|
||||||
|
"followUs": "Suivez-nous",
|
||||||
|
"newsletter": "Newsletter",
|
||||||
|
"newsletterDesc": "Abonnez-vous à notre newsletter pour des offres exclusives et des mises à jour.",
|
||||||
|
"copyright": "Tous droits réservés.",
|
||||||
|
"allRights": "Tous droits réservés.",
|
||||||
|
"shop": "Boutique",
|
||||||
|
"allProducts": "Tous Les Produits",
|
||||||
|
"hairCare": "Soins Capillaires",
|
||||||
|
"skinCare": "Soins Cutanés",
|
||||||
|
"giftSets": "Coffrets Cadeaux",
|
||||||
|
"about": "À Propos",
|
||||||
|
"ourStory": "Notre Histoire",
|
||||||
|
"process": "Processus",
|
||||||
|
"sustainability": "Durabilité",
|
||||||
|
"help": "Aide",
|
||||||
|
"contactUs": "Contactez-nous",
|
||||||
|
"brandDescription": "Huiles naturelles premium pour les soins capillaires et cutanés. Fait main avec amour en utilisant des méthodes traditionnelles.",
|
||||||
|
"weAccept": "Nous acceptons:",
|
||||||
|
"madeWith": "Fait avec"
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"error": "Une erreur est survenue",
|
||||||
|
"tryAgain": "Réessayer",
|
||||||
|
"close": "Fermer",
|
||||||
|
"back": "Retour",
|
||||||
|
"next": "Suivant",
|
||||||
|
"previous": "Précédent",
|
||||||
|
"search": "Rechercher",
|
||||||
|
"noResults": "Aucun résultat trouvé"
|
||||||
|
},
|
||||||
|
"Testimonials": {
|
||||||
|
"title": "Ce que disent nos clients",
|
||||||
|
"verified": "Achat vérifié",
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"name": "Sarah M.",
|
||||||
|
"skinType": "Peau sèche et sensible",
|
||||||
|
"text": "J'ai essayé d'innombrables huiles au fil des ans, mais ManoonOils est différent. Ma peau n'a jamais été aussi nourrie et en bonne santé. L'huile d'argan est maintenant un élément essentiels de ma routine."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "James K.",
|
||||||
|
"skinType": "Passionné de soins capillaires",
|
||||||
|
"text": "Enfin trouvé une huile qui rassemble vraiment mes frisottis sans rendre mes cheveux gras. L'huile de jojoba fait des merveilles pour ma barbe aussi. Je recommande vivement!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Emma L.",
|
||||||
|
"skinType": "Peau mixte",
|
||||||
|
"text": "J'étais sceptique au début mais après 3 semaines d'utilisation de l'huile de rose musquée, la texture de ma peau s'est améliorée dramatiquement. La qualité est incomparable."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ProductReviews": {
|
||||||
|
"customerReviews": "Avis Clients",
|
||||||
|
"whatCustomersSay": "Ce Que Disent Les Clients",
|
||||||
|
"basedOnReviews": "Basé sur 1000+ avis",
|
||||||
|
"reviews": [
|
||||||
|
{ "id": 1, "name": "Ana M.", "location": "Belgrade", "text": "Le Sérum Anti-âge Manoon a transformé ma peau en seulement 2 semaines!", "rating": 5 },
|
||||||
|
{ "id": 2, "name": "Milica P.", "location": "Novi Sad", "text": "Le meilleur sérum de jour que j'aie jamais utilisé. Mes rides sont visiblement réduites.", "rating": 5 },
|
||||||
|
{ "id": 3, "name": "Jelena K.", "location": "Belgrade", "text": "Le sérum de nuit Manoon est de la magie pure. Réveillez-vous avec une peau qui rayonne chaque matin.", "rating": 5 },
|
||||||
|
{ "id": 4, "name": "Stefan R.", "location": "Subotica", "text": "Le Set Anti-âge vaut chaque dinar. Ma femme et moi l'utilisons tous les deux.", "rating": 5 },
|
||||||
|
{ "id": 5, "name": "Marija T.", "location": "Kragujevac", "text": "J'ai enfin trouvé un sérum qui fonctionne vraiment! Manoon tient ses promesses.", "rating": 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TrustBadges": {
|
||||||
|
"averageRating": "Note Moyenne",
|
||||||
|
"basedOnReviews": "Basé sur 1000+ avis",
|
||||||
|
"happyCustomers": "Clients Satisfaits",
|
||||||
|
"worldwide": "Dans le Monde Entier",
|
||||||
|
"naturalIngredients": "Ingrédients Naturels",
|
||||||
|
"noAdditives": "Sans Additifs",
|
||||||
|
"freeShipping": "Livraison Gratuite",
|
||||||
|
"ordersOver": "Commandes +3.000 RSD"
|
||||||
|
},
|
||||||
|
"ProblemSection": {
|
||||||
|
"title": "Le Problème",
|
||||||
|
"subtitle": "Fatigué des Produits Capillaires & Cutanés Qui Ne Delivrent Pas?",
|
||||||
|
"description": "Vous méritez mieux que des produits remplis de produits chimiques agressifs et de promesses vides",
|
||||||
|
"problems": [
|
||||||
|
{
|
||||||
|
"problem": "Cheveux Secs et Endommagés",
|
||||||
|
"description": "Les produits laissent vos cheveux cassants, crépus et se cassent malgré des traitements coûteux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Ingrédients Déroutants",
|
||||||
|
"description": "Vous ne pouvez pas prononcer ce qu'il y a dans vos soins cutanés. Parabènes, sulfates, parfums synthétiques - toxines dangereuses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Aucun Vrai Résultat",
|
||||||
|
"description": "D'innombrables produits promettent des miracles mais ne livrent que des promesses vides et de l'argent gaspillé"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AsSeenIn": {
|
||||||
|
"title": "Comme Vus Dans"
|
||||||
|
},
|
||||||
|
"BeforeAfterGallery": {
|
||||||
|
"realResults": "Résultats Réels",
|
||||||
|
"seeTransformation": "Voir la Transformation",
|
||||||
|
"startTransformation": "Commencez Votre Transformation",
|
||||||
|
"before": "AVANT",
|
||||||
|
"after": "APRÈS",
|
||||||
|
"verified": "Vérifié",
|
||||||
|
"timeline": "Après {weeks}"
|
||||||
|
},
|
||||||
|
"HowItWorks": {
|
||||||
|
"title": "Processus Simple",
|
||||||
|
"subtitle": "Comment ManoonOils Fonctionne",
|
||||||
|
"startTransformation": "Commencez Votre Transformation",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"title": "Choisissez Votre Huile",
|
||||||
|
"description": "Sélectionnez parmi notre collection d'huiles pures cold-pressed formulées pour vos besoins spécifiques en cheveux et peau."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Appliquez Quotidiennement",
|
||||||
|
"description": "Massez quelques gouttes dans des cheveux ou une peau humides. Nos huiles s'absorbent instantanément - jamais grasses, toujours nourrissantes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Voyez les Résultats",
|
||||||
|
"description": "Vivez la transformation en 4-6 semaines. Cheveux plus brillants, peau radieuse et confiance qui rayonne."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Header": {
|
||||||
|
"products": "Produits",
|
||||||
|
"about": "À Propos",
|
||||||
|
"contact": "Contact",
|
||||||
|
"cart": "Panier",
|
||||||
|
"account": "Compte",
|
||||||
|
"openMenu": "Ouvrir le menu",
|
||||||
|
"closeMenu": "Fermer le menu",
|
||||||
|
"openCart": "Ouvrir le panier"
|
||||||
|
},
|
||||||
|
"ProductCard": {
|
||||||
|
"noImage": "Pas d'image",
|
||||||
|
"outOfStock": "Rupture de Stock",
|
||||||
|
"quickAdd": "Ajout Rapide",
|
||||||
|
"contactForPrice": "Contacter pour le prix"
|
||||||
|
},
|
||||||
|
"ProductDetail": {
|
||||||
|
"home": "Accueil",
|
||||||
|
"outOfStock": "Rupture de Stock",
|
||||||
|
"size": "Taille",
|
||||||
|
"qty": "Qté",
|
||||||
|
"adding": "Ajout en cours...",
|
||||||
|
"transformHairSkin": "Transformer Mes Cheveux & Ma Peau",
|
||||||
|
"freeShipping": "Livraison gratuite sur les commandes de +3.000 RSD",
|
||||||
|
"guarantee": "Garantie 30 Jours",
|
||||||
|
"secureCheckout": "Paiement Sécurisé",
|
||||||
|
"easyReturns": "Retours Faciles",
|
||||||
|
"benefits": "Bienfaits",
|
||||||
|
"description": "Description",
|
||||||
|
"howToUse": "Comment Utiliser",
|
||||||
|
"howToUseText": "Appliquez une petite quantité sur des cheveux ou une peau propres et humides. Massez doucement jusqu'à absorption. Utilisez quotidiennement pour de meilleurs résultats.",
|
||||||
|
"ingredients": "Ingrédients",
|
||||||
|
"ingredientsText": "100% Huile Naturelle Pure. Aucun additif, conservateur ou parfum artificiel.",
|
||||||
|
"youMayAlsoLike": "Vous Aimerez Aussi",
|
||||||
|
"similarProducts": "Produits Similaires",
|
||||||
|
"stocksRunningOut": "Les stocks s'épuisent!",
|
||||||
|
"urgency1": "Dépêchez-vous! 500+ articles vendus ces 3 derniers jours!",
|
||||||
|
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
|
||||||
|
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
|
||||||
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Choisir le Pack",
|
||||||
|
"singleUnit": "1 Unité",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Économisez {amount}",
|
||||||
|
"perUnit": "par unité"
|
||||||
|
},
|
||||||
|
"Newsletter": {
|
||||||
|
"stayConnected": "Restez Connectés",
|
||||||
|
"joinCommunity": "Rejoignez Notre Communauté",
|
||||||
|
"newsletterText": "Abonnez-vous pour recevoir des offres exclusives, des conseils beauté et être les premiers informés des nouveaux produits.",
|
||||||
|
"emailPlaceholder": "Entrez votre email",
|
||||||
|
"subscribe": "S'abonner"
|
||||||
|
},
|
||||||
|
"ProductBenefits": {
|
||||||
|
"whyChoose": "Pourquoi Choisir Ce Produit",
|
||||||
|
"manoonDifference": "La Différence Manoon",
|
||||||
|
"pureNatural": "Pur & Naturel",
|
||||||
|
"pureNaturalDesc": "Ingrédients 100% naturels sans additifs ni conservateurs",
|
||||||
|
"crueltyFree": "Cruelty Free",
|
||||||
|
"crueltyFreeDesc": "Jamais testé sur les animaux, ingrédients sourcés éthiquement",
|
||||||
|
"madeWithLove": "Fait avec Amour",
|
||||||
|
"madeWithLoveDesc": "Fabriqué à la main en petites séries pour une qualité maximale",
|
||||||
|
"visibleResults": "Résultats Visibles",
|
||||||
|
"visibleResultsDesc": "Des améliorations perceptibles en 4-6 semaines"
|
||||||
|
},
|
||||||
|
"Checkout": {
|
||||||
|
"checkout": "Commande",
|
||||||
|
"shippingAddress": "Adresse de Livraison",
|
||||||
|
"firstName": "Prénom",
|
||||||
|
"lastName": "Nom",
|
||||||
|
"streetAddress": "Rue et Numéro",
|
||||||
|
"streetAddressOptional": "Appartement, suite, etc. (optionnel)",
|
||||||
|
"city": "Ville",
|
||||||
|
"postalCode": "Code Postal",
|
||||||
|
"phone": "Téléphone",
|
||||||
|
"billingAddressSame": "L'adresse de facturation est la même que l'adresse de livraison",
|
||||||
|
"billingAddress": "Adresse de Facturation",
|
||||||
|
"paymentMethod": "Mode de Paiement",
|
||||||
|
"cashOnDelivery": "Contre-remboursement (COD)",
|
||||||
|
"cashOnDeliveryDesc": "Payez lorsque votre commande est livrée à votre porte.",
|
||||||
|
"processing": "En cours...",
|
||||||
|
"completeOrder": "Finaliser la Commande - {total}",
|
||||||
|
"orderSummary": "Résumé de la Commande",
|
||||||
|
"qty": "Qté",
|
||||||
|
"subtotal": "Sous-total",
|
||||||
|
"shipping": "Livraison",
|
||||||
|
"calculated": "Calculé",
|
||||||
|
"total": "Total",
|
||||||
|
"yourCartEmpty": "Votre panier est vide",
|
||||||
|
"continueShopping": "Continuer les Achats",
|
||||||
|
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||||
|
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
||||||
|
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||||
|
"orderConfirmed": "Commande Confirmée!",
|
||||||
|
"thankYou": "Merci pour votre achat.",
|
||||||
|
"orderNumber": "Numéro de Commande",
|
||||||
|
"confirmationEmail": "Vous recevrez bientôt un email de confirmation. Nous vous contacterons pour organiser le paiement contre-remboursement.",
|
||||||
|
"continueShoppingBtn": "Continuer les Achats"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,26 +8,52 @@
|
|||||||
"Home": {
|
"Home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "Premium prirodna ulja",
|
"title": "Premium prirodna ulja",
|
||||||
"subtitle": "Za negu kose i kože"
|
"subtitle": "Za negu kose i kože",
|
||||||
|
"lovedBy": "Omiljeno od 50.000+ kupaca širom sveta",
|
||||||
|
"transformHeadline": "Transformiši kosu i kožu",
|
||||||
|
"withNaturalOils": "sa 100% prirodnim uljima",
|
||||||
|
"subtitleText": "Hladno ceđena, organska ulja ručno pravljena sa ljubavlju. Bez aditiva, bez konzervanasa - samo najčistija dobrobit prirode za vašu svakodnevnu lepotu.",
|
||||||
|
"ctaButton": "Transformiši moju kosu i kožu",
|
||||||
|
"learnStory": "Saznaj našu priču",
|
||||||
|
"moneyBack": "Povrat novca 30 dana",
|
||||||
|
"freeShipping": "Besplatna dostava preko 3.000 RSD",
|
||||||
|
"crueltyFree": "Bez okrutnosti"
|
||||||
},
|
},
|
||||||
"ticker": {
|
"collection": "Naša kolekcija",
|
||||||
"text": "Besplatna dostava za porudžbine preko 3000 RSD • Prirodni sastojci • Bez okrutnosti • Ručno sa ljubavlju"
|
"premiumOils": "Premium prirodna ulja",
|
||||||
},
|
"oilsDescription": "Hladno ceđena, čista i prirodna ulja za vašu svakodnevnuBeauty rutinu",
|
||||||
"products": {
|
"viewAll": "Pogledaj sve proizvode",
|
||||||
"title": "Naši proizvodi"
|
"ourStory": "Naša priča",
|
||||||
},
|
"handmadeWithLove": "Ručno sa ljubavlju",
|
||||||
"bestsellers": {
|
"storyText1": "Svaka boca ManoonOils je izrađena sa pažnjom koristeći tradicionalne metode koje se prenose kroz generacije. Nabavljamo samo najkvalitetnije organske sastojke da bismo vam doneli ulja koja neguju kosu i kožu.",
|
||||||
"title": "Najprodavaniji"
|
"storyText2": "Naša posvećenost čistoći znači bez aditiva, bez konzervanasa - samo dobrobit prirode u njenom najpotentnijem obliku.",
|
||||||
},
|
"learnMore": "Saznajte više",
|
||||||
"story": {
|
"whyChooseUs": "Zašto nas izabrati",
|
||||||
"title": "Naša priča",
|
"manoonDifference": "Manoon razlika",
|
||||||
"description": "ManoonOils je rođen iz strasti za prirodnu lepotu..."
|
"stayConnected": "Ostanite povezani",
|
||||||
},
|
"joinCommunity": "Pridružite se našoj zajednici",
|
||||||
"newsletter": {
|
"newsletterText": "Pretplatite se da biste primali ekskluzivne ponude, Beauty savete i budite prvi koji ćete saznati za nove proizvode.",
|
||||||
"title": "Ostanite u toku",
|
"emailPlaceholder": "Unesite vaš email",
|
||||||
"placeholder": "Unesite vaš email",
|
"subscribe": "Pretplatite se"
|
||||||
"button": "Pretplati se"
|
},
|
||||||
}
|
"Benefits": {
|
||||||
|
"natural": "100% Prirodno",
|
||||||
|
"naturalDesc": "Čista, hladno ceđena ulja bez aditiva ili konzervanasa. Samo dobrobit prirode.",
|
||||||
|
"handcrafted": "Ručno pravljeno",
|
||||||
|
"handcraftedDesc": "Svaka serija je pažljivo pripremljena ručno kako bi se osigurao najviši kvalitet.",
|
||||||
|
"sustainable": "Održivo",
|
||||||
|
"sustainableDesc": "Etički nabavljeni sastojci i ekološka ambalaža za bolju planetu."
|
||||||
|
},
|
||||||
|
"Products": {
|
||||||
|
"collection": "Naša kolekcija",
|
||||||
|
"allProducts": "Svi proizvodi",
|
||||||
|
"productsCount": "{count} proizvoda",
|
||||||
|
"featured": "Istaknuto",
|
||||||
|
"newest": "Najnovije",
|
||||||
|
"priceLow": "Cena: Rastuće",
|
||||||
|
"priceHigh": "Cena: Opadajuće",
|
||||||
|
"noProducts": "Nema dostupnih proizvoda",
|
||||||
|
"checkBack": "Molimo proverite ponovo kasnije za nove proizvode."
|
||||||
},
|
},
|
||||||
"Product": {
|
"Product": {
|
||||||
"addToCart": "Dodaj u korpu",
|
"addToCart": "Dodaj u korpu",
|
||||||
@@ -35,19 +61,361 @@
|
|||||||
"details": "Detalji",
|
"details": "Detalji",
|
||||||
"ingredients": "Sastojci",
|
"ingredients": "Sastojci",
|
||||||
"usage": "Način upotrebe",
|
"usage": "Način upotrebe",
|
||||||
"related": "Takođe će vam se svideti"
|
"related": "Takođe će vam se svideti",
|
||||||
|
"notFound": "Proizvod nije pronađen",
|
||||||
|
"notFoundDesc": "Proizvod koji tražite ne postoji ili je uklonjen.",
|
||||||
|
"browseProducts": "Pregledaj proizvode"
|
||||||
},
|
},
|
||||||
"Cart": {
|
"Cart": {
|
||||||
"title": "Vaša korpa",
|
"title": "Vaša korpa",
|
||||||
"empty": "Vaša korpa je prazna",
|
"empty": "Vaša korpa je prazna",
|
||||||
|
"emptyDesc": "Izgleda da još uvek niste dodali ništa u korpu.",
|
||||||
"continueShopping": "Nastavite kupovinu",
|
"continueShopping": "Nastavite kupovinu",
|
||||||
"checkout": "Kupovina",
|
"checkout": "Kupovina",
|
||||||
"subtotal": "Ukupno",
|
"subtotal": "Ukupno",
|
||||||
"remove": "Ukloni"
|
"shipping": "Dostava",
|
||||||
|
"shippingCalc": "Racunato pri kupovini",
|
||||||
|
"total": "Ukupno",
|
||||||
|
"freeShipping": "Besplatna dostava za porudžbine preko {amount}",
|
||||||
|
"remove": "Ukloni",
|
||||||
|
"processes": "Obrađuje se...",
|
||||||
|
"cartEmpty": "Vaša korpa je prazna"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "O nama",
|
||||||
|
"subtitle": "Naša priča",
|
||||||
|
"intro": "ManoonOils je rođen iz strasti za prirodnu lepotu i verovanja da najbolja nega kože dolazi od same prirode.",
|
||||||
|
"intro2": "Verujemo u moć prirodnih sastojaka. Svako ulje u našoj kolekciji je pažljivo odabrano zbog svojih jedinstvenih svojstava i prednosti. Od hranljivih ulja koja obnavljaju vitalnost kose, do seruma koji podmlađuju kožu, svaki proizvod pravimo sa ljubavlju i pažnjom prema detaljima.",
|
||||||
|
"naturalIngredients": "Prirodni sastojci",
|
||||||
|
"naturalIngredientsDesc": "Koristimo samo najfinije prirodne sastojke, etički i održivo nabavljene od pouzdanih dobavljača širom sveta.",
|
||||||
|
"crueltyFree": "Bez okrutnosti",
|
||||||
|
"crueltyFreeDesc": "Naši proizvodi se nikada ne testiraju na životinjama. Verujemo u lepotu bez kompromisa.",
|
||||||
|
"sustainablePackaging": "Održiva ambalaža",
|
||||||
|
"sustainablePackagingDesc": "Koristimo ekološke materijale za pakovanje i minimizujemo otpad tokom celokupnog proizvodnog procesa.",
|
||||||
|
"handcraftedQuality": "Ručna izrada",
|
||||||
|
"handcraftedQualityDesc": "Svaka boca je ručno napravljena u malim serijama kako bi se osigurao najviši kvalitet i svežina.",
|
||||||
|
"mission": "Naša misija",
|
||||||
|
"missionQuote": "\"Da pružimo proizvode premium kvaliteta, prirodne proizvode koji unapređuju vašu svakodnevnuBeauty rutinu.\"",
|
||||||
|
"handmadeTitle": "Ručno sa ljubavlju",
|
||||||
|
"handmadeText1": "Svaka boca ManoonOils je ručno napravljena sa brigom. Proizvodimo proizvode u malim serijama kako bi osigurali najviši kvalitet i svežinu. Kada koristite ManoonOils, možete biti sigurni da koristite nešto napravljeno sa iskrenom brigom i stručnošću.",
|
||||||
|
"handmadeText2": "Naše putovanje je počelo jednostavnim pitanjem: kako možemo stvoriti proizvode koji zaista neguju kosu i kožu? Danas nastavljamo da inoviramo dok ostajemo verni našoj posvećenosti prirodnim, efikasnim Beauty rešenjima."
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"title": "Kontakt",
|
||||||
|
"subtitle": "Stupite u kontakt",
|
||||||
|
"getInTouch": "Stupite u kontakt",
|
||||||
|
"getInTouchDesc": "Tu smo da pomognemo! Bilo da imate pitanja o našim proizvodima, trebate pomoć sa narudžbinom, ili jednostavno želite reći zdravo, voleli bismo da čujemo od vas.",
|
||||||
|
"email": "Email",
|
||||||
|
"emailReply": "Odgovaramo u roku od 24 sata",
|
||||||
|
"shippingTitle": "Dostava",
|
||||||
|
"freeShipping": "Besplatna dostava preko 3.000 RSD",
|
||||||
|
"deliveryTime": "Isporučeno u roku od 2-5 radnih dana",
|
||||||
|
"location": "Lokacija",
|
||||||
|
"locationDesc": "Srbija",
|
||||||
|
"worldwideShipping": "Isporuka širom sveta",
|
||||||
|
"name": "Ime",
|
||||||
|
"namePlaceholder": "Vaše ime",
|
||||||
|
"emailField": "Email",
|
||||||
|
"emailPlaceholder": "vas@email.com",
|
||||||
|
"message": "Poruka",
|
||||||
|
"messagePlaceholder": "Kako možemo da vam pomognemo?",
|
||||||
|
"sendMessage": "Pošalji poruku",
|
||||||
|
"thankYou": "Hvala vam!",
|
||||||
|
"thankYouDesc": "Vaša poruka je poslata. Javićemo vam se uskoro.",
|
||||||
|
"faqTitle": "Često postavljana pitanja",
|
||||||
|
"faq1q": "Koliko dugo traje dostava?",
|
||||||
|
"faq1a": "Narudžbe se obično isporučuju u roku od 2-5 radnih dana za domaću dostavu. Dobićete broj za praćenje čim vaša narudžbina bude poslata.",
|
||||||
|
"faq2q": "Da li su vaši proizvodi 100% prirodni?",
|
||||||
|
"faq2a": "Da! Sva naša ulja su 100% prirodna, hladno ceđena i bez ikakvih aditiva, konzervanasa ili veštačkih mirisa.",
|
||||||
|
"faq3q": "Koja je vaša politika povrata?",
|
||||||
|
"faq3a": "Prihvatamo povrate u roku od 14 dana od isporuke za neotvorene proizvode. Molimo kontaktirajte nas ako imate bilo kakvih problema sa narudžbinom.",
|
||||||
|
"faq4q": "Da li nudite veleprodaju?",
|
||||||
|
"faq4a": "Da, nudimo veleprodajne cene za narudžbine u velikim količinama. Molimo kontaktirajte nas na hello@manoonoils.com za više informacija."
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"loading": "Učitavanje...",
|
||||||
|
"error": "Došlo je do greške",
|
||||||
|
"tryAgain": "Pokušajte ponovo",
|
||||||
|
"close": "Zatvori",
|
||||||
|
"back": "Nazad",
|
||||||
|
"next": "Sledeće",
|
||||||
|
"previous": "Prethodno",
|
||||||
|
"search": "Pretraga",
|
||||||
|
"noResults": "Nema rezultata"
|
||||||
|
},
|
||||||
|
"Testimonials": {
|
||||||
|
"title": "Šta naši kupci kažu",
|
||||||
|
"verified": "Potvrđena kupovina",
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"name": "Milica J.",
|
||||||
|
"skinType": "Suva, osetljiva koža",
|
||||||
|
"text": "Isprobala sam bezbroj ulja tokom godina, ali ManoonOils je drugačije. Moja koža nikad nije bila ovako negovana i zdrava. Arganovo ulje je sada osnovni deo moje rutine."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Marko P.",
|
||||||
|
"skinType": "Nega kose",
|
||||||
|
"text": "Konačno sam pronašla ulje koje zaista obuzdava moju kosu bez que je čini masnom. Jojobino ulje čini čuda i za moju bradu. Toplo preporučujem!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ana K.",
|
||||||
|
"skinType": "Kombinovana koža",
|
||||||
|
"text": "U početku sam bila skeptična, ali nakon 3 nedelje korišćenja ulja od šipka, tekstura moje kože se drastično poboljšala. Kvalitet je neuporediv."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ProductReviews": {
|
||||||
|
"customerReviews": "Ocene kupaca",
|
||||||
|
"whatCustomersSay": "Šta kupci kažu",
|
||||||
|
"basedOnReviews": "Na osnovu 1000+ recenzija",
|
||||||
|
"reviews": [
|
||||||
|
{ "id": 1, "name": "Ana M.", "location": "Beograd", "text": "Manoon Anti-age Serum je transformisao moju kožu za samo 2 nedelje!", "rating": 5 },
|
||||||
|
{ "id": 2, "name": "Milica P.", "location": "Novi Sad", "text": "Najbolji dnevni serum koji sam ikada koristila. Moje bore su vidno smanjene.", "rating": 5 },
|
||||||
|
{ "id": 3, "name": "Jelena K.", "location": "Beograd", "text": "Manoon noćni serum je čista magija. Probudite se sa blistavom kožom svako jutro.", "rating": 5 },
|
||||||
|
{ "id": 4, "name": "Stefan R.", "location": "Subotica", "text": "Anti-age set vredi svaki dinar. Moja supruga i ja ga oboje koristimo.", "rating": 5 },
|
||||||
|
{ "id": 5, "name": "Marija T.", "location": "Kragujevac", "text": "Konačno sam pronašla serum koji zaista deluje! Manoon ispunjava obećanja.", "rating": 5 },
|
||||||
|
{ "id": 6, "name": "Nikola V.", "location": "Niš", "text": "Moje fine linije nestaju. Ovaj dnevni serum je neverovatan.", "rating": 5 },
|
||||||
|
{ "id": 7, "name": "Ivana L.", "location": "Beograd", "text": "Manoon jutarnji serum miriše božanstveno i još bolje deluje.", "rating": 5 },
|
||||||
|
{ "id": 8, "name": "Dejan M.", "location": "Novi Sad", "text": "Noćni serum je potpuno transformisao moju rutinu nege kože.", "rating": 5 },
|
||||||
|
{ "id": 9, "name": "Sanja B.", "location": "Kragujevac", "text": "Moja koža izgleda 10 godina mlađe nakon mesec dana korišćenja Manoon-a.", "rating": 5 },
|
||||||
|
{ "id": 10, "name": "Marko J.", "location": "Subotica", "text": "Anti-age set je savršen poklon. Moja majka ga obožava!", "rating": 5 },
|
||||||
|
{ "id": 11, "name": "Petra D.", "location": "Niš", "text": "Tekstura Manoon seruma je toliko luksuzna. Vredi svaki dinar.", "rating": 5 },
|
||||||
|
{ "id": 12, "name": "Luka G.", "location": "Beograd", "text": "Dnevni serum se upija momentalno. Nikakav masni osećaj!", "rating": 5 },
|
||||||
|
{ "id": 13, "name": "Maja S.", "location": "Novi Sad", "text": "Moj kozmetičar je pitao šta koristim. Manoon je sada moja tajna!", "rating": 5 },
|
||||||
|
{ "id": 14, "name": "Vladimir P.", "location": "Kragujevac", "text": "Noćni serum deluje dok spavam. Probudite se sa vidljivo glatkom kožom.", "rating": 5 },
|
||||||
|
{ "id": 15, "name": "Katarina N.", "location": "Subotica", "text": "Anti-age set je stigao lepo upakovan. Savršen za poklone.", "rating": 5 },
|
||||||
|
{ "id": 16, "name": "Bojan R.", "location": "Niš", "text": "Koristim Manoon 3 meseca. Moje bore su primetno smanjene.", "rating": 5 },
|
||||||
|
{ "id": 17, "name": "Tamara F.", "location": "Beograd", "text": "Dnevni serum pruža savršenu bazu ispod šminke.", "rating": 5 },
|
||||||
|
{ "id": 18, "name": "Aleksandar K.", "location": "Novi Sad", "text": "Konačno srpski brend koji se takmiči sa luksuznim međunarodnim brendovima!", "rating": 5 },
|
||||||
|
{ "id": 19, "name": "Natalia M.", "location": "Kragujevac", "text": "Moja osetljiva koža obožava Manoon. Bez ikakve iritacije.", "rating": 5 },
|
||||||
|
{ "id": 20, "name": "Filip T.", "location": "Subotica", "text": "Anti-age serum je lagan, a opet neverovatno efektan.", "rating": 5 },
|
||||||
|
{ "id": 21, "name": "Andrea L.", "location": "Niš", "text": "Manoon noćni serum je moja večernja rutina. Koža izgleda neverovatno!", "rating": 5 },
|
||||||
|
{ "id": 22, "name": "Ognjen P.", "location": "Beograd", "text": "Prijatelji neprestano pitaju šta sam promenio u rutini nege kože.", "rating": 5 },
|
||||||
|
{ "id": 23, "name": "Mila J.", "location": "Novi Sad", "text": "Anti-age set sadrži sve što vam treba. Odlična vrednost!", "rating": 5 },
|
||||||
|
{ "id": 24, "name": "Dragan S.", "location": "Kragujevac", "text": "Čak je i moj muž primetio razliku. Sada i on koristi dnevni serum!", "rating": 5 },
|
||||||
|
{ "id": 25, "name": "Jovana V.", "location": "Subotica", "text": "Jutarnji serum za sjaj daje najlepšu luminoznost.", "rating": 5 },
|
||||||
|
{ "id": 26, "name": "Stefan M.", "location": "Niš", "text": "Manoon proizvodi su sada neophodni u mojoj dnevnoj rutini.", "rating": 5 },
|
||||||
|
{ "id": 27, "name": "Ana R.", "location": "Beograd", "text": "Noćni serum je pomogao da se pročisti ten. Koža izgleda tako zdravo!", "rating": 5 },
|
||||||
|
{ "id": 28, "name": "Nenad L.", "location": "Novi Sad", "text": "Anti-aging rezultati vidljivi za par nedelja. Toplo preporučujem Manoon!", "rating": 5 },
|
||||||
|
{ "id": 29, "name": "Sofija D.", "location": "Kragujevac", "text": "Tekstura je božanstvena. Oseća se kao luksuzni spa tretman kod kuće.", "rating": 5 },
|
||||||
|
{ "id": 30, "name": "Velibor K.", "location": "Subotica", "text": "Moje bore oko očiju su se značajno smanjile. Hvala Manoon!", "rating": 5 },
|
||||||
|
{ "id": 31, "name": "Irena M.", "location": "Niš", "text": "Anti-age set je savršen poklon za rođendan moje majke.", "rating": 5 },
|
||||||
|
{ "id": 32, "name": "Radoslav P.", "location": "Beograd", "text": "Profesionalni kvalitet seruma po poštenoj ceni. Srpska izvrsnost!", "rating": 5 },
|
||||||
|
{ "id": 33, "name": "Jelena B.", "location": "Novi Sad", "text": "Moja koža nikad nije bila ovako hidrirana. Dnevni serum je neverovatan!", "rating": 5 },
|
||||||
|
{ "id": 34, "name": "Dimitrije S.", "location": "Kragujevac", "text": "Noćni serum vredi svog zlata. Čista luksuz!", "rating": 5 },
|
||||||
|
{ "id": 35, "name": "Minela G.", "location": "Subotica", "text": "Manoon ispunjava očekivanja. Moja koža izgleda osveženo i mlado.", "rating": 5 },
|
||||||
|
{ "id": 36, "name": "Zoran T.", "location": "Niš", "text": "Isprobao sam mnoge serume. Manoon je daleko najefektivniji.", "rating": 5 },
|
||||||
|
{ "id": 37, "name": "Mirjana F.", "location": "Beograd", "text": "Anti-age set je potpuno transformisao rutinu nege kože moje majke.", "rating": 5 },
|
||||||
|
{ "id": 38, "name": "Ivan J.", "location": "Novi Sad", "text": "Brzo delujući serum sa stvarnim rezultatima. Preporučujem Manoon svima.", "rating": 5 },
|
||||||
|
{ "id": 39, "name": "Kristina P.", "location": "Kragujevac", "text": "Jutarnji serum za sjaj daje tako lep deve sjaj.", "rating": 5 },
|
||||||
|
{ "id": 40, "name": "Bratislav L.", "location": "Subotica", "text": "Primetni rezultati za samo 2 nedelje. Ovaj serum je prava stvar!", "rating": 5 },
|
||||||
|
{ "id": 41, "name": "Zorica M.", "location": "Niš", "text": "Noćni serum je izbrisao godine sa mog lica. Apsolutno čudesno!", "rating": 5 },
|
||||||
|
{ "id": 42, "name": "Patrik N.", "location": "Beograd", "text": "Premium kvalitet srpske kozmetike koja se takmiči sa međunarodnim luksuznim brendovima.", "rating": 5 },
|
||||||
|
{ "id": 43, "name": "Simona K.", "location": "Novi Sad", "text": "Manoon Anti-age Serum je najbolja investicija u moju kožu ikada.", "rating": 5 },
|
||||||
|
{ "id": 44, "name": "Mladen D.", "location": "Kragujevac", "text": "Dnevni serum se upije za sekundu. Nema čekanja!", "rating": 5 },
|
||||||
|
{ "id": 45, "name": "Ljiljana R.", "location": "Subotica", "text": "Poklanjam Anti-age set sestrama. Obožale su ga!", "rating": 5 },
|
||||||
|
{ "id": 46, "name": "Tomislav V.", "location": "Niš", "text": "Moje bore su vidno smanjene nakon mesec dana korišćenja Manoon-a.", "rating": 5 },
|
||||||
|
{ "id": 47, "name": "Emilija S.", "location": "Beograd", "text": "Noćni serum ostavlja moju kožu tako mekom i obnovljenom svako jutro.", "rating": 5 },
|
||||||
|
{ "id": 48, "name": "Andrija P.", "location": "Novi Sad", "text": "Manoon dnevni serum je savršen ispod sunscreena. Neophodna kombinacija!", "rating": 5 },
|
||||||
|
{ "id": 49, "name": "Miona L.", "location": "Kragujevac", "text": "Moja koža izgleda zračno i mlado. Ne mogu biti srećnija sa Manoon-om!", "rating": 5 },
|
||||||
|
{ "id": 50, "name": "Slavko M.", "location": "Subotica", "text": "Anti-age set daje vidljive rezultate. Prava srpska kvaliteta!", "rating": 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TrustBadges": {
|
||||||
|
"averageRating": "Prosečna ocena",
|
||||||
|
"basedOnReviews": "Na osnovu 1000+ recenzija",
|
||||||
|
"happyCustomers": "Srećni kupci",
|
||||||
|
"worldwide": "Širom sveta",
|
||||||
|
"naturalIngredients": "Prirodni sastojci",
|
||||||
|
"noAdditives": "Bez aditiva",
|
||||||
|
"freeShipping": "Besplatna dostava",
|
||||||
|
"ordersOver": "Porudžbine preko 3.000 RSD"
|
||||||
|
},
|
||||||
|
"ProblemSection": {
|
||||||
|
"title": "Problem",
|
||||||
|
"subtitle": "Zamareni proizvodima za kosu i kožu koji ne ispunjavaju obećanja?",
|
||||||
|
"description": "Zaslužujete više od proizvoda punih grubih hemikalija i praznih obećanja",
|
||||||
|
"problems": [
|
||||||
|
{
|
||||||
|
"problem": "Suva, oštećena kosa",
|
||||||
|
"description": "Proizvodi ostavljaju vašu kosu krhkom, frizirajućom i lomljivom uprkos skupim tretmanima"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Zbunjeni sastojci",
|
||||||
|
"description": "Ne možete izgovoriti šta je u vašoj nezi kože. Parabeni, sulfati, sintetički mirisi—opasni toksini"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"problem": "Bez stvarnih rezultata",
|
||||||
|
"description": "Bezbroj proizvoda obećava čuda ali isporučuju samo prazna obećanja i utrošen novac"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AsSeenIn": {
|
||||||
|
"title": "Kao što je viđeno u"
|
||||||
|
},
|
||||||
|
"BeforeAfterGallery": {
|
||||||
|
"realResults": "Stvarni rezultati",
|
||||||
|
"seeTransformation": "Pogledajte transformaciju",
|
||||||
|
"startTransformation": "Započnite vašu transformaciju",
|
||||||
|
"before": "PRE",
|
||||||
|
"after": "POSLE",
|
||||||
|
"verified": "Potvrđeno",
|
||||||
|
"timeline": "Nakon {weeks}"
|
||||||
|
},
|
||||||
|
"HowItWorks": {
|
||||||
|
"title": "Jednostavan proces",
|
||||||
|
"subtitle": "Kako ManoonOils funkcioniše",
|
||||||
|
"startTransformation": "Započnite vašu transformaciju",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"title": "Izaberite vaše ulje",
|
||||||
|
"description": "Izaberite iz naše kolekcije čistih, hladno ceđenih ulja formulisanih za vaše specifične potrebe kose i kože."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nanesite svakodnevno",
|
||||||
|
"description": "Umasirajte nekoliko kapi u vlažnu kosu ili kožu. Naša ulja se momentalno upijaju—nikada masna, uvek negujuća."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Vidite rezultate",
|
||||||
|
"description": "Doživite transformaciju za 4-6 nedelja. Sjajnija kosa, zračna koža i samopouzdanje koje sija."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Header": {
|
||||||
|
"products": "Proizvodi",
|
||||||
|
"about": "O nama",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"cart": "Korpa",
|
||||||
|
"account": "Nalog",
|
||||||
|
"openMenu": "Otvori meni",
|
||||||
|
"closeMenu": "Zatvori meni",
|
||||||
|
"openCart": "Otvori korpu"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"quickLinks": "Brze veze",
|
"shop": "Prodavnica",
|
||||||
"customerService": "Korisnička podrška",
|
"allProducts": "Svi proizvodi",
|
||||||
"copyright": "Sva prava zadržana."
|
"hairCare": "Nega kose",
|
||||||
|
"skinCare": "Nega kože",
|
||||||
|
"giftSets": "Poklon setovi",
|
||||||
|
"about": "O nama",
|
||||||
|
"ourStory": "Naša priča",
|
||||||
|
"process": "Proces",
|
||||||
|
"sustainability": "Održivost",
|
||||||
|
"help": "Pomoć",
|
||||||
|
"faq": "Česta pitanja",
|
||||||
|
"shipping": "Dostava",
|
||||||
|
"returns": "Povrat",
|
||||||
|
"contactUs": "Kontaktirajte nas",
|
||||||
|
"brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.",
|
||||||
|
"weAccept": "Prihvatamo:",
|
||||||
|
"allRights": "Sva prava zadržana.",
|
||||||
|
"madeWith": "Napravljeno sa"
|
||||||
|
},
|
||||||
|
"ProductCard": {
|
||||||
|
"noImage": "Nema slike",
|
||||||
|
"outOfStock": "Nema na stanju",
|
||||||
|
"quickAdd": "Brzo dodavanje",
|
||||||
|
"contactForPrice": "Kontaktirajte za cenu"
|
||||||
|
},
|
||||||
|
"ProductDetail": {
|
||||||
|
"home": "Početna",
|
||||||
|
"outOfStock": "Nema na stanju",
|
||||||
|
"size": "Veličina",
|
||||||
|
"qty": "Kol",
|
||||||
|
"adding": "Dodavanje...",
|
||||||
|
"transformHairSkin": "Transformiši kosu i kožu",
|
||||||
|
"freeShipping": "Besplatna dostava za porudžbine preko 3.000 RSD",
|
||||||
|
"guarantee": "30-dnevna garancija",
|
||||||
|
"secureCheckout": "Sigurno plaćanje",
|
||||||
|
"easyReturns": "Lak povrat",
|
||||||
|
"benefits": "Prednosti",
|
||||||
|
"description": "Opis",
|
||||||
|
"howToUse": "Kako koristiti",
|
||||||
|
"howToUseText": "Nanesite malu količinu na čistu, vlažnu kosu ili kožu. Nežno masirajte dok se ne upije. Koristite svakodnevno za najbolje rezultate.",
|
||||||
|
"ingredients": "Sastojci",
|
||||||
|
"ingredientsText": "100% čisto prirodno ulje. Bez dodataka, konzervansa ili veštačkih mirisa.",
|
||||||
|
"youMayAlsoLike": "Možda će vam se svideti",
|
||||||
|
"similarProducts": "Slični proizvodi",
|
||||||
|
"stocksRunningOut": "Zalihe se smanjuju!",
|
||||||
|
"urgency1": "Požuri! 500+ proizvoda prodato u poslednja 3 dana!",
|
||||||
|
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
|
||||||
|
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
|
||||||
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Izaberi pakovanje",
|
||||||
|
"singleUnit": "1 komad",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Štedi {amount}",
|
||||||
|
"perUnit": "po komadu"
|
||||||
|
},
|
||||||
|
"Newsletter": {
|
||||||
|
"stayConnected": "Ostanite povezani",
|
||||||
|
"joinCommunity": "Pridružite se našoj zajednici",
|
||||||
|
"newsletterText": "Pretplatite se da biste primali ekskluzivne ponude, savete za negu i budite prvi koji ćete saznati za nove proizvode.",
|
||||||
|
"emailPlaceholder": "Unesite vaš email",
|
||||||
|
"subscribe": "Pretplatite se"
|
||||||
|
},
|
||||||
|
"ProductBenefits": {
|
||||||
|
"whyChoose": "Zašto odabrati ovaj proizvod",
|
||||||
|
"manoonDifference": "Manoon razlika",
|
||||||
|
"pureNatural": "Čisto i prirodno",
|
||||||
|
"pureNaturalDesc": "100% prirodni sastojci bez aditiva ili konzervansa",
|
||||||
|
"crueltyFree": "Bez okrutnosti",
|
||||||
|
"crueltyFreeDesc": "Nikada testirano na životinjama, etički nabavljeni sastojci",
|
||||||
|
"madeWithLove": "Napravljeno sa ljubavlju",
|
||||||
|
"madeWithLoveDesc": "Ručno pravljeno u malim serijama za maksimalni kvalitet",
|
||||||
|
"visibleResults": "Vidljivi rezultati",
|
||||||
|
"visibleResultsDesc": "Primetna poboljšanja za 4-6 nedelja"
|
||||||
|
},
|
||||||
|
"Cart": {
|
||||||
|
"yourCart": "Vaša korpa",
|
||||||
|
"closeCart": "Zatvori korpu",
|
||||||
|
"dismiss": "Odbaci",
|
||||||
|
"yourCartEmpty": "Vaša korpa je prazna",
|
||||||
|
"looksLikeEmpty": "Izgleda da još uvek niste dodali ništa u korpu.",
|
||||||
|
"startShopping": "Započni kupovinu",
|
||||||
|
"subtotal": "Ukupno",
|
||||||
|
"shipping": "Dostava",
|
||||||
|
"calculatedAtCheckout": "Racunato pri kupovini",
|
||||||
|
"total": "Ukupno",
|
||||||
|
"freeShippingOver": "Besplatna dostava za porudžbine preko {amount}",
|
||||||
|
"processing": "Obrađivanje...",
|
||||||
|
"checkout": "Kupovina",
|
||||||
|
"continueShopping": "Nastavi kupovinu",
|
||||||
|
"removeItem": "Ukloni proizvod"
|
||||||
|
},
|
||||||
|
"Checkout": {
|
||||||
|
"checkout": "Kupovina",
|
||||||
|
"shippingAddress": "Adresa za dostavu",
|
||||||
|
"firstName": "Ime",
|
||||||
|
"lastName": "Prezime",
|
||||||
|
"streetAddress": "Ulica i broj",
|
||||||
|
"streetAddressOptional": "Stan, apartman, itd. (opciono)",
|
||||||
|
"city": "Grad",
|
||||||
|
"postalCode": "Poštanski broj",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"billingAddressSame": "Adresa za naplatu je ista kao adresa za dostavu",
|
||||||
|
"billingAddress": "Adresa za naplatu",
|
||||||
|
"paymentMethod": "Način plaćanja",
|
||||||
|
"cashOnDelivery": "Pouzećem (COD)",
|
||||||
|
"cashOnDeliveryDesc": "Platite kada vam narudžbina bude isporučena na vrata.",
|
||||||
|
"processing": "Obrađivanje...",
|
||||||
|
"completeOrder": "Završi narudžbinu - {total}",
|
||||||
|
"orderSummary": "Pregled narudžbine",
|
||||||
|
"qty": "Kol",
|
||||||
|
"subtotal": "Ukupno",
|
||||||
|
"shipping": "Dostava",
|
||||||
|
"calculated": "Po obračunu",
|
||||||
|
"total": "Ukupno",
|
||||||
|
"yourCartEmpty": "Vaša korpa je prazna",
|
||||||
|
"continueShopping": "Nastavi kupovinu",
|
||||||
|
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||||
|
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
||||||
|
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||||
|
"orderConfirmed": "Narudžbina potvrđena!",
|
||||||
|
"thankYou": "Hvala vam na kupovini!",
|
||||||
|
"orderNumber": "Broj narudžbine",
|
||||||
|
"confirmationEmail": "Uскoro ćete primiti email potvrde. Kontaktiraćemo vas da dogovorimo pouzećem plaćanje.",
|
||||||
|
"continueShoppingBtn": "Nastavi kupovinu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { routing } from './routing';
|
|||||||
export default getRequestConfig(async ({ requestLocale }) => {
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
let locale = await requestLocale;
|
let locale = await requestLocale;
|
||||||
|
|
||||||
if (!locale || !routing.locales.includes(locale as any)) {
|
if (!locale || !routing.locales.includes(locale as typeof routing.locales[number])) {
|
||||||
locale = routing.defaultLocale;
|
locale = routing.defaultLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineRouting } from 'next-intl/routing';
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
export const routing = defineRouting({
|
export const routing = defineRouting({
|
||||||
locales: ['en', 'sr'],
|
locales: SUPPORTED_LOCALES,
|
||||||
defaultLocale: 'sr'
|
defaultLocale: DEFAULT_LOCALE,
|
||||||
|
localePrefix: "as-needed",
|
||||||
});
|
});
|
||||||
|
|||||||
37
src/lib/i18n/locales.ts
Normal file
37
src/lib/i18n/locales.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export const SUPPORTED_LOCALES = ["sr", "en", "de", "fr"] as const;
|
||||||
|
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
export const DEFAULT_LOCALE: Locale = "sr";
|
||||||
|
|
||||||
|
export const LOCALE_COOKIE = "NEXT_LOCALE";
|
||||||
|
|
||||||
|
export const LOCALE_CONFIG: Record<Locale, { label: string; flag: string; saleorLocale: string }> = {
|
||||||
|
sr: { label: "Srpski", flag: "🇷🇸", saleorLocale: "SR" },
|
||||||
|
en: { label: "English", flag: "🇬🇧", saleorLocale: "EN" },
|
||||||
|
de: { label: "Deutsch", flag: "🇩🇪", saleorLocale: "EN" },
|
||||||
|
fr: { label: "Français", flag: "🇫🇷", saleorLocale: "EN" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidLocale(locale: string): locale is Locale {
|
||||||
|
return SUPPORTED_LOCALES.includes(locale as Locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSaleorLocale(locale: Locale): string {
|
||||||
|
return LOCALE_CONFIG[locale].saleorLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleFromPath(pathname: string): string {
|
||||||
|
const pattern = SUPPORTED_LOCALES.join("|");
|
||||||
|
const match = pathname.match(new RegExp(`^\\/(${pattern})`));
|
||||||
|
return match ? match[1] : DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPathWithoutLocale(pathname: string): string {
|
||||||
|
const pattern = SUPPORTED_LOCALES.join("|");
|
||||||
|
return pathname.replace(new RegExp(`^\\/(${pattern})`), "") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalePath(locale: Locale, path: string): string {
|
||||||
|
const pathPart = path === "/" ? "" : path;
|
||||||
|
return `/${locale}${pathPart}`;
|
||||||
|
}
|
||||||
37
src/lib/i18n/metadata.ts
Normal file
37
src/lib/i18n/metadata.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { DEFAULT_LOCALE, LOCALE_CONFIG, SUPPORTED_LOCALES, type Locale } from "./locales";
|
||||||
|
|
||||||
|
export function getSaleorLocale(locale: Locale): string {
|
||||||
|
return LOCALE_CONFIG[locale].saleorLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleLabel(locale: Locale): string {
|
||||||
|
return LOCALE_CONFIG[locale].label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDefaultLocale(locale: string): boolean {
|
||||||
|
return locale === DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleFromParams(params: { locale: string }): Locale {
|
||||||
|
const { locale } = params;
|
||||||
|
if (SUPPORTED_LOCALES.includes(locale as Locale)) {
|
||||||
|
return locale as Locale;
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductLocale(locale: Locale): string {
|
||||||
|
return getSaleorLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHreflangAlternates(baseUrl: string): Record<string, string> {
|
||||||
|
const alternates: Record<string, string> = {};
|
||||||
|
for (const loc of SUPPORTED_LOCALES) {
|
||||||
|
if (loc === DEFAULT_LOCALE) {
|
||||||
|
alternates[loc] = baseUrl;
|
||||||
|
} else {
|
||||||
|
alternates[loc] = `${baseUrl}/${loc}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alternates;
|
||||||
|
}
|
||||||
98
src/lib/i18n/pageMetadata.ts
Normal file
98
src/lib/i18n/pageMetadata.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Locale } from "./locales";
|
||||||
|
|
||||||
|
const PAGE_METADATA: Record<Locale, {
|
||||||
|
home: { title: string; description: string; productionAlt: string };
|
||||||
|
products: { title: string; description: string };
|
||||||
|
productNotFound: string;
|
||||||
|
about: { title: string; description: string; productionAlt: string };
|
||||||
|
contact: { title: string; description: string };
|
||||||
|
}> = {
|
||||||
|
sr: {
|
||||||
|
home: {
|
||||||
|
title: "ManoonOils - Premium prirodna ulja za negu kose i kože",
|
||||||
|
description: "Otkrijte našu premium kolekciju prirodnih ulja za negu kose i kože.",
|
||||||
|
productionAlt: "Proizvodnja prirodnih ulja",
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
title: "Proizvodi - ManoonOils",
|
||||||
|
description: "Pregledajte našu kolekciju premium prirodnih ulja za negu kose i kože.",
|
||||||
|
},
|
||||||
|
productNotFound: "Proizvod nije pronađen",
|
||||||
|
about: {
|
||||||
|
title: "O nama - ManoonOils",
|
||||||
|
description: "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti.",
|
||||||
|
productionAlt: "Proizvodnja prirodnih ulja",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: "Kontakt - ManoonOils",
|
||||||
|
description: "Kontaktirajte nas za sva pitanja o proizvodima, narudžbinama ili saradnji.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
home: {
|
||||||
|
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||||
|
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||||
|
productionAlt: "Natural oils production",
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
title: "Products - ManoonOils",
|
||||||
|
description: "Browse our collection of premium natural oils for hair and skin care.",
|
||||||
|
},
|
||||||
|
productNotFound: "Product not found",
|
||||||
|
about: {
|
||||||
|
title: "About - ManoonOils",
|
||||||
|
description: "Learn more about ManoonOils - our story, mission, and commitment to natural beauty.",
|
||||||
|
productionAlt: "Natural oils production",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: "Contact - ManoonOils",
|
||||||
|
description: "Contact us for any questions about products, orders, or collaborations.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
home: {
|
||||||
|
title: "ManoonOils - Premium natürliche Öle für Haar & Haut",
|
||||||
|
description: "Entdecken Sie unsere Premium-Kollektion natürlicher Öle für Haar- und Hautpflege.",
|
||||||
|
productionAlt: "Natürliche Ölproduktion",
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
title: "Produkte - ManoonOils",
|
||||||
|
description: "Durchsuchen Sie unsere Kollektion premium natürlicher Öle für Haar- und Hautpflege.",
|
||||||
|
},
|
||||||
|
productNotFound: "Produkt nicht gefunden",
|
||||||
|
about: {
|
||||||
|
title: "Über uns - ManoonOils",
|
||||||
|
description: "Erfahren Sie mehr über ManoonOils und unsere Mission, premium natürliche Produkte anzubieten.",
|
||||||
|
productionAlt: "Natürliche Ölproduktion",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: "Kontakt - ManoonOils",
|
||||||
|
description: "Kontaktieren Sie uns für Fragen zu Produkten, Bestellungen oder Zusammenarbeit.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
home: {
|
||||||
|
title: "ManoonOils - Huiles Naturelles Premium pour Cheveux & Peau",
|
||||||
|
description: "Découvrez notre collection premium d'huiles naturelles pour les soins capillaires et cutanés.",
|
||||||
|
productionAlt: "Production d'huiles naturelles",
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
title: "Produits - ManoonOils",
|
||||||
|
description: "Parcourez notre collection d'huiles naturelles premium pour les soins capillaires et cutanés.",
|
||||||
|
},
|
||||||
|
productNotFound: "Produit non trouvé",
|
||||||
|
about: {
|
||||||
|
title: "À propos - ManoonOils",
|
||||||
|
description: "En savoir plus sur ManoonOils et notre mission de fournir des produits naturels premium.",
|
||||||
|
productionAlt: "Production d'huiles naturelles",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: "Contact - ManoonOils",
|
||||||
|
description: "Contactez-nous pour toute question sur les produits, commandes ou collaborations.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPageMetadata(locale: Locale) {
|
||||||
|
return PAGE_METADATA[locale] || PAGE_METADATA.en;
|
||||||
|
}
|
||||||
57
src/lib/i18n/productText.ts
Normal file
57
src/lib/i18n/productText.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Locale } from "./locales";
|
||||||
|
|
||||||
|
const PRODUCT_TEXT: Record<Locale, {
|
||||||
|
defaultShortDescription: string;
|
||||||
|
defaultBenefits: string[];
|
||||||
|
}> = {
|
||||||
|
sr: {
|
||||||
|
defaultShortDescription: "Premium prirodno ulje za vašu rutinu lepote.",
|
||||||
|
defaultBenefits: ["Prirodno", "Organsko", "Bez okrutnosti"],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
defaultShortDescription: "Premium natural oil for your beauty routine.",
|
||||||
|
defaultBenefits: ["Natural", "Organic", "Cruelty-free"],
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
defaultShortDescription: "Premium natürliches Öl für Ihre Schönheitsroutine.",
|
||||||
|
defaultBenefits: ["Natürlich", "Bio", "Tierversuchsfrei"],
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
defaultShortDescription: "Huile naturelle premium pour votre routine beauté.",
|
||||||
|
defaultBenefits: ["Naturel", "Bio", "Sans cruauté"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getProductDefaults(locale: Locale) {
|
||||||
|
return PRODUCT_TEXT[locale] || PRODUCT_TEXT.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTranslatedBenefits(
|
||||||
|
metadataBenefits: string[] | undefined,
|
||||||
|
locale: Locale
|
||||||
|
): string[] {
|
||||||
|
const defaults = PRODUCT_TEXT[locale] || PRODUCT_TEXT.en;
|
||||||
|
|
||||||
|
if (!metadataBenefits || metadataBenefits.length === 0) {
|
||||||
|
return defaults.defaultBenefits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadataBenefits.map((benefit, index) => {
|
||||||
|
const trimmed = benefit.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return defaults.defaultBenefits[index] || trimmed;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTranslatedShortDescription(
|
||||||
|
description: string | undefined,
|
||||||
|
locale: Locale
|
||||||
|
): string {
|
||||||
|
if (description && description.trim()) {
|
||||||
|
return description.split('.')[0] + '.';
|
||||||
|
}
|
||||||
|
const defaults = PRODUCT_TEXT[locale] || PRODUCT_TEXT.en;
|
||||||
|
return defaults.defaultShortDescription;
|
||||||
|
}
|
||||||
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}
|
||||||
|
`;
|
||||||
93
src/lib/saleor/fragments/Product.ts
Normal file
93
src/lib/saleor/fragments/Product.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
attributes {
|
||||||
|
attribute {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
values {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
42
src/lib/saleor/index.ts
Normal file
42
src/lib/saleor/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// 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, GET_BUNDLE_PRODUCTS } 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,
|
||||||
|
getProductPriceAmount,
|
||||||
|
getProductImage,
|
||||||
|
isProductAvailable,
|
||||||
|
formatPrice,
|
||||||
|
getLocalizedProduct,
|
||||||
|
parseDescription,
|
||||||
|
getBundleProducts,
|
||||||
|
getBundleProductsForProduct,
|
||||||
|
getProductBundleComponents,
|
||||||
|
isBundleProduct,
|
||||||
|
filterOutBundles,
|
||||||
|
} 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($id: ID!, $linesIds: [ID!]!) {
|
||||||
|
checkoutLinesDelete(id: $id, linesIds: $linesIds) {
|
||||||
|
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}
|
||||||
|
`;
|
||||||
223
src/lib/saleor/products.ts
Normal file
223
src/lib/saleor/products.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { saleorClient } from "./client";
|
||||||
|
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_BUNDLE_PRODUCTS } 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 getProductPriceAmount(product: Product): number {
|
||||||
|
const variant = product.variants?.[0];
|
||||||
|
return variant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductsResponse {
|
||||||
|
products?: {
|
||||||
|
edges: Array<{ node: Product }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBundleProducts(
|
||||||
|
locale: string = "SR",
|
||||||
|
first: number = 50
|
||||||
|
): Promise<Product[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await saleorClient.query<ProductsResponse>({
|
||||||
|
query: GET_BUNDLE_PRODUCTS,
|
||||||
|
variables: {
|
||||||
|
channel: CHANNEL,
|
||||||
|
locale: locale.toUpperCase(),
|
||||||
|
first,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data?.products?.edges.map((edge) => edge.node) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bundle products from Saleor:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBundleProductsForProduct(
|
||||||
|
allProducts: Product[],
|
||||||
|
baseProductId: string
|
||||||
|
): Product[] {
|
||||||
|
return allProducts.filter((product) => {
|
||||||
|
const bundleItemsAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleItemsAttr) return false;
|
||||||
|
return bundleItemsAttr.values.some((val) => {
|
||||||
|
const referencedId = Buffer.from(val.slug.split(":")[1] || val.id).toString("base64");
|
||||||
|
const expectedId = `UHJvZHVjdDo${baseProductId.split("UHJvZHVjdDo")[1]}`;
|
||||||
|
return referencedId.includes(baseProductId.split("UHJvZHVjdDo")[1] || "") ||
|
||||||
|
val.slug.includes(baseProductId.split("UHJvZHVjdDo")[1] || "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductBundleComponents(product: Product): number | null {
|
||||||
|
const bundleAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr) return null;
|
||||||
|
|
||||||
|
const bundleAttrMatch = product.name.match(/(\d+)x/i);
|
||||||
|
if (bundleAttrMatch) {
|
||||||
|
return parseInt(bundleAttrMatch[1], 10);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBundleProduct(product: Product): boolean {
|
||||||
|
return getProductBundleComponents(product) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutBundles(products: Product[]): Product[] {
|
||||||
|
return products.filter((product) => !isBundleProduct(product));
|
||||||
|
}
|
||||||
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}
|
||||||
|
`;
|
||||||
64
src/lib/saleor/queries/Products.ts
Normal file
64
src/lib/saleor/queries/Products.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 {
|
||||||
|
...ProductFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${PRODUCT_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}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_BUNDLE_PRODUCTS = gql`
|
||||||
|
query GetBundleProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
|
||||||
|
products(channel: $channel, first: $first) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...ProductFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${PRODUCT_FRAGMENT}
|
||||||
|
`;
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import WooCommerceRestApi from "@woocommerce/woocommerce-rest-api";
|
|
||||||
|
|
||||||
const api = new WooCommerceRestApi({
|
|
||||||
url: process.env.NEXT_PUBLIC_WOOCOMMERCE_URL || "",
|
|
||||||
consumerKey: process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY || "",
|
|
||||||
consumerSecret: process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET || "",
|
|
||||||
version: "wc/v3",
|
|
||||||
});
|
|
||||||
|
|
||||||
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 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 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 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 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 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 api;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import createMiddleware from "next-intl/middleware";
|
|
||||||
import { defineRouting } from 'next-intl/routing';
|
|
||||||
|
|
||||||
const routing = defineRouting({
|
|
||||||
locales: ['en', 'sr'],
|
|
||||||
defaultLocale: 'sr'
|
|
||||||
});
|
|
||||||
|
|
||||||
export default createMiddleware(routing);
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ['/((?!api|_next|_vercel|).*)']
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user