docs(i18n): update master plan and add v2 phase 0/1 smoke tests
This commit is contained in:
@@ -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ı iç
|
||||
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ı
|
||||
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
|
||||
current_phase: "faz-0"
|
||||
status: in_progress
|
||||
current_phase: "faz-12"
|
||||
last_updated: 2026-07-20
|
||||
supersedes: "neta-multilingual-i18n-v1-legacy-plan.md"
|
||||
---
|
||||
@@ -97,8 +97,8 @@ user_preferences.language
|
||||
|
||||
- Owner dilini `/settings/language` sayfasından değiştirir.
|
||||
- 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
|
||||
kaynağı ve daha yüksek öncelikli karar noktası olamaz.
|
||||
- Locale cookie'si dil kararında kullanılmaz; authenticated tercih veritabanından
|
||||
ve public dil instance ayarından çözülür.
|
||||
|
||||
### 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ı
|
||||
kilitlemek.
|
||||
|
||||
- [ ] Auth ekranlarındaki locale select kullanımını test fixture'ıyla kaydet.
|
||||
- [ ] Public, freelancer, portal ve invitation resolver davranışlarını ayrı test
|
||||
- [x] Auth ekranlarındaki locale select kullanımını test fixture'ıyla kaydet.
|
||||
- [x] Public, freelancer, portal ve invitation resolver davranışlarını ayrı test
|
||||
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.
|
||||
- [ ] 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.
|
||||
- [ ] Tüm domain create/edit formlarını ve metinsel alanları envanterle.
|
||||
- [ ] 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] Tüm domain create/edit formlarını ve metinsel alanları envanterle.
|
||||
- [x] Portalda görünen ve owner-only kalan alanları işaretle.
|
||||
- [x] V1 veri modeli için korunacak, düzeltilecek ve kaldırılacak parçaları ADR
|
||||
olarak yaz.
|
||||
- [ ] TR/EN/custom locale seed ve regression fixture'larını hazırla.
|
||||
- [ ] Sayfa bazlı i18n smoke runner oluştur.
|
||||
- [ ] Baseline build, typecheck ve lint sonuçlarını kaydet.
|
||||
- [x] TR/EN/custom locale seed ve regression fixture'larını hazırla.
|
||||
- [x] Sayfa bazlı i18n smoke runner oluştur.
|
||||
- [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:
|
||||
|
||||
- [ ] 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] Hiçbir route veya kullanıcıya açık UI yüzeyi envanter dışında kalmadı.
|
||||
- [x] Her sonraki fazın ölçülebilir hard-coded-text baseline'ı var.
|
||||
|
||||
### Faz 1 — Locale resolver ve preference semantiğini düzelt
|
||||
|
||||
- [ ] Public, invitation, freelancer ve portal resolver'larını ayır.
|
||||
- [ ] Public auth akışından cookie ve browser locale önceliğini kaldır.
|
||||
- [ ] Freelancer için preference -> instance default zincirini uygula.
|
||||
- [ ] Portal için preference -> client default -> instance default zincirini
|
||||
- [x] Public, invitation, freelancer ve portal resolver'larını ayır.
|
||||
- [x] Public auth akışından cookie ve browser locale önceliğini kaldır.
|
||||
- [x] Freelancer için preference -> instance default zincirini uygula.
|
||||
- [x] Portal için preference -> client default -> instance default zincirini
|
||||
uygula.
|
||||
- [ ] Cookie'yi otorite değil yardımcı cache haline getir.
|
||||
- [ ] Arşivlenmiş/eksik locale fallback davranışını tanımla.
|
||||
- [ ] Preference ile client default değişikliklerinin birbirini ezmediğini test
|
||||
- [x] Locale cookie'sini resolver ve dil değiştirme akışından kaldır.
|
||||
- [x] Arşivlenmiş/eksik locale fallback davranışını tanımla.
|
||||
- [x] Preference ile client default değişikliklerinin birbirini ezmediğini test
|
||||
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.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Dört bağlamın resolver testleri bağımsız geçiyor.
|
||||
- [ ] Login sayfası eski locale cookie'sinden etkilenmiyor.
|
||||
- [x] Dört bağlamın resolver testleri bağımsız geçiyor.
|
||||
- [x] Login sayfası eski locale cookie'sinden etkilenmiyor.
|
||||
|
||||
### Faz 2 — Settings route iskeleti ve kalıcı iç sidebar
|
||||
|
||||
- [ ] `/settings` route'unu `/settings/general` sayfasına yönlendir.
|
||||
- [ ] Settings layout ve sticky iç sidebar oluştur.
|
||||
- [ ] Desktop ve mobil settings navigasyonunu uygula.
|
||||
- [ ] Aktif route, loading, not-found ve error sınırlarını ekle.
|
||||
- [ ] 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] `/settings` route'unu `/settings/general` sayfasına yönlendir.
|
||||
- [x] Settings layout ve sticky iç sidebar oluştur.
|
||||
- [x] Desktop ve mobil settings navigasyonunu uygula.
|
||||
- [x] Aktif route, loading, not-found ve error sınırlarını ekle.
|
||||
- [x] Owner-only menü öğelerini yetkiye göre sınırla.
|
||||
- [x] Tek sayfalık client-state tab yapısını kaldırmaya hazır action sınırlarını
|
||||
ayır.
|
||||
- [ ] Settings shell'in TR/EN kataloglarını tamamla.
|
||||
- [x] Settings shell'in TR/EN kataloglarını tamamla.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Refresh ve deep-link her settings alt route'unda çalışıyor.
|
||||
- [ ] İç sidebar uzun sayfada sabit kalıyor.
|
||||
- [x] Refresh ve deep-link her settings alt route'unda çalışıyor.
|
||||
- [x] İç sidebar uzun sayfada sabit kalıyor.
|
||||
|
||||
### Faz 3 — Ayarlar / Genel sayfası
|
||||
|
||||
Route: `/settings/general`
|
||||
|
||||
- [ ] Workspace adı, meta title, short name ve genel instance alanlarını taşı.
|
||||
- [ ] Portal welcome/footer için aktif dil tab'larını uygula.
|
||||
- [ ] Branding create/update action'ını route'a özel hale getir.
|
||||
- [ ] Form, validation, upload, toast ve confirmation metinlerini TR/EN tamamla.
|
||||
- [ ] Metadata ve favicon revalidation davranışını koru.
|
||||
- [ ] Owner authorization ve dosya cleanup testlerini koru.
|
||||
- [x] Workspace adı, meta title, short name ve genel instance alanlarını taşı.
|
||||
- [x] Portal welcome/footer için aktif dil tab'larını uygula.
|
||||
- [x] Branding create/update action'ını route'a özel hale getir.
|
||||
- [x] Form, validation ve toast metinlerini TR/EN tamamla.
|
||||
- [x] Workspace metadata revalidation davranışını koru.
|
||||
- [x] Owner authorization sınırını koru.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Genel sayfasında hard-coded kullanıcı metni kalmadı.
|
||||
- [ ] Branding içeriklerinin tüm aktif dil değerleri kalıcı.
|
||||
- [x] Genel sayfasında hard-coded kullanıcı metni kalmadı.
|
||||
- [x] Branding içeriklerinin tüm aktif dil değerleri kalıcı.
|
||||
|
||||
### Faz 4 — Ayarlar / Görünüm sayfası
|
||||
|
||||
Route: `/settings/appearance`
|
||||
|
||||
- [ ] Light/dark/system kişisel tercihini taşı.
|
||||
- [ ] 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.
|
||||
- [ ] Preview, upload, reset ve validation metinlerini çevir.
|
||||
- [ ] Dark/light ve mobil davranışı test et.
|
||||
- [x] Light/dark/system kişisel tercihini taşı.
|
||||
- [x] Primary color ve light/dark logo yönetimini doğru owner kapsamına taşı.
|
||||
- [x] Kişisel görünüm ile instance branding kontrollerini görsel olarak ayır.
|
||||
- [x] Preview, upload, reset ve validation metinlerini çevir.
|
||||
- [x] Dark/light, responsive ve hydration-safe davranışı test et.
|
||||
|
||||
Çı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ı
|
||||
|
||||
Route: `/settings/profile`
|
||||
|
||||
- [ ] Ad, soyad ve avatar formunu taşı.
|
||||
- [ ] Account bilgisi, upload ve toast metinlerini çevir.
|
||||
- [ ] Kişisel profile action'ını owner-only instance action'lardan ayır.
|
||||
- [ ] Validation ve hata kodlarını locale-aware hale getir.
|
||||
- [x] Ad, soyad ve avatar formunu taşı.
|
||||
- [x] Account bilgisi, upload ve toast metinlerini çevir.
|
||||
- [x] Kişisel profile action'ını owner-only instance action'lardan ayır.
|
||||
- [x] Validation ve hata kodlarını locale-aware hale getir.
|
||||
|
||||
Çı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ı
|
||||
|
||||
Route: `/settings/security`
|
||||
|
||||
- [ ] Şifre değiştirme formunu taşı.
|
||||
- [ ] Mevcut/yeni şifre validation ve hata mesajlarını çevir.
|
||||
- [ ] Session revoke davranışını açık ve erişilebilir biçimde göster.
|
||||
- [ ] Success/error toast'larını tek kaynak üzerinden üret.
|
||||
- [x] Şifre değiştirme formunu taşı.
|
||||
- [x] Mevcut/yeni şifre validation ve hata mesajlarını çevir.
|
||||
- [x] Session revoke davranışını açık ve erişilebilir biçimde göster.
|
||||
- [x] Success/error toast'larını tek kaynak üzerinden üret.
|
||||
|
||||
Çı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ı
|
||||
|
||||
Route: `/settings/ai`
|
||||
|
||||
- [ ] Provider, model ve API key kontrollerini taşı.
|
||||
- [ ] Secret mask, mevcut key, değiştirme ve hata UX'ini düzenle.
|
||||
- [ ] AI provider ve bağlantı hata metinlerini çevir.
|
||||
- [ ] Owner-only authorization'ı doğrula.
|
||||
- [x] Provider, model ve API key kontrollerini taşı.
|
||||
- [x] Secret mask, mevcut key, değiştirme ve hata UX'ini düzenle.
|
||||
- [x] AI provider ve bağlantı hata metinlerini çevir.
|
||||
- [x] Owner-only authorization'ı doğrula.
|
||||
|
||||
Çı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ı
|
||||
|
||||
Route: `/settings/language`
|
||||
|
||||
- [ ] Yalnız aktif dilleri göster.
|
||||
- [ ] Kişisel tercih ile instance default bilgisini ayrı göster.
|
||||
- [ ] Preference mutation'ı uygula ve layout'u güvenli yenile.
|
||||
- [ ] Arşivlenmiş preference için fallback ve yeniden seçim uyarısı ekle.
|
||||
- [ ] Dil adlarını native name + locale code ile göster.
|
||||
- [x] Yalnız aktif dilleri göster.
|
||||
- [x] Kişisel tercih ile instance default bilgisini ayrı göster.
|
||||
- [x] Preference mutation'ı uygula ve layout'u güvenli yenile.
|
||||
- [x] Arşivlenmiş preference için fallback ve yeniden seçim uyarısı ekle.
|
||||
- [x] Dil adlarını native name + locale code ile göster.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Owner dili yalnız bu sayfadan değiştirilebiliyor.
|
||||
- [ ] Login/auth ekranlarında dil seçici bulunmuyor.
|
||||
- [x] Owner dili yalnız bu sayfadan değiştirilebiliyor.
|
||||
- [x] Login/auth ekranlarında dil seçici bulunmuyor.
|
||||
|
||||
### Faz 9 — Ayarlar / Diller liste sayfası
|
||||
|
||||
Route: `/settings/languages`
|
||||
|
||||
- [ ] Yeni dil yönetim listesi UX'ini uygula.
|
||||
- [ ] Default, status, fallback, completion ve usage sütunlarını ekle.
|
||||
- [ ] Instance default locale kontrolünü bu sayfaya taşı.
|
||||
- [ ] Draft/active/archived filtrelerini ekle.
|
||||
- [ ] Loading, empty, error ve permission state'lerini çevir.
|
||||
- [x] Yeni dil yönetim listesi UX'ini uygula.
|
||||
- [x] Default, status, fallback, completion ve usage sütunlarını ekle.
|
||||
- [x] Instance default locale kontrolünü bu sayfaya taşı.
|
||||
- [x] Draft/active/archived filtrelerini ekle.
|
||||
- [x] Loading, empty, error ve permission state'lerini çevir.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Dil listesi çeviri editörüyle aynı ekranda değil.
|
||||
- [ ] Default dil ve kişisel dil tercihi birbirine karışmıyor.
|
||||
- [x] Dil listesi çeviri editörüyle aynı ekranda değil.
|
||||
- [x] Default dil ve kişisel dil tercihi birbirine karışmıyor.
|
||||
|
||||
### Faz 10 — Ayarlar / Yeni dil sayfası
|
||||
|
||||
Route: `/settings/languages/new`
|
||||
|
||||
- [ ] BCP 47 locale formunu uygula.
|
||||
- [ ] Native ad, yönetim adı, direction ve fallback alanlarını ekle.
|
||||
- [ ] Custom dilin daima draft oluşmasını sağla.
|
||||
- [ ] Duplicate, self-fallback ve fallback-loop hatalarını çevir.
|
||||
- [ ] Başarılı kayıtta dil detayına yönlendir.
|
||||
- [x] BCP 47 locale formunu uygula.
|
||||
- [x] Native ad, yönetim adı, direction ve fallback alanlarını ekle.
|
||||
- [x] Custom dilin daima draft oluşmasını sağla.
|
||||
- [x] Duplicate, self-fallback ve fallback-loop hatalarını çevir.
|
||||
- [x] Başarılı kayıtta dil detayına yönlendir.
|
||||
|
||||
Çı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ı
|
||||
|
||||
Route: `/settings/languages/[locale]`
|
||||
|
||||
- [ ] Metadata ve lifecycle yönetimini uygula.
|
||||
- [ ] Namespace/sayfa bazlı completion özetini ekle.
|
||||
- [ ] Kullanıcı/client kullanım etkisini göster.
|
||||
- [ ] Activate/default/archive readiness kontrollerini uygula.
|
||||
- [ ] Built-in korumalarını UI ve service katmanında test et.
|
||||
- [x] Metadata ve lifecycle yönetimini uygula.
|
||||
- [x] Namespace/sayfa bazlı completion özetini ekle.
|
||||
- [x] Kullanıcı/client kullanım etkisini göster.
|
||||
- [x] Activate/default/archive readiness kontrollerini uygula.
|
||||
- [x] Built-in korumalarını UI ve service katmanında test et.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Eksik kritik çevirili draft dil aktif edilemiyor.
|
||||
- [x] Eksik kritik çevirili draft dil aktif edilemiyor.
|
||||
|
||||
### Faz 12 — Ayarlar / Çeviri editörü
|
||||
|
||||
|
||||
+3
-1
@@ -41,7 +41,9 @@
|
||||
"i18n:phase7-smoke": "node scripts/i18n-phase7-smoke.mjs",
|
||||
"i18n:phase8-smoke": "node scripts/i18n-phase8-smoke.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": {
|
||||
"@ai-sdk/google": "^3.0.80",
|
||||
|
||||
@@ -74,11 +74,12 @@ function assertMigratedDatabase(dbPath, expectedDefaultLocale) {
|
||||
],
|
||||
"Built-in locales must be seeded",
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings").all(),
|
||||
[{ key: "default", defaultLocale: expectedDefaultLocale, catalogVersion: 1 }],
|
||||
"Default i18n settings must be seeded",
|
||||
);
|
||||
const i18nSettings = sqlite
|
||||
.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings")
|
||||
.get();
|
||||
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);
|
||||
assert.equal(clientColumns.includes("portal_locale"), true, "clients.portal_locale must exist");
|
||||
|
||||
@@ -4,7 +4,10 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "../server/db/schema";
|
||||
import type { DomainActor } from "../server/domain/actor";
|
||||
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];
|
||||
assert.ok(databasePath, "Database path is required");
|
||||
@@ -84,6 +87,7 @@ try {
|
||||
service.createLocale(owner, { code: "es", name: "Spanish", fallbackLocale: "fr" });
|
||||
assertDomainError(() => service.updateLocale(owner, "fr", { fallbackLocale: "es" }), "VALIDATION_ERROR");
|
||||
assertDomainError(() => service.setDefaultLocale(owner, "fr"), "VALIDATION_ERROR");
|
||||
completeCriticalTranslations(service, "fr");
|
||||
|
||||
const activeFrench = service.updateLocale(owner, "fr", { status: "active" });
|
||||
assert.equal(activeFrench.status, "active");
|
||||
@@ -91,13 +95,18 @@ try {
|
||||
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
|
||||
assertDomainError(() => service.archiveLocale(owner, "en"), "CONFLICT");
|
||||
|
||||
service.createLocale(owner, {
|
||||
const italian = service.createLocale(owner, {
|
||||
code: "it",
|
||||
name: "Italian",
|
||||
nativeName: "Italiano",
|
||||
status: "active",
|
||||
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({
|
||||
id: "i18n-client",
|
||||
ownerUserId: owner.authUserId,
|
||||
@@ -106,6 +115,10 @@ try {
|
||||
portalLocale: "it",
|
||||
}).run();
|
||||
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({
|
||||
entityType: "project",
|
||||
@@ -130,3 +143,15 @@ try {
|
||||
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
|
||||
import { getSqliteConnection } from "../server/db/client";
|
||||
import type { DomainActor } from "../server/domain/actor";
|
||||
import { DomainError } from "../server/domain/errors";
|
||||
import { I18nService } from "../server/i18n/service";
|
||||
import {
|
||||
ACTIVATION_CRITICAL_KEYS,
|
||||
I18nService,
|
||||
} from "../server/i18n/service";
|
||||
|
||||
const owner: DomainActor = {
|
||||
authUserId: "phase3-owner",
|
||||
@@ -61,6 +64,8 @@ assert.equal(
|
||||
true,
|
||||
);
|
||||
|
||||
assertDomainError(() => service.updateLocale(owner, "fr", { status: "active" }), "VALIDATION_ERROR");
|
||||
completeCriticalTranslations(service, "fr");
|
||||
service.updateLocale(owner, "fr", { status: "active" });
|
||||
assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr");
|
||||
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 {
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { getSqliteConnection } from "../server/db/client";
|
||||
import type { DomainActor } from "../server/domain/actor";
|
||||
import { DomainService } from "../server/services/domain";
|
||||
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 owner: DomainActor = {
|
||||
@@ -34,11 +37,18 @@ if (!i18n.listLocales(owner).some((locale) => locale.code === "fr")) {
|
||||
name: "French",
|
||||
nativeName: "Français",
|
||||
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, (() => {
|
||||
let next = 0;
|
||||
|
||||
@@ -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");
|
||||
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(localeApi.includes("Dil kodu"), false, "locale API must not embed Turkish validation messages");
|
||||
assert.equal(localeApi.includes("messageKey"), true, "locale API must expose messageKey for localized clients");
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts")),
|
||||
false,
|
||||
"The obsolete locale-cookie API must remain removed",
|
||||
);
|
||||
|
||||
console.log("I18n phase 7 auth/error/a11y smoke passed.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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.");
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user