- 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
522 lines
15 KiB
Markdown
522 lines
15 KiB
Markdown
# 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.
|