Tema
Ç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:
- Tema arayüz metinleri — bölümlerin
settingsalanlarına merchant'ın yazdığı serbest metin (başlık, buton yazısı, duyuru metni vb.). - Katalog verisi — ürün/kategori/marka adı ve açıklaması. Bunlar
products,categories,brandstablolarında düzVARCHAR/TEXTolarak tutuluyor, çeviri kavramı yok. - 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ı). - 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.ts → resolveStoreDomain), 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,
hreflangile 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_translationsOkuma 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:
translatabledeğilse değeri olduğu gibi kullanır (bugünkü davranış).translatableisevalue[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.xmlroute'u (src/app/sitemap.xml/route.ts) her locale için ayrı URL seti üretir, her URL kendihreflangalternatiflerine referans verir.generateMetadata([[...slug]]/page.tsx) her sayfada<link rel="alternate" hreflang="en" href=".../en/...">etiketlerini ekler — locale listesinisales_channel_locales'tan okur.robots.txtdeğişmez, locale'e duyarlı olması gerekmiyor.
Uygulama sırası (durum)
- ✅ Şema + middleware:
sales_channel_locales,resolveLocale,x-store-localeheader'ı. - ✅ Tema metinleri:
SettingDef.translatable, partner geliştirme editöründe "Çevrilebilir" kutusu, mağaza editöründe dil sekmeleri,resolveThemeLocaleile locale-keyed okuma. - ✅ 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).
- admin panelde çeviri paneli + API'de ikinci, toplu sorgu ile bindirme (
- ✅ URL/SEO: path-prefix routing, hreflang (
alternates.languages), sitemap.xml ve robots.txt dil varyantları. - ✅ Tema SDK yardımcıları:
createWinkwopClientartıklocale/locales/t()taşıyor; vitrin'in Header'ında gerçek bir dil değiştirici var. - ❌ 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).