Skip to content

Çoklu dil desteği mimarisi

Bu doküman, WinkWop'un bölüm/registry tabanlı tema sistemine eklenen çoklu dil (i18n) desteğinin mimarisini anlatır. Aşağıdaki 1-5. aşamalar uygulandı; 6. aşama (sistem e-postaları) henüz yapılmadı — sipariş e-postaları zaten platformda hiç yok, çevirecek bir şey bulunmuyor.

Önce kapsamı ayırmak gerekiyor

"Çoklu dil" tek bir özellik değil, birbirinden bağımsız dört katman:

  1. Tema arayüz metinleri — bölümlerin settings alanlarına merchant'ın yazdığı serbest metin (başlık, buton yazısı, duyuru metni vb.).
  2. Katalog verisi — ürün/kategori/marka adı ve açıklaması. Bunlar products, categories, brands tablolarında düz VARCHAR/TEXT olarak tutuluyor, çeviri kavramı yok.
  3. Platform sabit metinleri — "Sepete Ekle", "Stokta yok", checkout adımlarının etiketleri gibi tema koduna gömülü, merchant'ın değiştiremediği string'ler (themes/vitrin/src/lib/ui.tsx, section dosyaları).
  4. Sistem e-postaları — doğrulama/şifre sıfırlama gibi API'nin gönderdiği e-postalar (winkwop-ecommerce-api/src/utils/email.rs), şu an sabit Türkçe.

Bu dördü farklı hızda ilerleyebilir. Önerilen sıra da bu — tema metinleri en çok işe yarayan ve en az riskli katman, sistem e-postaları en sonda.

URL stratejisi: path-prefix

Alt alan adı tabanlı mağaza çözümlemesi zaten var (proxy.tsresolveStoreDomain), yani mağaza.winkwop.com zaten "hangi mağaza" sorusunu cevaplıyor. Dil için ikinci bir subdomain katmanı önerilmiyor (ör. en.magaza.winkwop.com) — DNS/sertifika karmaşıklığı katar ve mevcut wildcard kurulumuyla çakışır (bkz. Coolify'da *.winkwop.com wildcard domain kurulumu).

Bunun yerine path-prefix öneriliyor: magaza.winkwop.com/en/products. Gerekçe:

  • Google'ın önerdiği, hreflang ile en sorunsuz çalışan yöntem budur.
  • Tek bir Next.js deploy'u yeterli — locale'e göre ayrı build/deploy gerekmez.
  • Varsayılan dil (mağazanın ana dili) prefix'siz kalır: magaza.winkwop.com/products = ana dil, /en/products = İngilizce. Mevcut URL'ler kırılmaz, geriye dönük uyumluluk bedavaya gelir.

STANDARD_PAGES'teki yol segmentleri (/products, /collections/:slug, /cart) çevrilmez — yalnızca dil prefix'i eklenir, segment adları sabit kalır. Segment çevirisi (/en/collections/en/koleksiyonlar gibi) teorik olarak SEO'ya biraz daha iyi ama karmaşıklığı önemli ölçüde artırıyor; ilk sürümde buna girilmemesi öneriliyor.

Middleware: resolveStoreDomain'in yanına resolveLocale

proxy.ts'daki proxy() fonksiyonu zaten x-store-domain header'ı enjekte ediyor. Aynı yerde bir x-store-locale header'ı da enjekte edilir:

ts
function resolveLocale(pathname: string, availableLocales: string[], defaultLocale: string) {
  const [, maybeLocale] = pathname.split("/");
  if (availableLocales.includes(maybeLocale)) {
    return { locale: maybeLocale, pathname: pathname.slice(maybeLocale.length + 1) || "/" };
  }
  return { locale: defaultLocale, pathname };
}

availableLocales mağazaya özel — her mağaza hangi dilleri açtıysa (bkz. aşağıdaki sales_channel_locales tablosu) o liste API'den çekilip kullanılır. Locale mağazanın kendi ayarı olduğu için bu sorgu zaten x-store-domain çözümlendikten hemen sonra yapılabilir.

Veritabanı: katalog çevirileri

Var olan products/categories/brands tablolarına dil sütunu eklemek (name_en, name_de, ...) yeni dil eklendikçe migration gerektirir ve şema büyür. Bunun yerine ayrı çeviri tabloları:

sql
CREATE TABLE product_translations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    locale VARCHAR(10) NOT NULL,          -- 'en', 'de', 'tr' (BCP-47 kısa form)
    name VARCHAR(255),
    description TEXT,
    UNIQUE(product_id, locale)
);
-- Aynı desen: category_translations, brand_translations

Okuma tarafında public_catalog.rs'teki sorgular LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.locale = $locale yapıp COALESCE(pt.name, p.name) ile geri düşer — çevirisi eksik bir ürün ham veriyle gösterilir, sayfa kırılmaz. products.name/description ana dilin kendisi olarak kalır, ayrıca bir "varsayılan dil" satırı çevirmeye gerek yok.

Varyant options.values gibi JSONB alanlar (ör. "Kırmızı", "Mavi") için aynı desen küçültülmüş halde: product_variants üzerinde ayrı bir option_value_translations(product_id, locale, original_value, translated_value) tablosu, ya da daha basit yaklaşım — ilk sürümde varyant seçenek adlarının çevrilmemesi (çoğu mağaza için "Kırmızı/Red" ayrımı kritik değil, sonraki faza bırakılabilir).

Tema içeriği: theme_json içinde locale-keyed değerler

tenant_themes.custom_theme_json bugün şöyle bir şekil taşıyor (basitleştirilmiş):

json
{
  "sections": {
    "hero-1": { "type": "hero", "settings": { "title": "Yaz İndirimi" } }
  }
}

