feat(saleor): Phase 1 - GraphQL Client Setup
- Add Apollo Client for Saleor GraphQL API - Create GraphQL fragments (Product, Variant, Checkout) - Create GraphQL queries (Products, Checkout) - Create GraphQL mutations (Checkout operations) - Add TypeScript types for Saleor entities - Add product helper functions - Install @apollo/client and graphql dependencies Part of WordPress/WooCommerce → Saleor migration
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user