From 3a5ee5adc28725247423691c6198a293ac369937 Mon Sep 17 00:00:00 2001 From: Muhammed Koramaz Date: Thu, 24 Sep 2026 10:19:30 +0300 Subject: [PATCH 01/17] Add Turkish interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete Turkish (tr) message catalog and register tr as a supported interface locale and native language across the frontend and backend: locale lists, browser-language detection, registration, settings and admin selectors, schema validation, voice session titles, lesson feedback, and transactional email templates. Turkish capitalizes language names, so tr is added to the capitalization set in formatLanguageName (lowercasing would also turn "İ" into a dotted "i̇"). The dashboard announcement keeps its existing ten stored locales; Turkish users fall back to the English translation. --- CHANGELOG.md | 4 + backend/app/routers/lessons.py | 7 + backend/app/schemas/admin.py | 1 + backend/app/schemas/auth.py | 2 + backend/app/services/email_service.py | 66 + backend/app/services/language_helpers.py | 16 + backend/tests/test_conversation.py | 12 +- frontend/src/app/(app)/admin/users/page.tsx | 1 + frontend/src/app/(auth)/register/page.tsx | 1 + frontend/src/app/layout.tsx | 2 +- .../components/settings/ProfileSection.tsx | 1 + frontend/src/lib/locales.ts | 1 + frontend/src/lib/target-languages.ts | 2 +- .../app/onboarding-goals-subtitle.test.tsx | 4 +- .../tests/components/ProfileSection.test.tsx | 2 + frontend/tests/i18n/admin-messages.test.ts | 3 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt.json | 1 + messages/ro.json | 1 + messages/ru.json | 1 + messages/tr.json | 1695 +++++++++++++++++ specs/api-endpoints.instructions.md | 2 +- specs/platform.instructions.md | 2 +- 29 files changed, 1827 insertions(+), 7 deletions(-) create mode 100644 messages/tr.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b239e895..7566868c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Turkish (`tr`) interface language: full interface translation, Turkish as a native-language option at registration and in Settings, and Turkish verification, password reset, welcome, and account deletion emails. The dashboard announcement keeps its existing ten locales and falls back to English for Turkish users. + ### Fixed - Onboarding learning goals now name the selected language in all ten interface languages, with natural wording and a safe display fallback for unrecognized language codes. diff --git a/backend/app/routers/lessons.py b/backend/app/routers/lessons.py index b646d17db..d1a54e39c 100644 --- a/backend/app/routers/lessons.py +++ b/backend/app/routers/lessons.py @@ -119,6 +119,13 @@ "good_pronunciation": "Pronuntie buna!", "target_phrase": "Fraza tinta a fost: {answer}", }, + "tr": { + "correct": "Doğru!", + "correct_answer": "Doğru cevap: {answer}", + "free_write_unavailable": "Yazılı cevap şu anda değerlendirilemedi.", + "good_pronunciation": "Telaffuzunuz iyi!", + "target_phrase": "Hedef ifade şuydu: {answer}", + }, } diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 8852b78ca..22186699b 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -23,6 +23,7 @@ "nl", "pl", "ro", + "tr", } diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 53e9f0168..caba7eb03 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -29,6 +29,7 @@ def validate_password_strength(v: str) -> str: "nl", "pl", "ro", + "tr", } SUPPORTED_TARGET_LANGUAGES: set[str] = { @@ -65,6 +66,7 @@ def get_available_languages() -> list[str]: "nl", "ro", "ru", + "tr", } VALID_LEARNING_GOALS: set[str] = { diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 8c5802095..7bf6d731a 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -122,6 +122,14 @@ def _get_mail_config() -> ConnectionConfig: "link_fallback": "\u0415\u0441\u043b\u0438 \u043a\u043d\u043e\u043f\u043a\u0430 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442, \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u0438 \u0432\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u044d\u0442\u0443 \u0441\u0441\u044b\u043b\u043a\u0443 \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440:", "footer": "\u0415\u0441\u043b\u0438 \u0432\u044b \u043d\u0435 \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u043b\u0438 \u044d\u0442\u043e\u0442 \u0430\u043a\u043a\u0430\u0443\u043d\u0442, \u043f\u0440\u043e\u0441\u0442\u043e \u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u0439\u0442\u0435 \u044d\u0442\u043e \u043f\u0438\u0441\u044c\u043c\u043e.", }, + "tr": { + "greeting": "Merhaba {name},", + "subject": "FreeLingo hesab\u0131n\u0131z\u0131 do\u011frulay\u0131n", + "body": "FreeLingo hesab\u0131n\u0131z\u0131 olu\u015fturdu\u011funuz i\u00e7in te\u015fekk\u00fcr ederiz.
L\u00fctfen a\u015fa\u011f\u0131daki d\u00fc\u011fmeye t\u0131klayarak e-posta adresinizi do\u011frulay\u0131n. Bu ba\u011flant\u0131 24 saat ge\u00e7erlidir.", + "button": "Hesab\u0131m\u0131 do\u011frula", + "link_fallback": "D\u00fc\u011fme \u00e7al\u0131\u015fmazsa bu ba\u011flant\u0131y\u0131 kopyalay\u0131p taray\u0131c\u0131n\u0131za yap\u0131\u015ft\u0131r\u0131n:", + "footer": "Bu hesab\u0131 siz olu\u015fturmad\u0131ysan\u0131z bu e-postay\u0131 yok sayabilirsiniz.", + }, } _RESET_I18N: dict[str, dict[str, str]] = { @@ -205,6 +213,14 @@ def _get_mail_config() -> ConnectionConfig: "link_fallback": "\u0415\u0441\u043b\u0438 \u043a\u043d\u043e\u043f\u043a\u0430 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442, \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u0438 \u0432\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u044d\u0442\u0443 \u0441\u0441\u044b\u043b\u043a\u0443 \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440:", "footer": "\u0415\u0441\u043b\u0438 \u0432\u044b \u043d\u0435 \u0437\u0430\u043f\u0440\u0430\u0448\u0438\u0432\u0430\u043b\u0438 \u0441\u0431\u0440\u043e\u0441, \u043f\u0440\u043e\u0441\u0442\u043e \u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u0439\u0442\u0435 \u044d\u0442\u043e \u043f\u0438\u0441\u044c\u043c\u043e.", }, + "tr": { + "greeting": "Merhaba {name},", + "subject": "FreeLingo parolan\u0131z\u0131 s\u0131f\u0131rlay\u0131n", + "body": "FreeLingo hesab\u0131n\u0131z\u0131n parolas\u0131n\u0131 s\u0131f\u0131rlama iste\u011fi ald\u0131k.
Yeni bir parola olu\u015fturmak i\u00e7in a\u015fa\u011f\u0131daki d\u00fc\u011fmeye t\u0131klay\u0131n. Bu ba\u011flant\u0131 1 saat ge\u00e7erlidir.", + "button": "Parolam\u0131 s\u0131f\u0131rla", + "link_fallback": "D\u00fc\u011fme \u00e7al\u0131\u015fmazsa bu ba\u011flant\u0131y\u0131 kopyalay\u0131p taray\u0131c\u0131n\u0131za yap\u0131\u015ft\u0131r\u0131n:", + "footer": "Parola s\u0131f\u0131rlama iste\u011finde bulunmad\u0131ysan\u0131z bu e-postay\u0131 g\u00fcvenle yok sayabilirsiniz.", + }, } _WELCOME_I18N: dict[str, dict[str, str]] = { @@ -308,6 +324,16 @@ def _get_mail_config() -> ConnectionConfig: "button": "\u041f\u0435\u0440\u0435\u0439\u0442\u0438 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u044c \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f", "footer": "\u0423\u0441\u043f\u0435\u0445\u043e\u0432 \u0432 \u043e\u0431\u0443\u0447\u0435\u043d\u0438\u0438!", }, + "tr": { + "subject": "FreeLingo'ya ho\u015f geldiniz!", + "greeting": "Merhaba {name},", + "body": "Hesab\u0131n\u0131z haz\u0131r. Ba\u015flamak i\u00e7in \u015funlar\u0131 yapabilirsiniz:", + "step1": "Seviye Tespitine girin \u2014 mevcut seviyenizi (A1\u2013C2), g\u00fc\u00e7l\u00fc ve zay\u0131f y\u00f6nlerinizi belirler.", + "step2": "\u00c7al\u0131\u015fma Plan\u0131n\u0131z\u0131 olu\u015fturun \u2014 seviyenize ve hedeflerinize g\u00f6re haz\u0131rlanm\u0131\u015f haftal\u0131k bir program.", + "step3": "\u0130lk g\u00fcnl\u00fck dersinizi tamamlay\u0131n ve serinizi olu\u015fturmaya ba\u015flay\u0131n.", + "button": "Panoma git", + "footer": "\u0130yi \u00e7al\u0131\u015fmalar!", + }, } @@ -372,6 +398,12 @@ def _get_mail_config() -> ConnectionConfig: "body": "\u0412\u0430\u0448 \u0430\u043a\u043a\u0430\u0443\u043d\u0442 FreeLingo \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0443\u0434\u0430\u043b\u0451\u043d. \u0412\u0441\u0435 \u0432\u0430\u0448\u0438 \u0434\u0430\u043d\u043d\u044b\u0435 \u0431\u044b\u043b\u0438 \u043d\u0430\u0432\u0441\u0435\u0433\u0434\u0430 \u0443\u0434\u0430\u043b\u0435\u043d\u044b \u0441 \u043d\u0430\u0448\u0438\u0445 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u0432.", "footer": "\u0415\u0441\u043b\u0438 \u0432\u044b \u043d\u0435 \u0437\u0430\u043f\u0440\u0430\u0448\u0438\u0432\u0430\u043b\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435, \u043d\u0435\u043c\u0435\u0434\u043b\u0435\u043d\u043d\u043e \u0441\u0432\u044f\u0436\u0438\u0442\u0435\u0441\u044c \u0441 \u043d\u0430\u043c\u0438.", }, + "tr": { + "subject": "FreeLingo hesab\u0131n\u0131z silindi", + "greeting": "Merhaba {name},", + "body": "FreeLingo hesab\u0131n\u0131z ba\u015far\u0131yla silindi. T\u00fcm verileriniz sunucular\u0131m\u0131zdan kal\u0131c\u0131 olarak kald\u0131r\u0131ld\u0131.", + "footer": "Bu silme i\u015flemini siz istemediyseniz l\u00fctfen hemen bizimle ileti\u015fime ge\u00e7in.", + }, } _CONTACT_I18N: dict[str, dict[str, str]] = { @@ -465,6 +497,15 @@ def _get_mail_config() -> ConnectionConfig: "message_label": "Сообщение", "footer": "Отправлено через контактную форму FreeLingo", }, + "tr": { + "email_title": "İletişim formu mesajı", + "subject_prefix": "[FreeLingo İletişim]", + "logo": "FreeLingo: İletişim Formu", + "from_label": "Gönderen", + "subject_label": "Konu", + "message_label": "Mesaj", + "footer": "FreeLingo iletişim formu aracılığıyla gönderildi", + }, } _FEEDBACK_I18N: dict[str, dict[str, str]] = { @@ -598,6 +639,19 @@ def _get_mail_config() -> ConnectionConfig: "cta": "Открыть в панели администратора", "footer": "Доска отзывов FreeLingo", }, + "tr": { + "email_title": "Yeni geri bildirim gönderildi", + "logo": "FreeLingo: Yeni Geri Bildirim", + "feature_label": "Özellik isteği", + "bug_label": "Hata bildirimi", + "feature_subject_prefix": "[Özellik İsteği]", + "bug_subject_prefix": "[Hata Bildirimi]", + "author_label": "Gönderen", + "title_label": "Başlık", + "description_label": "Açıklama", + "cta": "Yönetim panelinde görüntüle", + "footer": "FreeLingo geri bildirim panosu", + }, } _REVIEW_I18N: dict[str, dict[str, str]] = { @@ -721,6 +775,18 @@ def _get_mail_config() -> ConnectionConfig: "cta": "Открыть в панели администратора", "footer": "Модерация отзывов FreeLingo", }, + "tr": { + "email_title": "Yeni yorum gönderildi", + "subject_prefix": "[Yeni Yorum]", + "logo": "FreeLingo: Yeni Yorum", + "author_label": "Gönderen", + "rating_label": "Puan", + "language_label": "Öğrenilen dil", + "comment_label": "Yorum", + "empty_comment": "Yorum yazılmadı.", + "cta": "Yönetim panelinde görüntüle", + "footer": "FreeLingo yorum moderasyonu", + }, } diff --git a/backend/app/services/language_helpers.py b/backend/app/services/language_helpers.py index 37094d13c..79e55c00d 100644 --- a/backend/app/services/language_helpers.py +++ b/backend/app/services/language_helpers.py @@ -135,6 +135,7 @@ "nl": "Spraaksessie", "ro": "Sesiune vocală", "ru": "Голосовая сессия", + "tr": "Sesli oturum", } _NATIVE_LANGUAGE_NAMES: dict[str, str] = { @@ -148,6 +149,7 @@ "nl": "Dutch", "ro": "Romanian", "ru": "Russian", + "tr": "Turkish", } _MONTH_NAMES: dict[str, list[str]] = { @@ -277,6 +279,20 @@ "ноября", "декабря", ], + "tr": [ + "Ocak", + "Şubat", + "Mart", + "Nisan", + "Mayıs", + "Haziran", + "Temmuz", + "Ağustos", + "Eylül", + "Ekim", + "Kasım", + "Aralık", + ], } diff --git a/backend/tests/test_conversation.py b/backend/tests/test_conversation.py index 394c65f04..86867b385 100644 --- a/backend/tests/test_conversation.py +++ b/backend/tests/test_conversation.py @@ -577,11 +577,21 @@ def test_voice_session_title_russian() -> None: assert " de " not in date_part +def test_voice_session_title_turkish() -> None: + """Turkish uses its own label and plain 'D Month YYYY' format.""" + from app.services.language_helpers import voice_session_title + + title = voice_session_title("tr") + assert title.startswith("Sesli oturum — ") + date_part = title.split(" — ", 1)[1] + assert " de " not in date_part + + def test_voice_session_title_all_supported_languages_produce_nonempty() -> None: """Every supported native language must produce a non-empty title.""" from app.services.language_helpers import voice_session_title - supported = ["es", "fr", "pt", "de", "it", "pl", "nl", "ro", "ru"] + supported = ["es", "fr", "pt", "de", "it", "pl", "nl", "ro", "ru", "tr"] for lang in supported: title = voice_session_title(lang) assert title.strip(), f"Empty title for language '{lang}'" diff --git a/frontend/src/app/(app)/admin/users/page.tsx b/frontend/src/app/(app)/admin/users/page.tsx index 7cb6e40f4..19d09c145 100644 --- a/frontend/src/app/(app)/admin/users/page.tsx +++ b/frontend/src/app/(app)/admin/users/page.tsx @@ -54,6 +54,7 @@ const LANGUAGES = [ 'nl', 'ro', 'ru', + 'tr', ] as const const PAGE_SIZE = 10 diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 82c9a178f..ec6236bd5 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -22,6 +22,7 @@ const LANGUAGES = [ 'nl', 'ro', 'ru', + 'tr', ] as const type SelectedPlan = 'monthly' | 'yearly' diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 5259bd1f1..c43d4928d 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -115,7 +115,7 @@ export default async function RootLayout({