Öneri: yalnızca çevrilebilir setting tipleri (text, textarea, links içindeki label) için değerin kendisi locale-keyed bir obje olabilsin:

json
{ "title": { "tr": "Yaz İndirimi", "en": "Summer Sale" } }

Bunu şema seviyesinde ayırt etmek gerekiyor — types.ts'teki SettingDef içine bir translatable?: boolean bayrağı eklenir (color, image, range, select, boolean, data prop'lar gibi tipler için asla true olmaz, text/textarea/links için varsayılan true). ThemeRenderer ve editör, bir setting'i okurken:

  • translatable değilse değeri olduğu gibi kullanır (bugünkü davranış).
  • translatable ise value[locale] ?? value[defaultLocale] ?? "" okur.

Bu yaklaşımın artısı: var olan temalar bozulmaz. Eski "title": "Yaz İndirimi" (düz string) hâlâ geçerli bir değer — value[locale] bir string üzerinde çalışmaz, o yüzden okuma fonksiyonu önce typeof value === "object" kontrolü yapar, değilse düz stringi tüm dillerde kullanır. Yani tek dilli bir mağaza hiçbir şey yapmadan olduğu gibi çalışmaya devam eder; çoklu dil yalnızca merchant ikinci bir dil AÇTIĞINDA devreye girer.

Editör tarafında (setting-field.tsx) translatable bir setting için dil sekmeleri (TR | EN) gösterilir, her sekme kendi Input/Textarea'sını render eder — bugünkü tek-input render'ın üstüne ince bir katman, mevcut SettingField dispatch deseni bozulmadan.

Mağaza bazında hangi diller açık

sql
CREATE TABLE sales_channel_locales (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sales_channel_id UUID NOT NULL REFERENCES sales_channels(id) ON DELETE CASCADE,
    locale VARCHAR(10) NOT NULL,
    is_default BOOLEAN NOT NULL DEFAULT false,
    UNIQUE(sales_channel_id, locale)
);

Her mağaza en az bir satıra sahip olmalı (is_default = true) — migration bunu her mevcut sales_channels satırı için tr olarak seed eder, yani geriye dönük hiçbir mağaza "dilsiz" kalmaz.

Paket kısıtı: ikinci bir dil açmak PlanLimits.multi_language'e tabi (Free'de kapalı, ücretli paketlerde açık) — plan::ensure_multi_language, set_channel_locales uç noktasında yalnızca istenen küme birden fazla dil içeriyorsa çağrılır. Tema çeviriyi desteklese bile (translatable alanları olsa bile) Free paketteki bir mağaza tek dilin ötesine geçemez. Tek dile geri dönmek (ikinci dili kapatmak) her paket için serbest — yalnızca açmak kısıtlı.

Platform sabit metinleri (tema kodundaki string'ler)

themes/vitrin gibi partner temaları kendi UI string'lerini kendi gömüyor ("Sepete Ekle", "Stokta yok" vb.). Bunlar merchant'ın değil, tema geliştiricisinin kontrolünde — çevirisi de tema paketinin sorumluluğu olmalı, platformun değil. Öneri: theme-sdk'ye küçük bir çeviri yardımcısı eklenir:

ts
// winkwop-theme-sdk
export function useLocale(): string; // storefront'un enjekte ettiği x-store-locale'i okur
export function t(dict: Record<string, Record<string, string>>, key: string): string;

Tema geliştiricisi kendi locales/tr.json, locales/en.json dosyalarını tema projesine ekler, t(dict, "addToCart") çağırır. Platform hangi dillerin desteklendiğine karışmaz — bu SDK'nın sağladığı bir araç, zorunlu bir sözleşme değil. Var olan temalar (bu yardımcıyı kullanmayanlar) tek dilde çalışmaya devam eder, kırılma olmaz.

SEO

  • sitemap.xml route'u (src/app/sitemap.xml/route.ts) her locale için ayrı URL seti üretir, her URL kendi hreflang alternatiflerine referans verir.
  • generateMetadata ([[...slug]]/page.tsx) her sayfada <link rel="alternate" hreflang="en" href=".../en/..."> etiketlerini ekler — locale listesini sales_channel_locales'tan okur.
  • robots.txt değişmez, locale'e duyarlı olması gerekmiyor.

Uygulama sırası (durum)

  1. Şema + middleware: sales_channel_locales, resolveLocale, x-store-locale header'ı.
  2. Tema metinleri: SettingDef.translatable, partner geliştirme editöründe "Çevrilebilir" kutusu, mağaza editöründe dil sekmeleri, resolveThemeLocale ile locale-keyed okuma.
  3. Katalog çevirileri: product_translations/category_translations
    • admin panelde çeviri paneli + API'de ikinci, toplu sorgu ile bindirme (apply_product_translations / apply_category_translations). brand_translations şeması var ama okuyan bir uç yok (herkese açık marka listesi diye bir şey henüz yok).
  4. URL/SEO: path-prefix routing, hreflang (alternates.languages), sitemap.xml ve robots.txt dil varyantları.
  5. Tema SDK yardımcıları: createWinkwopClient artık locale/ locales/t() taşıyor; vitrin'in Header'ında gerçek bir dil değiştirici var.
  6. Sistem e-postaları: yapılmadı — sipariş e-postaları zaten platformda hiç yok (bkz. §8 Kritik, madde 2, PROJE.md), çevirecek bir şey bulunmuyor. Bu özellik eklendiğinde ele alınmalı.

Her adım bağımsız deploy edildi ve bir öncekini bozmadan durdu; tek dilli bir mağaza (bugün hemen hemen hepsi) için hiçbirinin görünür etkisi yok, yalnızca ikinci bir dil açıldığında devreye giriyor — ki o da yalnızca ücretli paketlerde mümkün (PlanLimits.multi_language).

Winkwop tema platformu