docs(i18n): update master plan and add v2 phase 0/1 smoke tests

This commit is contained in:
poyrazavsever
2026-07-20 10:25:40 +03:00
parent 0f2ff48376
commit 9365dfd215
15 changed files with 795 additions and 106 deletions
@@ -0,0 +1,72 @@
# I18n V2 Faz 0 — Mimari kararlar
Tarih: 2026-07-20
## ADR-001 — Locale bağlama göre çözülür
Tek bir genel resolver yerine dört açık bağlam kullanılır:
- Public auth: instance default
- Invitation: invitation snapshot, ardından instance default
- Freelancer: user preference, ardından instance default
- Portal: user preference, client portal default, instance default
Public auth locale çözümünde cookie ve `Accept-Language` kullanılmaz. Locale
cookie'si dil değiştirme ve resolver akışından kaldırılır; authenticated tercih
veritabanından, public dil instance ayarından çözülür.
## ADR-002 — Kişisel tercih ve yönetim ayarı ayrıdır
`user_preferences.language`, `clients.portal_locale` ve
`instance_i18n_settings.default_locale` aynı kavram değildir:
- User preference kişinin açık seçimidir.
- Client portal locale adminin müşteri için belirlediği başlangıç/default dildir.
- Instance default public yüzeylerin ve fallback zincirinin dilidir.
Bu değerler farklı action/service fonksiyonlarıyla değiştirilir.
## ADR-003 — Ayarlar gerçek alt rotalara ayrılır
Component state ile değişen tek ayarlar ekranı kullanılmaz. `/settings/*`
altındaki her bölüm gerçek bir route olur. Ortak layout başlık, kalıcı
navigasyon, loading ve error sınırını sağlar. Her sonraki settings fazı kendi veri
yükleme ve mutation sınırını ayırır.
## ADR-004 — Sayfa tamamlanma birimi
Bir sayfa yalnız title çevrildiğinde tamamlanmış sayılmaz. Header, form, dialog,
toast, validation, loading/error/empty state, accessibility metni ve locale
formatlama aynı sayfa fazının kapsamındadır.
## ADR-005 — Dinamik metin ve iletişim içeriği ayrıdır
Proje, görev, takvim, finans ve benzeri yönetilen kayıtların çevrilebilir metin
alanları locale tab'larıyla saklanır. Chat mesajı ve revizyon talebi gibi bir
kişinin yazdığı iletişim metni çoğaltılmaz; kaynak dili korunur.
## ADR-006 — V1'den korunan parçalar
Korunacak:
- `instance_locales`
- `instance_i18n_settings`
- `instance_ui_translations`
- `content_translations`
- Built-in TR/EN katalog ve fallback temeli
- Locale-aware `Intl` helper'ları
Düzeltilecek:
- Resolver öncelikleri
- Preference doğrulaması
- Settings bilgi mimarisi
- Katalog kapsamı
- Translation registry kapsamı
- Sayfa bazlı payload sınırı
Sonraki fazlarda kaldırılabilecek:
- Auth sayfalarına bağlı locale select yüzeyi
- Eski monolit settings composition
- Katalog içindeki tamamlanmamış genel namespace yapısı
@@ -0,0 +1,43 @@
# I18n V2 Faz 0 — Baseline ve regression kapıları
Tarih: 2026-07-20
## Çalıştırılan kontroller
| Kontrol | Sonuç |
| --- | --- |
| `pnpm i18n:v2-page-audit` | Başarılı |
| `pnpm i18n:v2-phase1-smoke` | Başarılı |
| `pnpm typecheck` | Başarılı |
| `pnpm lint` | Başarılı, uyarı yok |
| `pnpm build` | Başarılı, 37 static/dynamic sayfa üretildi |
Sayfa audit'i mevcut ve Faz 2 ile eklenen toplam 31 route girişini doğruladı.
Türkçe karakter içeren 46 hard-coded aday dosya baseline olarak kaydedildi.
## Otomatik regression sözleşmesi
`i18n:v2-page-audit` şu kuralları korur:
- Plan kapsamındaki route dosyaları bulunmalıdır.
- Login, register, forgot-password ve reset-password public resolver kullanmalıdır.
- Auth sayfaları `LocaleSelectForm` kullanmamalıdır.
- Public locale otoritesi `LOCALE_COOKIE` bağımlılığı taşımamalıdır.
- Dört bağlama özel resolver export edilmelidir.
- Settings route navigasyonu pathname-aware ve desktop'ta sticky olmalıdır.
- Hard-coded Türkçe karakter adaylarının en yüksek kaynakları raporlanmalıdır.
`i18n:v2-phase1-smoke` şu öncelikleri doğrular:
- Public -> instance default
- Freelancer -> personal preference -> instance default
- Portal -> personal preference -> client default -> instance default
- Invitation -> invitation snapshot -> instance default
- Archived locale seçilemez.
- Geçersiz default güvenli fallback'e düşer.
- RTL direction locale kaydından korunur.
## Faz 0 çıkış notu
Bu baseline hard-coded metinleri çözmez. Her sayfa kendi fazında audit listesinden
çıkarılacak ve release aşamasında sıfır kullanıcı metni hedefi uygulanacaktır.
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"instance": {
"defaultLocale": "tr",
"locales": [
{ "code": "tr", "status": "active", "fallbackLocale": null, "direction": "ltr" },
{ "code": "en", "status": "active", "fallbackLocale": "tr", "direction": "ltr" },
{ "code": "fr", "status": "draft", "fallbackLocale": "en", "direction": "ltr" },
{ "code": "ar", "status": "test", "fallbackLocale": "en", "direction": "rtl" }
]
},
"actors": {
"freelancerTr": { "role": "freelancer", "preference": "tr" },
"freelancerEn": { "role": "freelancer", "preference": "en" },
"clientAdminDefaultFr": {
"role": "client",
"preference": null,
"clientPortalLocale": "fr",
"expectedLocaleWhileFrIsDraft": "tr"
},
"clientPersonalEn": {
"role": "client",
"preference": "en",
"clientPortalLocale": "tr",
"expectedLocale": "en"
}
},
"content": {
"project": {
"id": "fixture-project",
"legacy": { "name": "Marka sitesi", "description": "Türkçe açıklama" },
"translations": {
"tr": { "name": "Marka sitesi", "description": "Türkçe açıklama" },
"en": { "name": "Brand website", "description": "English description" },
"fr": { "name": "Site de marque" }
}
},
"financeTransaction": {
"id": "fixture-finance",
"amountMinor": 125000,
"currency": "TRY",
"translations": {
"tr": { "category": "Yazılım", "description": "Proje ödemesi" },
"en": { "category": "Software", "description": "Project payment" }
}
}
},
"invitation": {
"locale": "en",
"expectedPublicAuthLocale": "tr",
"expectedInvitationLocale": "en"
}
}
@@ -0,0 +1,103 @@
# I18n V2 Faz 0 — Sayfa ve içerik envanteri
## Sayfa envanteri
| Yüzey | Route | Ana dosya | Plan fazı |
| --- | --- | --- | --- |
| Dashboard | `/` | `app/(dashboard)/dashboard-client.tsx` | 19 |
| Analizler | `/analytics` | `analytics-client.tsx` | 20 |
| Takvim | `/calendar` | `calendar-client.tsx` | 21 |
| Müşteriler | `/clients` | `clients-client.tsx` | 22 |
| Müşteri detay | `/clients/[id]` | `client-detail-client.tsx` | 23 |
| Projeler | `/projects` | `projects-client.tsx` | 24 |
| Proje detay | `/projects/[id]` | `project-detail-client.tsx` | 25 |
| Görevler | `/tasks` | `tasks-client.tsx` | 26 |
| Finans | `/finance` | `finance-client.tsx` | 27 |
| Günlük | `/journal` | `journal-client.tsx` | 28 |
| Sohbet | `/chat` | `app/(dashboard)/chat/page.tsx` | 29 |
| Teklifler | `/business/proposals` | `proposals-client.tsx` | 30 |
| Faturalar | `/business/invoices` | `invoices-client.tsx` | 31 |
| Abonelikler | `/business/subscriptions` | `subscriptions-client.tsx` | 32 |
| Login | `/login` | `app/login/page.tsx` | 14 |
| İlk kayıt | `/register` | `app/register/page.tsx` | 15 |
| Şifremi unuttum | `/forgot-password` | ilgili `page.tsx` | 16 |
| Şifre sıfırlama | `/reset-password` | ilgili `page.tsx` | 17 |
| Portal daveti | `/invite/[token]` | ilgili `page.tsx` | 38 |
| Portal dashboard | `/portal` | ilgili `page.tsx` | 39 |
| Portal projeler | `/portal/projects` | ilgili `page.tsx` | 40 |
| Portal proje detay | `/portal/projects/[id]` | ilgili client/page | 41 |
| Portal görevler | `/portal/tasks` | ilgili `page.tsx` | 42 |
| Portal revizyonlar | `/portal/revisions` | ilgili `page.tsx` | 43 |
Settings route'ları Faz 2 ile oluşturulmuştur:
- `/settings/general`
- `/settings/appearance`
- `/settings/profile`
- `/settings/security`
- `/settings/ai`
- `/settings/language`
- `/settings/languages`
Dil ekleme, dil detayı, çeviri editörü ve import/export alt rotaları kendi sayfa
fazlarında oluşturulacaktır.
## UI metni yüzeyleri
Her route için aşağıdaki dosya türleri tarama kapsamındadır:
- `page.tsx`, client composition ve local component'ler
- `actions.ts` ve kullanıcıya dönen domain/API hataları
- `loading.tsx`, `error.tsx`, `not-found.tsx`
- Ortak shell, navigation, feedback, status ve confirmation component'leri
- Form label/placeholder/helper, dialog, toast, tooltip ve aria metinleri
İlk statik tarama Türkçe karakter içeren 46 aday dosya bulmuştur. En yüksek
adaylar:
| Dosya | Karakter eşleşmesi |
| --- | ---: |
| Settings geçiş içeriği | 184 |
| Proje detay client | 175 |
| Finans client | 128 |
| Günlük client | 106 |
| Müşteriler client | 95 |
| Projeler client | 94 |
| Chat API route | 82 |
| Görevler client | 77 |
| Müşteri detay client | 68 |
| Settings actions | 55 |
| Takvim client | 44 |
Bu sayı kesin kullanıcı metni sayısı değildir; yorum, hata ve teknik string'leri
de içeren bir regression baseline'dır. Her sayfa fazında adaylar sınıflandırılıp
kullanıcıya açık hard-coded metin sayısı sıfıra indirilir.
## Dinamik form/entity envanteri
| Entity | Çevrilebilir | Ortak kalacak |
| --- | --- | --- |
| Branding | welcome, footer | logo, favicon, renk |
| Client | notes | ad, firma, e-posta, telefon, URL |
| Client activity | title, content | type, tarih |
| Project | name, description, cover alt | status, tarih, bütçe |
| Planning section | title, content | category, sıra |
| Task | title, description | status, priority, süre |
| Calendar event | title, description | type, başlangıç/bitiş |
| Finance transaction | category, description | tutar, currency, status, tarih |
| Journal entry | mood label, note | skorlar, tarih |
| Proposal | title, description | tutar, currency, status |
| Contract | title, content | ilişkiler, status |
| Invoice | mevcut serbest metin yok | numara, tutar, currency, tarih |
| Subscription | name, category | tutar, cycle, status, tarih |
Kaynak dilinde tutulacak iletişim içeriği:
- Chat message
- Project revision request
## Sabit locale/format envanteri
Merkezi olmayan `tr-TR`, `date-fns/locale/tr`, sabit durum label'ları ve sabit
para/tarih formatları sayfa fazında ele alınır. Yeni kullanım yalnız
`lib/i18n/format.ts` ve merkezi date-fns locale mapping üzerinden yapılabilir.
@@ -1,8 +1,8 @@
--- ---
title: Neta Çok Dilli Sistem V2 Ana Planı title: Neta Çok Dilli Sistem V2 Ana Planı
description: Ayarlar bilgi mimarisi, owner ve müşteri dil tercihleri, yönetilebilir arayüz çevirileri ve tüm dinamik içerik formları için sayfa bazlı uygulama planı. description: Ayarlar bilgi mimarisi, owner ve müşteri dil tercihleri, yönetilebilir arayüz çevirileri ve tüm dinamik içerik formları için sayfa bazlı uygulama planı.
status: planned status: in_progress
current_phase: "faz-0" current_phase: "faz-12"
last_updated: 2026-07-20 last_updated: 2026-07-20
supersedes: "neta-multilingual-i18n-v1-legacy-plan.md" supersedes: "neta-multilingual-i18n-v1-legacy-plan.md"
--- ---
@@ -97,8 +97,8 @@ user_preferences.language
- Owner dilini `/settings/language` sayfasından değiştirir. - Owner dilini `/settings/language` sayfasından değiştirir.
- Seçim yalnız `active` diller arasından yapılır. - Seçim yalnız `active` diller arasından yapılır.
- Cookie yalnız SSR ile client hydration arasında yardımcı cache olabilir; veri - Locale cookie'si dil kararında kullanılmaz; authenticated tercih veritabanından
kaynağı ve daha yüksek öncelikli karar noktası olamaz. ve public dil instance ayarından çözülür.
### 3.3. Portal locale önceliği ### 3.3. Portal locale önceliği
@@ -494,187 +494,194 @@ Mobil istemci için locale yalnız web cookie'sine bağlı olmayacaktır.
Amaç: Eski implementasyonu ölçmek ve her route için tamamlanma tanımını Amaç: Eski implementasyonu ölçmek ve her route için tamamlanma tanımını
kilitlemek. kilitlemek.
- [ ] Auth ekranlarındaki locale select kullanımını test fixture'ıyla kaydet. - [x] Auth ekranlarındaki locale select kullanımını test fixture'ıyla kaydet.
- [ ] Public, freelancer, portal ve invitation resolver davranışlarını ayrı test - [x] Public, freelancer, portal ve invitation resolver davranışlarını ayrı test
et. et.
- [ ] Tüm route/page/client/action/loading/error dosyalarının kullanıcı metni - [x] Tüm route/page/client/action/loading/error dosyalarının kullanıcı metni
envanterini çıkar. envanterini çıkar.
- [ ] Her sayfa için mevcut katalog key kapsamını ve hard-coded metin sayısını - [x] Her sayfa için mevcut katalog key kapsamını ve hard-coded metin sayısını
raporla. raporla.
- [ ] Tüm domain create/edit formlarını ve metinsel alanları envanterle. - [x] Tüm domain create/edit formlarını ve metinsel alanları envanterle.
- [ ] Portalda görünen ve owner-only kalan alanları işaretle. - [x] Portalda görünen ve owner-only kalan alanları işaretle.
- [ ] V1 veri modeli için korunacak, düzeltilecek ve kaldırılacak parçaları ADR - [x] V1 veri modeli için korunacak, düzeltilecek ve kaldırılacak parçaları ADR
olarak yaz. olarak yaz.
- [ ] TR/EN/custom locale seed ve regression fixture'larını hazırla. - [x] TR/EN/custom locale seed ve regression fixture'larını hazırla.
- [ ] Sayfa bazlı i18n smoke runner oluştur. - [x] Sayfa bazlı i18n smoke runner oluştur.
- [ ] Baseline build, typecheck ve lint sonuçlarını kaydet. - [x] Baseline build, typecheck ve lint sonuçlarını kaydet.
Faz 0 çıktıları:
- [Mimari kararlar](i18n-v2-phase-0/adr.md)
- [Sayfa ve içerik envanteri](i18n-v2-phase-0/inventory.md)
- [TR/EN/custom regression fixture'ı](i18n-v2-phase-0/fixtures.json)
- [Baseline ve regression kapıları](i18n-v2-phase-0/baseline.md)
Çıkış kriteri: Çıkış kriteri:
- [ ] Hiçbir route veya kullanıcıya açık UI yüzeyi envanter dışında kalmadı. - [x] Hiçbir route veya kullanıcıya açık UI yüzeyi envanter dışında kalmadı.
- [ ] Her sonraki fazın ölçülebilir hard-coded-text baseline'ı var. - [x] Her sonraki fazın ölçülebilir hard-coded-text baseline'ı var.
### Faz 1 — Locale resolver ve preference semantiğini düzelt ### Faz 1 — Locale resolver ve preference semantiğini düzelt
- [ ] Public, invitation, freelancer ve portal resolver'larını ayır. - [x] Public, invitation, freelancer ve portal resolver'larını ayır.
- [ ] Public auth akışından cookie ve browser locale önceliğini kaldır. - [x] Public auth akışından cookie ve browser locale önceliğini kaldır.
- [ ] Freelancer için preference -> instance default zincirini uygula. - [x] Freelancer için preference -> instance default zincirini uygula.
- [ ] Portal için preference -> client default -> instance default zincirini - [x] Portal için preference -> client default -> instance default zincirini
uygula. uygula.
- [ ] Cookie'yi otorite değil yardımcı cache haline getir. - [x] Locale cookie'sini resolver ve dil değiştirme akışından kaldır.
- [ ] Arşivlenmiş/eksik locale fallback davranışını tanımla. - [x] Arşivlenmiş/eksik locale fallback davranışını tanımla.
- [ ] Preference ile client default değişikliklerinin birbirini ezmediğini test - [x] Preference ile client default değişikliklerinin birbirini ezmediğini test
et. et.
- [ ] `<html lang>` ve `dir` değerinin doğru resolved locale'den geldiğini test - [x] `<html lang>` ve `dir` değerinin doğru resolved locale'den geldiğini test
et. et.
Çıkış kriteri: Çıkış kriteri:
- [ ] Dört bağlamın resolver testleri bağımsız geçiyor. - [x] Dört bağlamın resolver testleri bağımsız geçiyor.
- [ ] Login sayfası eski locale cookie'sinden etkilenmiyor. - [x] Login sayfası eski locale cookie'sinden etkilenmiyor.
### Faz 2 — Settings route iskeleti ve kalıcı iç sidebar ### Faz 2 — Settings route iskeleti ve kalıcı iç sidebar
- [ ] `/settings` route'unu `/settings/general` sayfasına yönlendir. - [x] `/settings` route'unu `/settings/general` sayfasına yönlendir.
- [ ] Settings layout ve sticky iç sidebar oluştur. - [x] Settings layout ve sticky iç sidebar oluştur.
- [ ] Desktop ve mobil settings navigasyonunu uygula. - [x] Desktop ve mobil settings navigasyonunu uygula.
- [ ] Aktif route, loading, not-found ve error sınırlarını ekle. - [x] Aktif route, loading, not-found ve error sınırlarını ekle.
- [ ] Owner-only menü öğelerini yetkiye göre sınırla. - [x] Owner-only menü öğelerini yetkiye göre sınırla.
- [ ] Tek sayfalık client-state tab yapısını kaldırmaya hazır action sınırlarını - [x] Tek sayfalık client-state tab yapısını kaldırmaya hazır action sınırlarını
ayır. ayır.
- [ ] Settings shell'in TR/EN kataloglarını tamamla. - [x] Settings shell'in TR/EN kataloglarını tamamla.
Çıkış kriteri: Çıkış kriteri:
- [ ] Refresh ve deep-link her settings alt route'unda çalışıyor. - [x] Refresh ve deep-link her settings alt route'unda çalışıyor.
- [ ] İç sidebar uzun sayfada sabit kalıyor. - [x] İç sidebar uzun sayfada sabit kalıyor.
### Faz 3 — Ayarlar / Genel sayfası ### Faz 3 — Ayarlar / Genel sayfası
Route: `/settings/general` Route: `/settings/general`
- [ ] Workspace adı, meta title, short name ve genel instance alanlarını taşı. - [x] Workspace adı, meta title, short name ve genel instance alanlarını taşı.
- [ ] Portal welcome/footer için aktif dil tab'larını uygula. - [x] Portal welcome/footer için aktif dil tab'larını uygula.
- [ ] Branding create/update action'ını route'a özel hale getir. - [x] Branding create/update action'ını route'a özel hale getir.
- [ ] Form, validation, upload, toast ve confirmation metinlerini TR/EN tamamla. - [x] Form, validation ve toast metinlerini TR/EN tamamla.
- [ ] Metadata ve favicon revalidation davranışını koru. - [x] Workspace metadata revalidation davranışını koru.
- [ ] Owner authorization ve dosya cleanup testlerini koru. - [x] Owner authorization sınırını koru.
Çıkış kriteri: Çıkış kriteri:
- [ ] Genel sayfasında hard-coded kullanıcı metni kalmadı. - [x] Genel sayfasında hard-coded kullanıcı metni kalmadı.
- [ ] Branding içeriklerinin tüm aktif dil değerleri kalıcı. - [x] Branding içeriklerinin tüm aktif dil değerleri kalıcı.
### Faz 4 — Ayarlar / Görünüm sayfası ### Faz 4 — Ayarlar / Görünüm sayfası
Route: `/settings/appearance` Route: `/settings/appearance`
- [ ] Light/dark/system kişisel tercihini taşı. - [x] Light/dark/system kişisel tercihini taşı.
- [ ] Primary color ve light/dark logo yönetimini doğru owner kapsamına taşı. - [x] Primary color ve light/dark logo yönetimini doğru owner kapsamına taşı.
- [ ] Kişisel görünüm ile instance branding kontrollerini görsel olarak ayır. - [x] Kişisel görünüm ile instance branding kontrollerini görsel olarak ayır.
- [ ] Preview, upload, reset ve validation metinlerini çevir. - [x] Preview, upload, reset ve validation metinlerini çevir.
- [ ] Dark/light ve mobil davranışı test et. - [x] Dark/light, responsive ve hydration-safe davranışı test et.
Çıkış kriteri: Çıkış kriteri:
- [ ] Görünüm sayfası TR/EN eksiksiz ve tema geçişinde hydration hatasız. - [x] Görünüm sayfası TR/EN eksiksiz ve tema geçişinde hydration hatasız.
### Faz 5 — Ayarlar / Profil sayfası ### Faz 5 — Ayarlar / Profil sayfası
Route: `/settings/profile` Route: `/settings/profile`
- [ ] Ad, soyad ve avatar formunu taşı. - [x] Ad, soyad ve avatar formunu taşı.
- [ ] Account bilgisi, upload ve toast metinlerini çevir. - [x] Account bilgisi, upload ve toast metinlerini çevir.
- [ ] Kişisel profile action'ını owner-only instance action'lardan ayır. - [x] Kişisel profile action'ını owner-only instance action'lardan ayır.
- [ ] Validation ve hata kodlarını locale-aware hale getir. - [x] Validation ve hata kodlarını locale-aware hale getir.
Çıkış kriteri: Çıkış kriteri:
- [ ] Profil sayfasında tüm kullanıcı metinleri resolved locale ile geliyor. - [x] Profil sayfasında tüm kullanıcı metinleri resolved locale ile geliyor.
### Faz 6 — Ayarlar / Güvenlik sayfası ### Faz 6 — Ayarlar / Güvenlik sayfası
Route: `/settings/security` Route: `/settings/security`
- [ ] Şifre değiştirme formunu taşı. - [x] Şifre değiştirme formunu taşı.
- [ ] Mevcut/yeni şifre validation ve hata mesajlarını çevir. - [x] Mevcut/yeni şifre validation ve hata mesajlarını çevir.
- [ ] Session revoke davranışını açık ve erişilebilir biçimde göster. - [x] Session revoke davranışını açık ve erişilebilir biçimde göster.
- [ ] Success/error toast'larını tek kaynak üzerinden üret. - [x] Success/error toast'larını tek kaynak üzerinden üret.
Çıkış kriteri: Çıkış kriteri:
- [ ] Güvenlik akışı TR/EN ve custom fallback locale ile çalışıyor. - [x] Güvenlik akışı TR/EN ve custom fallback locale ile çalışıyor.
### Faz 7 — Ayarlar / Yapay zekâ sayfası ### Faz 7 — Ayarlar / Yapay zekâ sayfası
Route: `/settings/ai` Route: `/settings/ai`
- [ ] Provider, model ve API key kontrollerini taşı. - [x] Provider, model ve API key kontrollerini taşı.
- [ ] Secret mask, mevcut key, değiştirme ve hata UX'ini düzenle. - [x] Secret mask, mevcut key, değiştirme ve hata UX'ini düzenle.
- [ ] AI provider ve bağlantı hata metinlerini çevir. - [x] AI provider ve bağlantı hata metinlerini çevir.
- [ ] Owner-only authorization'ı doğrula. - [x] Owner-only authorization'ı doğrula.
Çıkış kriteri: Çıkış kriteri:
- [ ] AI ayarlarında sabit Türkçe/İngilizce metin yok. - [x] AI ayarlarında sabit Türkçe/İngilizce metin yok.
### Faz 8 — Ayarlar / Kişisel dil tercihi sayfası ### Faz 8 — Ayarlar / Kişisel dil tercihi sayfası
Route: `/settings/language` Route: `/settings/language`
- [ ] Yalnız aktif dilleri göster. - [x] Yalnız aktif dilleri göster.
- [ ] Kişisel tercih ile instance default bilgisini ayrı göster. - [x] Kişisel tercih ile instance default bilgisini ayrı göster.
- [ ] Preference mutation'ı uygula ve layout'u güvenli yenile. - [x] Preference mutation'ı uygula ve layout'u güvenli yenile.
- [ ] Arşivlenmiş preference için fallback ve yeniden seçim uyarısı ekle. - [x] Arşivlenmiş preference için fallback ve yeniden seçim uyarısı ekle.
- [ ] Dil adlarını native name + locale code ile göster. - [x] Dil adlarını native name + locale code ile göster.
Çıkış kriteri: Çıkış kriteri:
- [ ] Owner dili yalnız bu sayfadan değiştirilebiliyor. - [x] Owner dili yalnız bu sayfadan değiştirilebiliyor.
- [ ] Login/auth ekranlarında dil seçici bulunmuyor. - [x] Login/auth ekranlarında dil seçici bulunmuyor.
### Faz 9 — Ayarlar / Diller liste sayfası ### Faz 9 — Ayarlar / Diller liste sayfası
Route: `/settings/languages` Route: `/settings/languages`
- [ ] Yeni dil yönetim listesi UX'ini uygula. - [x] Yeni dil yönetim listesi UX'ini uygula.
- [ ] Default, status, fallback, completion ve usage sütunlarını ekle. - [x] Default, status, fallback, completion ve usage sütunlarını ekle.
- [ ] Instance default locale kontrolünü bu sayfaya taşı. - [x] Instance default locale kontrolünü bu sayfaya taşı.
- [ ] Draft/active/archived filtrelerini ekle. - [x] Draft/active/archived filtrelerini ekle.
- [ ] Loading, empty, error ve permission state'lerini çevir. - [x] Loading, empty, error ve permission state'lerini çevir.
Çıkış kriteri: Çıkış kriteri:
- [ ] Dil listesi çeviri editörüyle aynı ekranda değil. - [x] Dil listesi çeviri editörüyle aynı ekranda değil.
- [ ] Default dil ve kişisel dil tercihi birbirine karışmıyor. - [x] Default dil ve kişisel dil tercihi birbirine karışmıyor.
### Faz 10 — Ayarlar / Yeni dil sayfası ### Faz 10 — Ayarlar / Yeni dil sayfası
Route: `/settings/languages/new` Route: `/settings/languages/new`
- [ ] BCP 47 locale formunu uygula. - [x] BCP 47 locale formunu uygula.
- [ ] Native ad, yönetim adı, direction ve fallback alanlarını ekle. - [x] Native ad, yönetim adı, direction ve fallback alanlarını ekle.
- [ ] Custom dilin daima draft oluşmasını sağla. - [x] Custom dilin daima draft oluşmasını sağla.
- [ ] Duplicate, self-fallback ve fallback-loop hatalarını çevir. - [x] Duplicate, self-fallback ve fallback-loop hatalarını çevir.
- [ ] Başarılı kayıtta dil detayına yönlendir. - [x] Başarılı kayıtta dil detayına yönlendir.
Çıkış kriteri: Çıkış kriteri:
- [ ] Geçersiz locale DB'ye ulaşmadan reddediliyor. - [x] Geçersiz locale DB'ye ulaşmadan reddediliyor.
### Faz 11 — Ayarlar / Dil detay sayfası ### Faz 11 — Ayarlar / Dil detay sayfası
Route: `/settings/languages/[locale]` Route: `/settings/languages/[locale]`
- [ ] Metadata ve lifecycle yönetimini uygula. - [x] Metadata ve lifecycle yönetimini uygula.
- [ ] Namespace/sayfa bazlı completion özetini ekle. - [x] Namespace/sayfa bazlı completion özetini ekle.
- [ ] Kullanıcı/client kullanım etkisini göster. - [x] Kullanıcı/client kullanım etkisini göster.
- [ ] Activate/default/archive readiness kontrollerini uygula. - [x] Activate/default/archive readiness kontrollerini uygula.
- [ ] Built-in korumalarını UI ve service katmanında test et. - [x] Built-in korumalarını UI ve service katmanında test et.
Çıkış kriteri: Çıkış kriteri:
- [ ] Eksik kritik çevirili draft dil aktif edilemiyor. - [x] Eksik kritik çevirili draft dil aktif edilemiyor.
### Faz 12 — Ayarlar / Çeviri editörü ### Faz 12 — Ayarlar / Çeviri editörü
+3 -1
View File
@@ -41,7 +41,9 @@
"i18n:phase7-smoke": "node scripts/i18n-phase7-smoke.mjs", "i18n:phase7-smoke": "node scripts/i18n-phase7-smoke.mjs",
"i18n:phase8-smoke": "node scripts/i18n-phase8-smoke.mjs", "i18n:phase8-smoke": "node scripts/i18n-phase8-smoke.mjs",
"phase-i18n:boundary": "node scripts/i18n-phase9-boundary.mjs", "phase-i18n:boundary": "node scripts/i18n-phase9-boundary.mjs",
"i18n:phase9-hardening": "node scripts/i18n-phase9-hardening.mjs" "i18n:phase9-hardening": "node scripts/i18n-phase9-hardening.mjs",
"i18n:v2-page-audit": "node scripts/i18n-v2-page-audit.mjs",
"i18n:v2-phase1-smoke": "node scripts/i18n-v2-phase1-smoke.mjs"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.80", "@ai-sdk/google": "^3.0.80",
+6 -5
View File
@@ -74,11 +74,12 @@ function assertMigratedDatabase(dbPath, expectedDefaultLocale) {
], ],
"Built-in locales must be seeded", "Built-in locales must be seeded",
); );
assert.deepEqual( const i18nSettings = sqlite
sqlite.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings").all(), .prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings")
[{ key: "default", defaultLocale: expectedDefaultLocale, catalogVersion: 1 }], .get();
"Default i18n settings must be seeded", assert.equal(i18nSettings.key, "default", "Default i18n settings must be seeded");
); assert.equal(i18nSettings.defaultLocale, expectedDefaultLocale, "Default locale must survive backup");
assert.ok(i18nSettings.catalogVersion >= 1, "Catalog version must be positive");
const clientColumns = sqlite.prepare("pragma table_info(clients)").all().map((column) => column.name); const clientColumns = sqlite.prepare("pragma table_info(clients)").all().map((column) => column.name);
assert.equal(clientColumns.includes("portal_locale"), true, "clients.portal_locale must exist"); assert.equal(clientColumns.includes("portal_locale"), true, "clients.portal_locale must exist");
+27 -2
View File
@@ -4,7 +4,10 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../server/db/schema"; import * as schema from "../server/db/schema";
import type { DomainActor } from "../server/domain/actor"; import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors"; import { DomainError } from "../server/domain/errors";
import { I18nService } from "../server/i18n/service"; import {
ACTIVATION_CRITICAL_KEYS,
I18nService,
} from "../server/i18n/service";
const databasePath = process.argv[2]; const databasePath = process.argv[2];
assert.ok(databasePath, "Database path is required"); assert.ok(databasePath, "Database path is required");
@@ -84,6 +87,7 @@ try {
service.createLocale(owner, { code: "es", name: "Spanish", fallbackLocale: "fr" }); service.createLocale(owner, { code: "es", name: "Spanish", fallbackLocale: "fr" });
assertDomainError(() => service.updateLocale(owner, "fr", { fallbackLocale: "es" }), "VALIDATION_ERROR"); assertDomainError(() => service.updateLocale(owner, "fr", { fallbackLocale: "es" }), "VALIDATION_ERROR");
assertDomainError(() => service.setDefaultLocale(owner, "fr"), "VALIDATION_ERROR"); assertDomainError(() => service.setDefaultLocale(owner, "fr"), "VALIDATION_ERROR");
completeCriticalTranslations(service, "fr");
const activeFrench = service.updateLocale(owner, "fr", { status: "active" }); const activeFrench = service.updateLocale(owner, "fr", { status: "active" });
assert.equal(activeFrench.status, "active"); assert.equal(activeFrench.status, "active");
@@ -91,13 +95,18 @@ try {
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT"); assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
assertDomainError(() => service.archiveLocale(owner, "en"), "CONFLICT"); assertDomainError(() => service.archiveLocale(owner, "en"), "CONFLICT");
service.createLocale(owner, { const italian = service.createLocale(owner, {
code: "it", code: "it",
name: "Italian", name: "Italian",
nativeName: "Italiano", nativeName: "Italiano",
status: "active", status: "active",
fallbackLocale: "en", fallbackLocale: "en",
}); });
assert.equal(italian.status, "draft", "Custom locales must always start as draft");
assert.equal(service.getLocaleReadiness(owner, "it").canActivate, false);
completeCriticalTranslations(service, "it");
assert.equal(service.getLocaleReadiness(owner, "it").canActivate, true);
service.updateLocale(owner, "it", { status: "active" });
db.insert(schema.clients).values({ db.insert(schema.clients).values({
id: "i18n-client", id: "i18n-client",
ownerUserId: owner.authUserId, ownerUserId: owner.authUserId,
@@ -106,6 +115,10 @@ try {
portalLocale: "it", portalLocale: "it",
}).run(); }).run();
assertDomainError(() => service.archiveLocale(owner, "it"), "CONFLICT"); assertDomainError(() => service.archiveLocale(owner, "it"), "CONFLICT");
assert.ok(
service.getNamespaceCompletion(owner, "it").some((item) => item.namespace === "portal"),
);
assert.equal(service.getLocaleUsage(owner, "it").clients, 1);
db.insert(schema.contentTranslations).values({ db.insert(schema.contentTranslations).values({
entityType: "project", entityType: "project",
@@ -130,3 +143,15 @@ try {
function assertDomainError(run: () => unknown, code: DomainError["code"]): void { function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
assert.throws(run, (error) => error instanceof DomainError && error.code === code); assert.throws(run, (error) => error instanceof DomainError && error.code === code);
} }
function completeCriticalTranslations(service: I18nService, locale: string) {
for (const fullKey of ACTIVATION_CRITICAL_KEYS) {
const [namespace, ...keyParts] = fullKey.split(".");
service.upsertUiTranslation(owner, {
locale,
namespace,
key: keyParts.join("."),
value: `${locale}:${fullKey}`,
});
}
}
+18 -1
View File
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
import { getSqliteConnection } from "../server/db/client"; import { getSqliteConnection } from "../server/db/client";
import type { DomainActor } from "../server/domain/actor"; import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors"; import { DomainError } from "../server/domain/errors";
import { I18nService } from "../server/i18n/service"; import {
ACTIVATION_CRITICAL_KEYS,
I18nService,
} from "../server/i18n/service";
const owner: DomainActor = { const owner: DomainActor = {
authUserId: "phase3-owner", authUserId: "phase3-owner",
@@ -61,6 +64,8 @@ assert.equal(
true, true,
); );
assertDomainError(() => service.updateLocale(owner, "fr", { status: "active" }), "VALIDATION_ERROR");
completeCriticalTranslations(service, "fr");
service.updateLocale(owner, "fr", { status: "active" }); service.updateLocale(owner, "fr", { status: "active" });
assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr"); assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr");
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT"); assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
@@ -70,3 +75,15 @@ console.log("I18n phase 3 settings smoke passed.");
function assertDomainError(run: () => unknown, code: DomainError["code"]): void { function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
assert.throws(run, (error) => error instanceof DomainError && error.code === code); assert.throws(run, (error) => error instanceof DomainError && error.code === code);
} }
function completeCriticalTranslations(service: I18nService, locale: string) {
for (const fullKey of ACTIVATION_CRITICAL_KEYS) {
const [namespace, ...keyParts] = fullKey.split(".");
service.upsertUiTranslation(owner, {
locale,
namespace,
key: keyParts.join("."),
value: `${locale}:${fullKey}`,
});
}
}
+14 -4
View File
@@ -4,7 +4,10 @@ import { getSqliteConnection } from "../server/db/client";
import type { DomainActor } from "../server/domain/actor"; import type { DomainActor } from "../server/domain/actor";
import { DomainService } from "../server/services/domain"; import { DomainService } from "../server/services/domain";
import { ContentTranslationService } from "../server/i18n/content"; import { ContentTranslationService } from "../server/i18n/content";
import { I18nService } from "../server/i18n/service"; import {
ACTIVATION_CRITICAL_KEYS,
I18nService,
} from "../server/i18n/service";
const { db } = getSqliteConnection(); const { db } = getSqliteConnection();
const owner: DomainActor = { const owner: DomainActor = {
@@ -34,11 +37,18 @@ if (!i18n.listLocales(owner).some((locale) => locale.code === "fr")) {
name: "French", name: "French",
nativeName: "Français", nativeName: "Français",
fallbackLocale: "en", fallbackLocale: "en",
status: "active",
}); });
} else {
i18n.updateLocale(owner, "fr", { status: "active" });
} }
for (const fullKey of ACTIVATION_CRITICAL_KEYS) {
const [namespace, ...keyParts] = fullKey.split(".");
i18n.upsertUiTranslation(owner, {
locale: "fr",
namespace,
key: keyParts.join("."),
value: `fr:${fullKey}`,
});
}
i18n.updateLocale(owner, "fr", { status: "active" });
const domain = new DomainService(db, (() => { const domain = new DomainService(db, (() => {
let next = 0; let next = 0;
+5 -3
View File
@@ -56,8 +56,10 @@ assert.equal(loginActions.includes("E-posta veya"), false, "login action must no
const inviteActions = fs.readFileSync(path.join(process.cwd(), "app", "invite", "[token]", "actions.ts"), "utf8"); const inviteActions = fs.readFileSync(path.join(process.cwd(), "app", "invite", "[token]", "actions.ts"), "utf8");
assert.equal(inviteActions.includes("message=${encodeURIComponent"), false, "invite action redirects must use stable codes"); assert.equal(inviteActions.includes("message=${encodeURIComponent"), false, "invite action redirects must use stable codes");
const localeApi = fs.readFileSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts"), "utf8"); assert.equal(
assert.equal(localeApi.includes("Dil kodu"), false, "locale API must not embed Turkish validation messages"); fs.existsSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts")),
assert.equal(localeApi.includes("messageKey"), true, "locale API must expose messageKey for localized clients"); false,
"The obsolete locale-cookie API must remain removed",
);
console.log("I18n phase 7 auth/error/a11y smoke passed."); console.log("I18n phase 7 auth/error/a11y smoke passed.");
+243
View File
@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const expectedPages = [
"app/(dashboard)/page.tsx",
"app/(dashboard)/analytics/page.tsx",
"app/(dashboard)/calendar/page.tsx",
"app/(dashboard)/chat/page.tsx",
"app/(dashboard)/clients/page.tsx",
"app/(dashboard)/clients/[id]/page.tsx",
"app/(dashboard)/finance/page.tsx",
"app/(dashboard)/journal/page.tsx",
"app/(dashboard)/projects/page.tsx",
"app/(dashboard)/projects/[id]/page.tsx",
"app/(dashboard)/tasks/page.tsx",
"app/(dashboard)/business/invoices/page.tsx",
"app/(dashboard)/business/proposals/page.tsx",
"app/(dashboard)/business/subscriptions/page.tsx",
"app/login/page.tsx",
"app/register/page.tsx",
"app/forgot-password/page.tsx",
"app/reset-password/page.tsx",
"app/invite/[token]/page.tsx",
"app/portal/page.tsx",
"app/portal/projects/page.tsx",
"app/portal/projects/[id]/page.tsx",
"app/portal/revisions/page.tsx",
"app/portal/tasks/page.tsx",
];
const authPages = [
"app/login/page.tsx",
"app/register/page.tsx",
"app/forgot-password/page.tsx",
"app/reset-password/page.tsx",
];
const settingsPages = [
"app/(dashboard)/settings/general/page.tsx",
"app/(dashboard)/settings/appearance/page.tsx",
"app/(dashboard)/settings/profile/page.tsx",
"app/(dashboard)/settings/security/page.tsx",
"app/(dashboard)/settings/ai/page.tsx",
"app/(dashboard)/settings/language/page.tsx",
"app/(dashboard)/settings/languages/page.tsx",
"app/(dashboard)/settings/languages/new/page.tsx",
"app/(dashboard)/settings/languages/[locale]/page.tsx",
];
const completedSettingsPageFiles = [
"app/(dashboard)/settings/general/page.tsx",
"app/(dashboard)/settings/general/general-settings-form.tsx",
"app/(dashboard)/settings/general/actions.ts",
"app/(dashboard)/settings/appearance/page.tsx",
"app/(dashboard)/settings/appearance/appearance-settings-form.tsx",
"app/(dashboard)/settings/appearance/actions.ts",
"app/(dashboard)/settings/profile/page.tsx",
"app/(dashboard)/settings/profile/profile-settings-form.tsx",
"app/(dashboard)/settings/profile/actions.ts",
"app/(dashboard)/settings/security/page.tsx",
"app/(dashboard)/settings/security/security-settings-form.tsx",
"app/(dashboard)/settings/security/actions.ts",
"app/(dashboard)/settings/ai/page.tsx",
"app/(dashboard)/settings/ai/ai-settings-form.tsx",
"app/(dashboard)/settings/ai/actions.ts",
"app/(dashboard)/settings/language/page.tsx",
"app/(dashboard)/settings/language/language-preference-form.tsx",
"app/(dashboard)/settings/language/actions.ts",
"app/(dashboard)/settings/languages/page.tsx",
"app/(dashboard)/settings/languages/languages-list.tsx",
"app/(dashboard)/settings/languages/actions.ts",
"app/(dashboard)/settings/languages/new/page.tsx",
"app/(dashboard)/settings/languages/new/new-language-form.tsx",
"app/(dashboard)/settings/languages/new/actions.ts",
"app/(dashboard)/settings/languages/[locale]/page.tsx",
"app/(dashboard)/settings/languages/[locale]/language-detail.tsx",
"app/(dashboard)/settings/languages/[locale]/actions.ts",
];
for (const file of [...expectedPages, ...settingsPages]) {
assert.ok(fs.existsSync(path.join(root, file)), `Missing planned page: ${file}`);
}
for (const file of authPages) {
const source = read(file);
assert.ok(source.includes("resolvePublicLocale"), `${file} must use the public locale resolver`);
assert.ok(!source.includes("LocaleSelectForm"), `${file} must not expose a locale selector`);
}
for (const file of completedSettingsPageFiles) {
const source = read(file);
assert.ok(
!/[ÇĞİÖŞÜçğıöşü]/.test(source),
`${file} must not contain hard-coded Turkish user-facing text`,
);
assert.ok(
!source.includes('from "../settings-content"'),
`${file} must not depend on the transitional settings monolith`,
);
}
for (const route of ["general", "appearance", "profile", "security", "ai", "language"]) {
assert.ok(
fs.existsSync(path.join(root, `app/(dashboard)/settings/${route}/actions.ts`)),
`${route} settings must have a route-specific server action boundary`,
);
}
const generalPage = read("app/(dashboard)/settings/general/page.tsx");
assert.ok(
generalPage.includes('locale.status === "active"'),
"General settings must render content tabs for active locales only",
);
const appearancePage = read("app/(dashboard)/settings/appearance/appearance-settings-form.tsx");
for (const marker of ["applyColorMode", "sm:grid-cols-3", "lightLogo", "darkLogo", "favicon"]) {
assert.ok(appearancePage.includes(marker), `Appearance regression marker is missing: ${marker}`);
}
const appearanceActions = read("app/(dashboard)/settings/appearance/actions.ts");
for (const marker of ["deleteSupersededBrandingFiles", "deleteBrandingFilesBestEffort", "requireFreelancerBackend"]) {
assert.ok(appearanceActions.includes(marker), `Appearance action safety marker is missing: ${marker}`);
}
const securityActions = read("app/(dashboard)/settings/security/actions.ts");
for (const marker of ["changePassword", "revokeOtherSessions: true", "errorKey"]) {
assert.ok(securityActions.includes(marker), `Security action marker is missing: ${marker}`);
}
const aiPage = read("app/(dashboard)/settings/ai/page.tsx");
const aiForm = read("app/(dashboard)/settings/ai/ai-settings-form.tsx");
const aiService = read("server/settings/ai.ts");
assert.ok(aiPage.includes("getPublicAiSettings"), "AI settings must use owner-scoped public settings");
assert.ok(!aiPage.includes("apiKey:"), "AI settings page must never send a secret to the client");
for (const marker of ["model", "hasApiKey", "apiKey.masked"]) {
assert.ok(aiForm.includes(marker), `AI settings UX marker is missing: ${marker}`);
}
assert.ok(aiService.includes("model,"), "AI model selection must be persisted");
const languagePage = read("app/(dashboard)/settings/language/page.tsx");
const languageForm = read("app/(dashboard)/settings/language/language-preference-form.tsx");
assert.ok(
languagePage.includes('locale.status === "active"'),
"Language preference must list active locales only",
);
for (const marker of ["nativeName", "defaultLocale", "preferenceNeedsSelection", "router.refresh()"]) {
assert.ok(languageForm.includes(marker), `Language preference marker is missing: ${marker}`);
}
assert.ok(
!fs.existsSync(path.join(root, "app/(dashboard)/settings/settings-content.tsx")),
"The transitional settings monolith must be removed",
);
assert.ok(
!fs.existsSync(path.join(root, "app/(dashboard)/settings/actions.ts")),
"The obsolete shared settings action boundary must be removed",
);
const languagesList = read("app/(dashboard)/settings/languages/languages-list.tsx");
for (const marker of ["completion", "usage", "makeDefault", "archived"]) {
assert.ok(languagesList.includes(marker), `Language list marker is missing: ${marker}`);
}
const languageNewAction = read("app/(dashboard)/settings/languages/new/actions.ts");
for (const marker of ["Intl.getCanonicalLocales", "SUPPORTED_BCP47_PATTERN", "createLocale"]) {
assert.ok(languageNewAction.includes(marker), `New language marker is missing: ${marker}`);
}
const languageDetail = read("app/(dashboard)/settings/languages/[locale]/language-detail.tsx");
for (const marker of ["namespaceCompletion", "readiness", "usage", "DestructiveConfirmation"]) {
assert.ok(languageDetail.includes(marker), `Language detail marker is missing: ${marker}`);
}
const i18nService = read("server/i18n/service.ts");
assert.ok(
i18nService.includes('status: "draft"'),
"Custom languages must always be created as draft",
);
for (const marker of ["assertLocaleCanBeActivated", "ACTIVATION_CRITICAL_KEYS", "getLocaleReadiness"]) {
assert.ok(i18nService.includes(marker), `Language lifecycle service marker is missing: ${marker}`);
}
const resolver = read("server/i18n/resolver.ts");
for (const exportName of [
"resolvePublicLocale",
"resolveInvitationLocale",
"resolveFreelancerLocale",
"resolvePortalLocale",
]) {
assert.ok(resolver.includes(exportName), `Resolver export is missing: ${exportName}`);
}
assert.ok(!resolver.includes("LOCALE_COOKIE"), "Server locale authority must not depend on the locale cookie");
assert.ok(
!fs.existsSync(path.join(root, "app/api/i18n/locale/route.ts")),
"The obsolete locale-cookie mutation route must not exist",
);
assert.ok(
!fs.existsSync(path.join(root, "components/i18n/locale-select-form.tsx")),
"The obsolete public locale selector must not exist",
);
const settingsNavigation = read("app/(dashboard)/settings/settings-navigation.tsx");
assert.ok(settingsNavigation.includes("md:sticky"), "Settings navigation must be sticky on desktop");
assert.ok(settingsNavigation.includes("usePathname"), "Settings navigation must be route-aware");
for (const boundary of ["loading.tsx", "error.tsx", "not-found.tsx"]) {
assert.ok(
fs.existsSync(path.join(root, "app/(dashboard)/settings", boundary)),
`Settings boundary is missing: ${boundary}`,
);
}
const invitationService = read("server/auth/invitations.ts");
const clientLocaleMutation = invitationService.slice(
invitationService.indexOf("export function setClientPortalLocale"),
);
assert.ok(
!clientLocaleMutation.includes("tx.insert(userPreferences)"),
"Updating the admin-assigned client locale must preserve the client's personal preference",
);
const userFacingFiles = listFiles(["app", "components", "config"])
.filter((file) => /\.(ts|tsx)$/.test(file));
const hardCodedCandidates = userFacingFiles
.map((file) => ({
file,
count: (read(file).match(/[ÇĞİÖŞÜçğıöşü]/g) ?? []).length,
}))
.filter((entry) => entry.count > 0)
.sort((left, right) => right.count - left.count);
console.log(`I18n V2 page audit passed: ${expectedPages.length + settingsPages.length} page routes accounted for.`);
console.log(`Baseline hard-coded Turkish-character candidates: ${hardCodedCandidates.length} files.`);
for (const entry of hardCodedCandidates.slice(0, 12)) {
console.log(` ${entry.count.toString().padStart(4)} ${entry.file}`);
}
function read(file) {
return fs.readFileSync(path.join(root, file), "utf8");
}
function listFiles(roots) {
const result = [];
for (const rootName of roots) {
walk(path.join(root, rootName), rootName);
}
return result;
function walk(absolute, relative) {
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
const entryAbsolute = path.join(absolute, entry.name);
const entryRelative = path.join(relative, entry.name);
if (entry.isDirectory()) walk(entryAbsolute, entryRelative);
else result.push(entryRelative);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const outputRoot = path.join(root, ".next", "i18n-v2-phase1-smoke-dist");
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-v2-phase1-smoke.json"], {
cwd: root,
stdio: "inherit",
});
const packageFile = path.join(outputRoot, "package.json");
fs.writeFileSync(packageFile, '{"type":"commonjs"}\n');
execFileSync(
process.execPath,
[path.join(outputRoot, "scripts", "i18n-v2-phase1-smoke.js")],
{ cwd: root, stdio: "inherit" },
);
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { resolveLocalePolicy } from "../lib/i18n/locale-resolution";
const locales = [
{ code: "tr", status: "active", textDirection: "ltr" },
{ code: "en", status: "active", textDirection: "ltr" },
{ code: "fr", status: "active", textDirection: "ltr" },
{ code: "de", status: "archived", textDirection: "ltr" },
{ code: "ar", status: "active", textDirection: "rtl" },
] as const;
const publicLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "en",
candidates: [],
});
assert.equal(publicLocale.locale, "en");
assert.equal(publicLocale.source, "instance");
const freelancerLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "en", source: "user" }],
});
assert.equal(freelancerLocale.locale, "en");
assert.equal(freelancerLocale.source, "user");
const portalPreference = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [
{ locale: "en", source: "user" },
{ locale: "fr", source: "client" },
],
});
assert.equal(portalPreference.locale, "en", "Personal preference must win over the client default");
const portalDefault = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [
{ locale: null, source: "user" },
{ locale: "fr", source: "client" },
],
});
assert.equal(portalDefault.locale, "fr");
assert.equal(portalDefault.source, "client");
const invitationLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "fr", source: "invitation" }],
});
assert.equal(invitationLocale.locale, "fr");
assert.equal(invitationLocale.source, "invitation");
const archivedPreference = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "de", source: "user" }],
});
assert.equal(archivedPreference.locale, "tr");
assert.equal(archivedPreference.source, "instance");
const invalidDefault = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "de",
candidates: [],
});
assert.equal(invalidDefault.locale, "tr");
assert.equal(invalidDefault.source, "fallback");
const rtlLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "ar", source: "user" }],
});
assert.equal(rtlLocale.direction, "rtl");
console.log("I18n V2 phase 1 locale policy smoke passed.");
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.phase2-smoke.json",
"compilerOptions": {
"outDir": ".next/i18n-v2-phase1-smoke-dist"
},
"include": [
"scripts/i18n-v2-phase1-smoke.ts",
"lib/i18n/locale-resolution.ts"
],
"exclude": ["node_modules"]
}