# 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)
`/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 ( <> {displayData.name} {/* Canonical URL - Serbian version */} {/* Alternate languages */}

{displayData.name}

{/* Language Switcher */}
🇷🇸 Srpski 🇬🇧 English
); } ``` ### 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 ( <>

{displayName}

); } ``` ## 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.