From eb73ba3a7bdc03b863b76c55c44deee91b3281b3 Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:23:39 +0200 Subject: [PATCH 01/14] Add Turkish support for dashboard banners and release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend dashboard banner locales and translation prompts to include `tr` - Allow legacy ten-locale banner records to remain readable until Turkish is added - Update admin and public banner responses to preserve missing translations - Add backend and frontend tests for Turkish source and legacy banner editing - Update UI locale lists, version badges, and What’s New version strings to `1.9.25` - Refresh changelog and localization files for the Turkish interface release - Update specs and version metadata to reflect eleven supported locales --- AGENTS.md | 2 +- CHANGELOG.md | 15 +++- README.md | 2 +- backend/app/routers/admin_dashboard_banner.py | 8 +- backend/app/schemas/dashboard_banner.py | 11 ++- backend/tests/test_dashboard_banner.py | 74 ++++++++++++++++++- frontend/src/app/(app)/admin/system/page.tsx | 17 +++-- frontend/src/app/(app)/layout.tsx | 4 +- .../src/components/whats-new/WhatsNew.tsx | 2 +- .../tests/app/admin-system-banner.test.tsx | 57 +++++++++++++- messages/de.json | 19 ++--- messages/en.json | 19 ++--- messages/es.json | 19 ++--- messages/fr.json | 19 ++--- messages/it.json | 19 ++--- messages/nl.json | 19 ++--- messages/pl.json | 19 ++--- messages/pt.json | 19 ++--- messages/ro.json | 19 ++--- messages/ru.json | 19 ++--- messages/tr.json | 17 ++--- specs/api-endpoints.instructions.md | 6 +- specs/database-models.instructions.md | 3 +- specs/version.md | 4 +- specs/whats-new.instructions.md | 2 +- 25 files changed, 243 insertions(+), 171 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f92d4cea..eb5315d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — FreeLingo -**Current version: 1.9.20** +**Current version: 1.9.25** ## Project diff --git a/CHANGELOG.md b/CHANGELOG.md index 8000187d2..4399137e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,21 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.9.20] - 2026-09-24 +## [1.9.25] - 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. +- Turkish (`tr`) interface language and native-language option in registration, Settings, and administration, including localized learning feedback, voice-session titles, and transactional emails. +- Dashboard announcements support Turkish as a source and translation language, alongside the existing ten languages. + +### Changed + +- Existing dashboard announcements remain readable with ten translations; adding Turkish is required before saving them again and creates a new announcement revision. +- What's New now highlights the Turkish interface and retains the general fixes note in all eleven interface languages. + +## [1.9.20] - 2026-09-24 ### Fixed diff --git a/README.md b/README.md index 5617e636b..9b25483fe 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Next.js](https://img.shields.io/badge/next.js-16-black?style=flat-square) ![Python](https://img.shields.io/badge/python-3.14-blue?style=flat-square) ![Self-hosted](https://img.shields.io/badge/self--hosted-yes-orange?style=flat-square) -![Version](https://img.shields.io/badge/version-1.9.20-brightgreen?style=flat-square) +![Version](https://img.shields.io/badge/version-1.9.25-brightgreen?style=flat-square)

FreeLingo logo diff --git a/backend/app/routers/admin_dashboard_banner.py b/backend/app/routers/admin_dashboard_banner.py index 7fd64931e..e4f3dd30f 100644 --- a/backend/app/routers/admin_dashboard_banner.py +++ b/backend/app/routers/admin_dashboard_banner.py @@ -19,8 +19,8 @@ router = APIRouter(prefix="/api/admin/dashboard-banner", tags=["admin"]) -_TRANSLATION_PROMPT = """Translate this dashboard announcement faithfully into all ten requested locales. -Return en, es, fr, pt, de, it, ru, nl, pl, and ro. Preserve the original tone and meaning. +_TRANSLATION_PROMPT = """Translate this dashboard announcement faithfully into all eleven requested locales. +Return en, es, fr, pt, de, it, ru, nl, pl, ro, and tr. Preserve the original tone and meaning. Do not add claims, details, formatting, Markdown, or HTML. Every field must contain plain text only. For the source locale, copy each provided field exactly. @@ -31,7 +31,9 @@ """ -@router.get("", response_model=DashboardBannerAdminResponse | None) +@router.get( + "", response_model=DashboardBannerAdminResponse | None, response_model_exclude_unset=True +) @limiter.limit("60/minute") async def get_dashboard_banner( request: Request, diff --git a/backend/app/schemas/dashboard_banner.py b/backend/app/schemas/dashboard_banner.py index 53691ad92..51418c663 100644 --- a/backend/app/schemas/dashboard_banner.py +++ b/backend/app/schemas/dashboard_banner.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -DashboardBannerLocale = Literal["en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro"] +DashboardBannerLocale = Literal["en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr"] class DashboardBannerTranslation(BaseModel): @@ -24,7 +24,7 @@ def strip_nonblank_text(cls, value: str) -> str: return value -class DashboardBannerTranslations(BaseModel): +class DashboardBannerStoredTranslations(BaseModel): model_config = ConfigDict(extra="forbid") en: DashboardBannerTranslation @@ -37,6 +37,11 @@ class DashboardBannerTranslations(BaseModel): nl: DashboardBannerTranslation pl: DashboardBannerTranslation ro: DashboardBannerTranslation + tr: DashboardBannerTranslation | None = None + + +class DashboardBannerTranslations(DashboardBannerStoredTranslations): + tr: DashboardBannerTranslation class DashboardBannerTranslateRequest(DashboardBannerTranslation): @@ -57,7 +62,7 @@ class DashboardBannerUpdate(BaseModel): class DashboardBannerPublicResponse(BaseModel): revision: int - translations: DashboardBannerTranslations + translations: DashboardBannerStoredTranslations class DashboardBannerAdminResponse(DashboardBannerPublicResponse): diff --git a/backend/tests/test_dashboard_banner.py b/backend/tests/test_dashboard_banner.py index d80905b75..3f22f0f7d 100644 --- a/backend/tests/test_dashboard_banner.py +++ b/backend/tests/test_dashboard_banner.py @@ -2,10 +2,11 @@ import pytest +from app.models.dashboard_banner import DashboardBanner from app.schemas.dashboard_banner import DashboardBannerTranslationResponse from app.services.llm_adapter import LLMError -LOCALES = ("en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro") +LOCALES = ("en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr") def banner_translations(label: str = "Notice") -> dict[str, dict[str, str]]: @@ -45,6 +46,43 @@ async def test_config_and_admin_banner_default_to_null(client, admin_user): assert admin_response.json() is None +@pytest.mark.asyncio +async def test_existing_ten_locale_banner_remains_readable_until_turkish_is_added( + client, admin_user, db_session +): + _, headers = admin_user + legacy_translations = banner_translations() + legacy_translations.pop("tr") + db_session.add( + DashboardBanner( + id=1, + source_locale="en", + is_active=True, + translations=legacy_translations, + revision=4, + ) + ) + await db_session.commit() + + admin_response = await client.get("/api/admin/dashboard-banner", headers=headers) + public_response = await client.get("/api/config") + + assert admin_response.status_code == 200 + assert admin_response.json()["translations"] == legacy_translations + assert admin_response.json()["revision"] == 4 + assert public_response.json()["dashboard_banner"] == { + "revision": 4, + "translations": legacy_translations, + } + + missing_tr = await client.put( + "/api/admin/dashboard-banner", + headers=headers, + json=banner_update(translations=legacy_translations), + ) + assert missing_tr.status_code == 422 + + @pytest.mark.asyncio async def test_dashboard_banner_admin_endpoints_require_admin(client, test_user): _, user_headers = test_user @@ -108,12 +146,46 @@ async def fake_structured_output(messages, schema): "subtitle": "Brief interruption", "description": "Service returns shortly.", } + assert "tr" in translations # Translation is a preview and must not create the singleton. banner_response = await client.get("/api/admin/dashboard-banner", headers=headers) assert banner_response.json() is None +@pytest.mark.asyncio +async def test_translate_banner_accepts_turkish_source(client, admin_user, monkeypatch): + _, headers = admin_user + generated = banner_translations("Generated") + + async def fake_structured_output(messages, schema): + assert "all eleven requested locales" in messages[0]["content"] + assert "Source locale: tr" in messages[0]["content"] + return schema(translations=generated) + + monkeypatch.setattr( + "app.routers.admin_dashboard_banner.llm_adapter.structured_output", + fake_structured_output, + ) + response = await client.post( + "/api/admin/dashboard-banner/translate", + headers=headers, + json={ + "source_locale": "tr", + "title": " Duyuru ", + "subtitle": " Önemli ", + "description": " Ayrıntılar ", + }, + ) + + assert response.status_code == 200 + assert response.json()["translations"]["tr"] == { + "title": "Duyuru", + "subtitle": "Önemli", + "description": "Ayrıntılar", + } + + @pytest.mark.asyncio async def test_translate_banner_returns_safe_502_for_llm_failure(client, admin_user, monkeypatch): _, headers = admin_user diff --git a/frontend/src/app/(app)/admin/system/page.tsx b/frontend/src/app/(app)/admin/system/page.tsx index 1ce66da38..2a8634529 100644 --- a/frontend/src/app/(app)/admin/system/page.tsx +++ b/frontend/src/app/(app)/admin/system/page.tsx @@ -21,6 +21,7 @@ const BANNER_LOCALES = [ 'nl', 'pl', 'ro', + 'tr', ] as const type BannerLocale = (typeof BANNER_LOCALES)[number] @@ -30,7 +31,7 @@ interface AdminDashboardBanner { source_locale: BannerLocale is_active: boolean revision: number - translations: BannerTranslations + translations: Partial created_at: string updated_at: string } @@ -80,8 +81,10 @@ export default function AdminSystemPage() { if (banner) { setSourceLocale(banner.source_locale) setEditorLocale(banner.source_locale) - setSource({ ...banner.translations[banner.source_locale] }) - setTranslations(banner.translations) + setSource({ + ...(banner.translations[banner.source_locale] ?? EMPTY_TRANSLATION), + }) + setTranslations({ ...emptyTranslations(), ...banner.translations }) setHasTranslations(true) setIsActive(banner.is_active) setRevision(banner.revision) @@ -163,11 +166,15 @@ export default function AdminSystemPage() { const saved: AdminDashboardBanner = await response.json() setRevision(saved.revision) setUpdatedAt(saved.updated_at) - setTranslations(saved.translations) + const savedTranslations = { + ...emptyTranslations(), + ...saved.translations, + } + setTranslations(savedTranslations) setIsActive(saved.is_active) useConfigStore.setState({ dashboardBanner: saved.is_active - ? { revision: saved.revision, translations: saved.translations } + ? { revision: saved.revision, translations: savedTranslations } : null, }) setBannerSuccess(t('dashboardBanner.saveSuccess')) diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 7dc83d92e..1205ed3c0 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -379,7 +379,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {

- v1.9.20 + v1.9.25

@@ -478,7 +479,7 @@ export default function ChatPage() { {msg.role === 'assistant' ? ( Tutor {entries.map((entry, i) => { const canDelete = currentUserId === entry.author.id || isAdmin + const voteLabel = t( + entry.voted_by_me ? 'removeVoteAction' : 'voteAction' + ) return (
▲ diff --git a/frontend/src/app/(app)/flashcards/vocabulary/page.tsx b/frontend/src/app/(app)/flashcards/vocabulary/page.tsx index 85ead5653..548ef7a07 100644 --- a/frontend/src/app/(app)/flashcards/vocabulary/page.tsx +++ b/frontend/src/app/(app)/flashcards/vocabulary/page.tsx @@ -20,6 +20,7 @@ const LIMIT = 10 export default function VocabularyPage() { const t = useTranslations('flashcards') + const tCommon = useTranslations('common') const [items, setItems] = useState([]) const [total, setTotal] = useState(0) @@ -146,7 +147,7 @@ export default function VocabularyPage() { onClick={() => deleteItem(item.id)} disabled={deletingId === item.id} className="text-fl-muted-3 shrink-0 font-mono text-xs transition-colors hover:text-red-400 disabled:opacity-40" - aria-label="Delete" + aria-label={tCommon('delete')} > ✕ diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 1205ed3c0..aa0d6a670 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -405,7 +405,9 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { diff --git a/frontend/src/components/conversation/TranscriptBubble.tsx b/frontend/src/components/conversation/TranscriptBubble.tsx index 5be928897..c7a6389e6 100644 --- a/frontend/src/components/conversation/TranscriptBubble.tsx +++ b/frontend/src/components/conversation/TranscriptBubble.tsx @@ -44,7 +44,7 @@ export default function TranscriptBubble({ {!isUser ? ( Tutor { - if (!open) return + if (!open) { + setChecking(true) + setHasReview(false) + return + } let cancelled = false setChecking(true) setError('') diff --git a/frontend/src/components/ui/WordTooltip.tsx b/frontend/src/components/ui/WordTooltip.tsx index 8990fc84b..6d5d32f83 100644 --- a/frontend/src/components/ui/WordTooltip.tsx +++ b/frontend/src/components/ui/WordTooltip.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useEffect, useRef } from 'react' +import { useTranslations } from 'next-intl' import { apiFetch } from '@/lib/api' // --------------------------------------------------------------------------- @@ -38,6 +39,7 @@ export function WordTooltip({ wordSaveError: string } }) { + const tCommon = useTranslations('common') return (
✕ diff --git a/frontend/src/components/ui/landing-nav.tsx b/frontend/src/components/ui/landing-nav.tsx index 66a076237..4100dce4b 100644 --- a/frontend/src/components/ui/landing-nav.tsx +++ b/frontend/src/components/ui/landing-nav.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import Link from 'next/link' import Image from 'next/image' import { Menu, X } from 'lucide-react' +import { useTranslations } from 'next-intl' import { hasActiveLandingSubscription } from '@/lib/landing-subscription' interface LandingNavProps { @@ -29,6 +30,7 @@ export function LandingNav({ signIn, dashboard, }: LandingNavProps) { + const tCommon = useTranslations('common') const [open, setOpen] = useState(false) const [showPricing, setShowPricing] = useState(stripeEnabled && !hasSession) @@ -118,7 +120,7 @@ export function LandingNav({ diff --git a/frontend/src/components/ui/loading-bar.tsx b/frontend/src/components/ui/loading-bar.tsx index 4790acdb0..b0e407c68 100644 --- a/frontend/src/components/ui/loading-bar.tsx +++ b/frontend/src/components/ui/loading-bar.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' import { useLoadingStore } from '@/store/loading' const MIN_VISIBLE_MS = 600 @@ -9,6 +10,7 @@ const COMPLETE_DURATION_MS = 400 type Phase = 'hidden' | 'loading' | 'completing' export function LoadingBar() { + const tCommon = useTranslations('common') const count = useLoadingStore((s) => s.count) const complete = useLoadingStore((s) => s.complete) const finishComplete = useLoadingStore((s) => s.finishComplete) @@ -46,7 +48,7 @@ export function LoadingBar() {
@@ -46,6 +47,7 @@ function SheetContent({ side?: 'top' | 'right' | 'bottom' | 'left' showCloseButton?: boolean }) { + const tCommon = useTranslations('common') return ( @@ -71,7 +73,7 @@ function SheetContent({ } > - Close + {tCommon('close')} )} diff --git a/frontend/tests/app/admin-overview.test.tsx b/frontend/tests/app/admin-overview.test.tsx index c470cb15e..2a26ff898 100644 --- a/frontend/tests/app/admin-overview.test.tsx +++ b/frontend/tests/app/admin-overview.test.tsx @@ -2,8 +2,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import React from 'react' +const translate = vi.hoisted(() => (key: string) => key) + vi.mock('next-intl', () => ({ - useTranslations: () => (key: string) => key, + useTranslations: () => translate, })) vi.mock('next/link', () => ({ @@ -60,10 +62,8 @@ describe('AdminOverviewPage', () => { render() - await waitFor(() => - expect(mockApiFetch).toHaveBeenCalledWith('/api/admin/stats') - ) - expect(screen.getByText('12')).toBeDefined() + await waitFor(() => expect(screen.getByText('12')).toBeDefined()) + expect(mockApiFetch).toHaveBeenCalledWith('/api/admin/stats') expect(screen.getByText('10')).toBeDefined() expect(screen.getByText('4 / 3')).toBeDefined() expect(screen.getByText('5')).toBeDefined() diff --git a/frontend/tests/app/feedback-unread-label.test.tsx b/frontend/tests/app/feedback-unread-label.test.tsx index bd142e9e7..edb12c6b3 100644 --- a/frontend/tests/app/feedback-unread-label.test.tsx +++ b/frontend/tests/app/feedback-unread-label.test.tsx @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' -const { mockApiFetch } = vi.hoisted(() => ({ +const { mockApiFetch, translate } = vi.hoisted(() => ({ mockApiFetch: vi.fn(), + translate: (key: string) => key, })) vi.mock('next-intl', () => ({ - useTranslations: () => (key: string) => key, + useTranslations: () => translate, })) vi.mock('@/lib/api', () => ({ @@ -82,4 +83,43 @@ describe('FeedbackPage unread labels', () => { expect(screen.getAllByText('statusPending').length).toBeGreaterThan(0) expect(screen.getByText('Add focused practice')).toBeDefined() }) + + it('updates the translated vote button label when a vote is toggled', async () => { + const list = { + items: [ + { + id: 10, + type: 'feature', + title: 'Add focused practice', + description: 'A suggestion.', + status: 'pending', + author: { + id: 2, + username: 'other', + display_name: 'Other User', + }, + vote_count: 0, + voted_by_me: false, + unread_by_me: false, + comment_count: 0, + created_at: '2026-07-04T10:00:00', + }, + ], + total: 1, + skip: 0, + limit: 20, + } + mockApiFetch + .mockResolvedValueOnce(jsonResponse(list)) + .mockResolvedValueOnce(jsonResponse(list)) + .mockResolvedValueOnce(jsonResponse({ voted: true, vote_count: 1 })) + + render() + const voteButton = await screen.findByRole('button', { name: 'voteAction' }) + expect(voteButton).toHaveAttribute('title', 'voteAction') + fireEvent.click(voteButton) + expect( + await screen.findByRole('button', { name: 'removeVoteAction' }) + ).toHaveAttribute('title', 'removeVoteAction') + }) }) diff --git a/frontend/tests/components/ReviewPrompt.test.tsx b/frontend/tests/components/ReviewPrompt.test.tsx index 79072e7ad..63c19ce9c 100644 --- a/frontend/tests/components/ReviewPrompt.test.tsx +++ b/frontend/tests/components/ReviewPrompt.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ReviewPrompt, getReviewPromptDismissal, @@ -53,7 +53,18 @@ describe('ReviewPrompt', () => { it('submits rating-only reviews', async () => { const onClose = vi.fn() mockCreateReview.mockResolvedValue({ id: 1, rating: 5 }) + let resolveReview!: (value: { has_review: boolean; review: null }) => void + mockFetchMyReview.mockReturnValueOnce( + new Promise<{ has_review: boolean; review: null }>((resolve) => { + resolveReview = resolve + }) + ) render() + expect(screen.getByText('checking')).toBeInTheDocument() + expect(screen.queryByText('Rating required')).toBeNull() + await act(async () => { + resolveReview({ has_review: false, review: null }) + }) await screen.findByText('Rating required') fireEvent.click(screen.getByLabelText('5 stars')) await waitFor(() => diff --git a/frontend/tests/components/WordTooltip.test.tsx b/frontend/tests/components/WordTooltip.test.tsx index 0d0752e79..16f6752de 100644 --- a/frontend/tests/components/WordTooltip.test.tsx +++ b/frontend/tests/components/WordTooltip.test.tsx @@ -5,6 +5,10 @@ import { WordTooltip, useWordSave } from '@/components/ui/WordTooltip' const mockApiFetch = vi.hoisted(() => vi.fn()) vi.mock('@/lib/api', () => ({ apiFetch: mockApiFetch })) +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => + key === 'close' ? 'Cerrar' : key, +})) const labels = { saveWord: 'Save', @@ -40,6 +44,7 @@ describe('WordTooltip component', () => { /> ) expect(screen.getByText('Save')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cerrar' })).toBeInTheDocument() }) it('renders wordSaved with green styling in saved state', () => { diff --git a/messages/de.json b/messages/de.json index 1ab335354..e128336eb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -78,6 +78,9 @@ "backToDashboard": "Zurück zum Dashboard", "tagline": "KI-gestütztes Sprachenlernen", "close": "Schließen", + "delete": "Löschen", + "openMenu": "Menü öffnen", + "closeMenu": "Menü schließen", "verifyEmailBanner": "Bitte bestätige deine E-Mail-Adresse, um dein Konto einzurichten.", "verifyEmailSent": "E-Mail gesendet. Prüfe deinen Posteingang.", "resendVerification": "Bestätigungs-E-Mail erneut senden", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Chats anzeigen", "toggleSidebarHide": "Chats ausblenden", "conversations": "Gespräche", + "voiceSession": "Sprachsitzung", "loading": "Laden...", "thinking": "Denke nach...", "takingLonger": "Dauert länger als erwartet...", @@ -848,6 +852,7 @@ "informal": "Informell", "copied": "Kopiert!", "copy": "Kopieren", + "copyPhrase": "Satz kopieren", "noResults": "Keine Phrasen gefunden", "level": "Niveau", "register": "Register", @@ -1582,6 +1587,8 @@ "noEntries": "Noch nichts hier. Sei der Erste!", "votes": "{count} Stimmen", "vote": "1 Stimme", + "voteAction": "Abstimmen", + "removeVoteAction": "Stimme zurückziehen", "comments": "{count} Kommentare", "comment": "1 Kommentar", "by": "von", diff --git a/messages/en.json b/messages/en.json index 05282b075..0c1ba6586 100644 --- a/messages/en.json +++ b/messages/en.json @@ -78,6 +78,9 @@ "backToDashboard": "Back to Dashboard", "tagline": "AI language learning", "close": "Close", + "delete": "Delete", + "openMenu": "Open menu", + "closeMenu": "Close menu", "verifyEmailBanner": "Please verify your email address to complete your account setup.", "verifyEmailSent": "Email sent. Check your inbox.", "resendVerification": "Resend verification email", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Show chats", "toggleSidebarHide": "Hide chats", "conversations": "Conversations", + "voiceSession": "Voice session", "loading": "Loading...", "thinking": "Thinking...", "takingLonger": "Taking longer than expected...", @@ -848,6 +852,7 @@ "informal": "Informal", "copied": "Copied!", "copy": "Copy", + "copyPhrase": "Copy phrase", "noResults": "No phrases match your filters", "level": "Level", "register": "Register", @@ -1582,6 +1587,8 @@ "noEntries": "Nothing here yet. Be the first to submit one.", "votes": "{count} votes", "vote": "1 vote", + "voteAction": "Vote", + "removeVoteAction": "Remove vote", "comments": "{count} comments", "comment": "1 comment", "by": "by", diff --git a/messages/es.json b/messages/es.json index 06ac0a9b8..6fed89a8b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -78,6 +78,9 @@ "backToDashboard": "Volver al inicio", "tagline": "aprendizaje de idiomas con IA", "close": "Cerrar", + "delete": "Eliminar", + "openMenu": "Abrir menú", + "closeMenu": "Cerrar menú", "verifyEmailBanner": "Por favor, verifica tu dirección de correo para completar la configuración de tu cuenta.", "verifyEmailSent": "Correo enviado. Revisa tu bandeja de entrada.", "resendVerification": "Reenviar correo de verificación", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Mostrar chats", "toggleSidebarHide": "Ocultar chats", "conversations": "Conversaciones", + "voiceSession": "Sesión de voz", "loading": "Cargando...", "thinking": "Pensando...", "takingLonger": "Está tardando más de lo esperado...", @@ -848,6 +852,7 @@ "informal": "Informal", "copied": "¡Copiado!", "copy": "Copiar", + "copyPhrase": "Copiar frase", "noResults": "Ninguna frase coincide con los filtros", "level": "Nivel", "register": "Registro", @@ -1582,6 +1587,8 @@ "noEntries": "Nada aquí todavía. ¡Sé el primero en enviar algo!", "votes": "{count} votos", "vote": "1 voto", + "voteAction": "Votar", + "removeVoteAction": "Retirar voto", "comments": "{count} comentarios", "comment": "1 comentario", "by": "por", diff --git a/messages/fr.json b/messages/fr.json index e322110f8..b50c89e85 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -78,6 +78,9 @@ "backToDashboard": "Retour au tableau de bord", "tagline": "apprentissage des langues par IA", "close": "Fermer", + "delete": "Supprimer", + "openMenu": "Ouvrir le menu", + "closeMenu": "Fermer le menu", "verifyEmailBanner": "Veuillez vérifier votre adresse e-mail pour finaliser la configuration de votre compte.", "verifyEmailSent": "E-mail envoyé. Vérifiez votre boîte de réception.", "resendVerification": "Renvoyer l'e-mail de vérification", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Afficher les chats", "toggleSidebarHide": "Masquer les chats", "conversations": "Conversations", + "voiceSession": "Conversation vocale", "loading": "Chargement...", "thinking": "Réflexion...", "takingLonger": "Cela prend plus de temps que prévu...", @@ -848,6 +852,7 @@ "informal": "Informel", "copied": "Copié !", "copy": "Copier", + "copyPhrase": "Copier la phrase", "noResults": "Aucune phrase ne correspond aux filtres", "level": "Niveau", "register": "Registre", @@ -1582,6 +1587,8 @@ "noEntries": "Rien ici pour l'instant. Soyez le premier à soumettre !", "votes": "{count} votes", "vote": "1 vote", + "voteAction": "Voter", + "removeVoteAction": "Retirer mon vote", "comments": "{count} commentaires", "comment": "1 commentaire", "by": "par", diff --git a/messages/it.json b/messages/it.json index 01fff1edd..b848148e9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -78,6 +78,9 @@ "backToDashboard": "Torna alla dashboard", "tagline": "apprendimento linguistico con IA", "close": "Chiudi", + "delete": "Elimina", + "openMenu": "Apri menu", + "closeMenu": "Chiudi menu", "verifyEmailBanner": "Verifica il tuo indirizzo e-mail per completare la configurazione dell'account.", "verifyEmailSent": "E-mail inviata. Controlla la tua casella di posta.", "resendVerification": "Reinvia e-mail di verifica", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Mostra chat", "toggleSidebarHide": "Nascondi chat", "conversations": "Conversazioni", + "voiceSession": "Sessione vocale", "loading": "Caricamento...", "thinking": "Sto pensando...", "takingLonger": "Ci sta mettendo più del previsto...", @@ -848,6 +852,7 @@ "informal": "Informale", "copied": "Copiato!", "copy": "Copia", + "copyPhrase": "Copia frase", "noResults": "Nessuna frase corrisponde ai filtri", "level": "Livello", "register": "Registro", @@ -1582,6 +1587,8 @@ "noEntries": "Niente ancora. Sii il primo a inviare qualcosa!", "votes": "{count} voti", "vote": "1 voto", + "voteAction": "Vota", + "removeVoteAction": "Rimuovi voto", "comments": "{count} commenti", "comment": "1 commento", "by": "di", diff --git a/messages/nl.json b/messages/nl.json index f538131ad..274f7d2db 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -78,6 +78,9 @@ "backToDashboard": "Terug naar dashboard", "tagline": "AI-taalonderwijs", "close": "Sluiten", + "delete": "Verwijderen", + "openMenu": "Menu openen", + "closeMenu": "Menu sluiten", "verifyEmailBanner": "Verifieer je e-mailadres om je accountinstellingen te voltooien.", "verifyEmailSent": "E-mail verzonden. Controleer je inbox.", "resendVerification": "Verificatie-e-mail opnieuw verzenden", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Chats tonen", "toggleSidebarHide": "Chats verbergen", "conversations": "Gesprekken", + "voiceSession": "Spraaksessie", "loading": "Laden...", "thinking": "Nadenken...", "takingLonger": "Duurt langer dan verwacht...", @@ -848,6 +852,7 @@ "informal": "Informeel", "copied": "Gekopieerd!", "copy": "Kopiëren", + "copyPhrase": "Zin kopiëren", "noResults": "Geen zinnen komen overeen met je filters", "level": "Niveau", "register": "Register", @@ -1582,6 +1587,8 @@ "noEntries": "Nog niets hier. Wees de eerste!", "votes": "{count} stemmen", "vote": "1 stem", + "voteAction": "Stemmen", + "removeVoteAction": "Stem intrekken", "comments": "{count} reacties", "comment": "1 reactie", "by": "door", diff --git a/messages/pl.json b/messages/pl.json index 7f20304b8..e73b66021 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -78,6 +78,9 @@ "backToDashboard": "Wróć do panelu", "tagline": "Nauka języków z AI", "close": "Zamknij", + "delete": "Usuń", + "openMenu": "Otwórz menu", + "closeMenu": "Zamknij menu", "verifyEmailBanner": "Prosimy o potwierdzenie adresu e-mail w celu dokończenia konfiguracji konta.", "verifyEmailSent": "Wiadomość wysłana. Sprawdź swoją skrzynkę odbiorczą.", "resendVerification": "Wyślij ponownie e-mail weryfikacyjny", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Pokaż czaty", "toggleSidebarHide": "Ukryj czaty", "conversations": "Rozmowy", + "voiceSession": "Sesja głosowa", "loading": "Ładowanie...", "thinking": "Myślenie...", "takingLonger": "Trwa to dłużej niż oczekiwano...", @@ -848,6 +852,7 @@ "informal": "Nieformalny", "copied": "Skopiowano!", "copy": "Kopiuj", + "copyPhrase": "Kopiuj frazę", "noResults": "Brak zwrotów pasujących do filtrów", "level": "Poziom", "register": "Rejestr", @@ -1582,6 +1587,8 @@ "noEntries": "Nic tu jeszcze nie ma. Bądź pierwszy!", "votes": "{count} głosów", "vote": "1 głos", + "voteAction": "Zagłosuj", + "removeVoteAction": "Cofnij głos", "comments": "{count} komentarzy", "comment": "1 komentarz", "by": "przez", diff --git a/messages/pt.json b/messages/pt.json index 504f51550..59f5110fc 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -78,6 +78,9 @@ "backToDashboard": "Voltar ao painel", "tagline": "aprendizado de idiomas com IA", "close": "Fechar", + "delete": "Eliminar", + "openMenu": "Abrir menu", + "closeMenu": "Fechar menu", "verifyEmailBanner": "Por favor, verifique o seu endereço de e-mail para concluir a configuração da conta.", "verifyEmailSent": "E-mail enviado. Verifique a sua caixa de entrada.", "resendVerification": "Reenviar e-mail de verificação", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Mostrar chats", "toggleSidebarHide": "Ocultar chats", "conversations": "Conversas", + "voiceSession": "Sessão de voz", "loading": "Carregando...", "thinking": "Pensando...", "takingLonger": "Está a demorar mais do que o esperado...", @@ -848,6 +852,7 @@ "informal": "Informal", "copied": "Copiado!", "copy": "Copiar", + "copyPhrase": "Copiar frase", "noResults": "Nenhuma frase corresponde aos filtros", "level": "Nível", "register": "Registo", @@ -1582,6 +1587,8 @@ "noEntries": "Nada aqui ainda. Seja o primeiro a enviar!", "votes": "{count} votos", "vote": "1 voto", + "voteAction": "Votar", + "removeVoteAction": "Retirar voto", "comments": "{count} comentários", "comment": "1 comentário", "by": "por", diff --git a/messages/ro.json b/messages/ro.json index ebb8a8949..7cc6ec4f2 100644 --- a/messages/ro.json +++ b/messages/ro.json @@ -78,6 +78,9 @@ "backToDashboard": "Înapoi la panou", "tagline": "Educație lingvistică AI", "close": "Închide", + "delete": "Șterge", + "openMenu": "Deschide meniul", + "closeMenu": "Închide meniul", "verifyEmailBanner": "Vă rugăm să vă verificați adresa de e-mail pentru a finaliza configurarea contului.", "verifyEmailSent": "E-mail trimis. Verificați inbox-ul.", "resendVerification": "Retrimite e-mail de verificare", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Arată chat-urile", "toggleSidebarHide": "Ascunde chat-urile", "conversations": "Conversații", + "voiceSession": "Sesiune vocală", "loading": "Se încarcă...", "thinking": "Se gândește...", "takingLonger": "Durează mai mult decât de obicei...", @@ -848,6 +852,7 @@ "informal": "Informal", "copied": "Copiat!", "copy": "Copiază", + "copyPhrase": "Copiază fraza", "noResults": "Nicio frază nu corespunde filtrelor tale", "level": "Nivel", "register": "Registru", @@ -1582,6 +1587,8 @@ "noEntries": "Nimic aici încă. Fii primul care trimite ceva!", "votes": "{count} voturi", "vote": "1 vot", + "voteAction": "Votează", + "removeVoteAction": "Retrage votul", "comments": "{count} comentarii", "comment": "1 comentariu", "by": "de", diff --git a/messages/ru.json b/messages/ru.json index e08850a7c..d466350b3 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -78,6 +78,9 @@ "backToDashboard": "На главную", "tagline": "ИИ-обучение языкам", "close": "Закрыть", + "delete": "Удалить", + "openMenu": "Открыть меню", + "closeMenu": "Закрыть меню", "verifyEmailBanner": "Пожалуйста, подтвердите свой адрес электронной почты для завершения настройки аккаунта.", "verifyEmailSent": "Письмо отправлено. Проверьте входящие.", "resendVerification": "Повторно отправить письмо для подтверждения", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Показать чаты", "toggleSidebarHide": "Скрыть чаты", "conversations": "Разговоры", + "voiceSession": "Голосовой сеанс", "loading": "Загрузка...", "thinking": "Думает...", "takingLonger": "Это занимает больше времени...", @@ -848,6 +852,7 @@ "informal": "Неформальный", "copied": "Скопировано!", "copy": "Копировать", + "copyPhrase": "Скопировать фразу", "noResults": "Нет фраз, соответствующих фильтрам", "level": "Уровень", "register": "Регистр", @@ -1582,6 +1587,8 @@ "noEntries": "Пока ничего нет. Будьте первым!", "votes": "{count} голосов", "vote": "1 голос", + "voteAction": "Голосовать", + "removeVoteAction": "Отменить голос", "comments": "{count} комментариев", "comment": "1 комментарий", "by": "от", diff --git a/messages/tr.json b/messages/tr.json index c9c067bcb..9a59ad1d3 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -78,6 +78,9 @@ "backToDashboard": "Panoya dön", "tagline": "Yapay zekâ ile dil öğrenimi", "close": "Kapat", + "delete": "Sil", + "openMenu": "Menüyü aç", + "closeMenu": "Menüyü kapat", "verifyEmailBanner": "Hesap kurulumunuzu tamamlamak için lütfen e-posta adresinizi doğrulayın.", "verifyEmailSent": "E-posta gönderildi. Gelen kutunuzu kontrol edin.", "resendVerification": "Doğrulama e-postasını tekrar gönder", @@ -761,6 +764,7 @@ "toggleSidebarShow": "Sohbetleri göster", "toggleSidebarHide": "Sohbetleri gizle", "conversations": "Konuşmalar", + "voiceSession": "Sesli oturum", "loading": "Yükleniyor...", "thinking": "Düşünüyor...", "takingLonger": "Beklenenden uzun sürüyor...", @@ -848,6 +852,7 @@ "informal": "Gündelik", "copied": "Kopyalandı!", "copy": "Kopyala", + "copyPhrase": "İfadeyi kopyala", "noResults": "Filtrelerinizle eşleşen ifade yok", "level": "Seviye", "register": "Üslup", @@ -1582,6 +1587,8 @@ "noEntries": "Burada henüz bir şey yok. İlk gönderen siz olun.", "votes": "{count} oy", "vote": "1 oy", + "voteAction": "Oy ver", + "removeVoteAction": "Oyu geri çek", "comments": "{count} yorum", "comment": "1 yorum", "by": "Gönderen:", diff --git a/specs/architecture-frontend.instructions.md b/specs/architecture-frontend.instructions.md index 33195b804..fd974ada4 100644 --- a/specs/architecture-frontend.instructions.md +++ b/specs/architecture-frontend.instructions.md @@ -118,6 +118,8 @@ fall back to English according to the platform contract. Locale selection, profile persistence, and cookies are coordinated by Settings and middleware. A target-language switch must not mutate UI locale or global account preferences. +Visible tooltips, control titles, accessibility labels, and screen-reader-only text use the active +UI locale's catalog rather than fixed English strings. ## Streaming and media diff --git a/specs/reviews.instructions.md b/specs/reviews.instructions.md index a9b770183..94aa3ce39 100644 --- a/specs/reviews.instructions.md +++ b/specs/reviews.instructions.md @@ -116,6 +116,7 @@ not. When the prompt opens, it requests `/api/reviews/me`: +- a loading state blocks the form until the lookup completes, including when the prompt reopens; - an existing review suppresses the prompt; - a successful no-review response shows the form; - a failed lookup shows an error-only state and prevents submission. From 89c97ecb24bfc346ec4f95ff68a19019da0f5149 Mon Sep 17 00:00:00 2001 From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:13 +0200 Subject: [PATCH 03/14] Add Swedish locale support across the stack - Add Swedish to supported UI, native language, and dashboard banner locale lists - Extend backend email, lesson feedback, voice session, and month-name localization for sv - Update dashboard banner translation prompts and schemas to require twelve locales - Add Swedish translations to message catalogs and refresh Whats New copy - Update frontend locale detection, admin UI options, and registration/profile language pickers - Add tests covering Swedish locale handling, banner translation, and middleware detection --- CHANGELOG.md | 6 +- README.md | 2 +- backend/app/routers/admin_dashboard_banner.py | 4 +- backend/app/routers/lessons.py | 7 + backend/app/schemas/admin.py | 1 + backend/app/schemas/auth.py | 2 + backend/app/schemas/dashboard_banner.py | 4 +- backend/app/services/email_service.py | 66 + backend/app/services/language_helpers.py | 16 + backend/tests/test_auth.py | 13 + backend/tests/test_conversation.py | 10 +- backend/tests/test_dashboard_banner.py | 37 +- frontend/src/app/(app)/admin/system/page.tsx | 1 + 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 + .../tests/app/admin-system-banner.test.tsx | 26 +- .../app/onboarding-goals-subtitle.test.tsx | 4 +- .../tests/components/ProfileSection.test.tsx | 2 + frontend/tests/i18n/admin-messages.test.ts | 3 +- frontend/tests/middleware.test.ts | 9 + messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/nl.json | 8 +- messages/pl.json | 8 +- messages/pt.json | 8 +- messages/ro.json | 8 +- messages/ru.json | 8 +- messages/sv.json | 1697 +++++++++++++++++ messages/tr.json | 8 +- specs/api-endpoints.instructions.md | 8 +- specs/database-models.instructions.md | 4 +- specs/platform.instructions.md | 2 +- specs/version.md | 2 +- specs/whats-new.instructions.md | 2 +- 40 files changed, 1963 insertions(+), 59 deletions(-) create mode 100644 messages/sv.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f43fe2f..a1d430b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - Turkish (`tr`) interface language and native-language option in registration, Settings, and administration, including localized learning feedback, voice-session titles, and transactional emails. - Dashboard announcements support Turkish as a source and translation language, alongside the existing ten languages. +- Swedish (`sv`) interface language and native-language option, with localized emails, lesson feedback, and voice-session titles; Swedish is not a study language. +- Dashboard announcements support Swedish as a source and translation language. ### Changed -- Existing dashboard announcements remain readable with ten translations; adding Turkish is required before saving them again and creates a new announcement revision. -- What's New now highlights the Turkish interface and retains the general fixes note in all eleven interface languages. +- Existing dashboard announcements with ten or eleven translations remain readable; a new save requires all twelve, and adding a missing language creates a new announcement revision. +- What's New previews five interface languages (Croatian, Danish, Finnish, Swedish, and Turkish) in alphabetical order for each of the twelve current interface locales, alongside the general fixes note. ### Fixed diff --git a/README.md b/README.md index 9b25483fe..efdc65ca2 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ freelingo/ ├── data/ # Shared data files ├── docs/ # GitHub Pages landing site ├── frontend/ # Next.js (React) -├── messages/ # i18n translation files (de, en, es, fr, it, nl, pl, pt, ro, ru) +├── messages/ # i18n catalogs (de, en, es, fr, it, nl, pl, pt, ro, ru, sv, tr) ├── specs/ # Specification files ├── AGENTS.md # AI assistant instructions ├── CHANGELOG.md # Version history diff --git a/backend/app/routers/admin_dashboard_banner.py b/backend/app/routers/admin_dashboard_banner.py index e4f3dd30f..41df26749 100644 --- a/backend/app/routers/admin_dashboard_banner.py +++ b/backend/app/routers/admin_dashboard_banner.py @@ -19,8 +19,8 @@ router = APIRouter(prefix="/api/admin/dashboard-banner", tags=["admin"]) -_TRANSLATION_PROMPT = """Translate this dashboard announcement faithfully into all eleven requested locales. -Return en, es, fr, pt, de, it, ru, nl, pl, ro, and tr. Preserve the original tone and meaning. +_TRANSLATION_PROMPT = """Translate this dashboard announcement faithfully into all twelve requested locales. +Return en, es, fr, pt, de, it, ru, nl, pl, ro, tr, and sv. Preserve the original tone and meaning. Do not add claims, details, formatting, Markdown, or HTML. Every field must contain plain text only. For the source locale, copy each provided field exactly. diff --git a/backend/app/routers/lessons.py b/backend/app/routers/lessons.py index d1a54e39c..29727b645 100644 --- a/backend/app/routers/lessons.py +++ b/backend/app/routers/lessons.py @@ -126,6 +126,13 @@ "good_pronunciation": "Telaffuzunuz iyi!", "target_phrase": "Hedef ifade şuydu: {answer}", }, + "sv": { + "correct": "Rätt!", + "correct_answer": "Rätt svar: {answer}", + "free_write_unavailable": "Det gick inte att bedöma det skriftliga svaret just nu.", + "good_pronunciation": "Bra uttal!", + "target_phrase": "Målfrasen var: {answer}", + }, } diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 22186699b..e68175c0f 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -24,6 +24,7 @@ "pl", "ro", "tr", + "sv", } diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index caba7eb03..33f586917 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -30,6 +30,7 @@ def validate_password_strength(v: str) -> str: "pl", "ro", "tr", + "sv", } SUPPORTED_TARGET_LANGUAGES: set[str] = { @@ -67,6 +68,7 @@ def get_available_languages() -> list[str]: "ro", "ru", "tr", + "sv", } VALID_LEARNING_GOALS: set[str] = { diff --git a/backend/app/schemas/dashboard_banner.py b/backend/app/schemas/dashboard_banner.py index 51418c663..9b26c4bbd 100644 --- a/backend/app/schemas/dashboard_banner.py +++ b/backend/app/schemas/dashboard_banner.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -DashboardBannerLocale = Literal["en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr"] +DashboardBannerLocale = Literal["en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr", "sv"] class DashboardBannerTranslation(BaseModel): @@ -38,10 +38,12 @@ class DashboardBannerStoredTranslations(BaseModel): pl: DashboardBannerTranslation ro: DashboardBannerTranslation tr: DashboardBannerTranslation | None = None + sv: DashboardBannerTranslation | None = None class DashboardBannerTranslations(DashboardBannerStoredTranslations): tr: DashboardBannerTranslation + sv: DashboardBannerTranslation class DashboardBannerTranslateRequest(DashboardBannerTranslation): diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 7bf6d731a..437fe2d18 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -130,6 +130,14 @@ def _get_mail_config() -> ConnectionConfig: "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.", }, + "sv": { + "greeting": "Hej {name},", + "subject": "Bekräfta ditt FreeLingo-konto", + "body": "Tack för att du skapade ett FreeLingo-konto.
Bekräfta din e-postadress genom att klicka på knappen nedan. Länken är giltig i 24 timmar.", + "button": "Bekräfta mitt konto", + "link_fallback": "Om knappen inte fungerar kan du kopiera länken och klistra in den i din webbläsare:", + "footer": "Om du inte har skapat kontot kan du ignorera det här e-postmeddelandet.", + }, } _RESET_I18N: dict[str, dict[str, str]] = { @@ -221,6 +229,14 @@ def _get_mail_config() -> ConnectionConfig: "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.", }, + "sv": { + "greeting": "Hej {name},", + "subject": "Återställ ditt FreeLingo-lösenord", + "body": "Vi har fått en begäran om att återställa lösenordet för ditt FreeLingo-konto.
Klicka på knappen nedan för att skapa ett nytt lösenord. Länken är giltig i 1 timme.", + "button": "Återställ mitt lösenord", + "link_fallback": "Om knappen inte fungerar kan du kopiera länken och klistra in den i din webbläsare:", + "footer": "Om du inte har begärt en lösenordsåterställning kan du ignorera det här e-postmeddelandet.", + }, } _WELCOME_I18N: dict[str, dict[str, str]] = { @@ -334,6 +350,16 @@ def _get_mail_config() -> ConnectionConfig: "button": "Panoma git", "footer": "\u0130yi \u00e7al\u0131\u015fmalar!", }, + "sv": { + "subject": "Välkommen till FreeLingo!", + "greeting": "Hej {name},", + "body": "Ditt konto är redo. Så här kommer du igång:", + "step1": "Gör nivåbedömningen – den visar din nuvarande nivå (A1–C2) samt dina styrkor och svagheter.", + "step2": "Skapa din studieplan – en plan vecka för vecka anpassad till din nivå och dina mål.", + "step3": "Slutför din första dagliga lektion och börja bygga din svit.", + "button": "Gå till min översikt", + "footer": "Lycka till med studierna!", + }, } @@ -404,6 +430,12 @@ def _get_mail_config() -> ConnectionConfig: "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.", }, + "sv": { + "subject": "Ditt FreeLingo-konto har raderats", + "greeting": "Hej {name},", + "body": "Ditt FreeLingo-konto har raderats. Alla dina uppgifter har tagits bort permanent från våra servrar.", + "footer": "Om du inte har begärt att kontot ska raderas ska du kontakta oss omedelbart.", + }, } _CONTACT_I18N: dict[str, dict[str, str]] = { @@ -506,6 +538,15 @@ def _get_mail_config() -> ConnectionConfig: "message_label": "Mesaj", "footer": "FreeLingo iletişim formu aracılığıyla gönderildi", }, + "sv": { + "email_title": "Meddelande från kontaktformuläret", + "subject_prefix": "[FreeLingo Kontakt]", + "logo": "FreeLingo: Kontaktformulär", + "from_label": "Från", + "subject_label": "Ämne", + "message_label": "Meddelande", + "footer": "Skickat via FreeLingos kontaktformulär", + }, } _FEEDBACK_I18N: dict[str, dict[str, str]] = { @@ -652,6 +693,19 @@ def _get_mail_config() -> ConnectionConfig: "cta": "Yönetim panelinde görüntüle", "footer": "FreeLingo geri bildirim panosu", }, + "sv": { + "email_title": "Ny feedback har skickats in", + "logo": "FreeLingo: Ny feedback", + "feature_label": "Funktionsförslag", + "bug_label": "Felrapport", + "feature_subject_prefix": "[Funktionsförslag]", + "bug_subject_prefix": "[Felrapport]", + "author_label": "Skickat av", + "title_label": "Titel", + "description_label": "Beskrivning", + "cta": "Visa i administrationspanelen", + "footer": "FreeLingos feedbacktavla", + }, } _REVIEW_I18N: dict[str, dict[str, str]] = { @@ -787,6 +841,18 @@ def _get_mail_config() -> ConnectionConfig: "cta": "Yönetim panelinde görüntüle", "footer": "FreeLingo yorum moderasyonu", }, + "sv": { + "email_title": "Nytt omdöme har skickats in", + "subject_prefix": "[Nytt omdöme]", + "logo": "FreeLingo: Nytt omdöme", + "author_label": "Skickat av", + "rating_label": "Betyg", + "language_label": "Språk som lärs in", + "comment_label": "Kommentar", + "empty_comment": "Ingen kommentar lämnades.", + "cta": "Visa i administrationspanelen", + "footer": "Moderering av FreeLingo-omdömen", + }, } diff --git a/backend/app/services/language_helpers.py b/backend/app/services/language_helpers.py index 79e55c00d..e73ac9ec5 100644 --- a/backend/app/services/language_helpers.py +++ b/backend/app/services/language_helpers.py @@ -136,6 +136,7 @@ "ro": "Sesiune vocală", "ru": "Голосовая сессия", "tr": "Sesli oturum", + "sv": "Röstsamtal", } _NATIVE_LANGUAGE_NAMES: dict[str, str] = { @@ -150,6 +151,7 @@ "ro": "Romanian", "ru": "Russian", "tr": "Turkish", + "sv": "Swedish", } _MONTH_NAMES: dict[str, list[str]] = { @@ -293,6 +295,20 @@ "Kasım", "Aralık", ], + "sv": [ + "januari", + "februari", + "mars", + "april", + "maj", + "juni", + "juli", + "augusti", + "september", + "oktober", + "november", + "december", + ], } diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 911541ad7..cb6d0647b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -647,6 +647,19 @@ async def test_patch_me_ui_locale_empty_string_clears(client, test_user): assert response.json()["ui_locale"] is None +@pytest.mark.asyncio +async def test_patch_me_accepts_swedish_ui_locale_and_native_language(client, test_user): + _, headers = test_user + response = await client.patch( + "/api/auth/me", + headers=headers, + json={"ui_locale": "sv", "native_language": "sv"}, + ) + assert response.status_code == 200 + assert response.json()["ui_locale"] == "sv" + assert response.json()["native_language"] == "sv" + + @pytest.mark.asyncio async def test_register_unsupported_native_language(client): """Registration must reject native_language codes outside SUPPORTED_LANGUAGES.""" diff --git a/backend/tests/test_conversation.py b/backend/tests/test_conversation.py index 86867b385..444e38cd4 100644 --- a/backend/tests/test_conversation.py +++ b/backend/tests/test_conversation.py @@ -587,11 +587,19 @@ def test_voice_session_title_turkish() -> None: assert " de " not in date_part +def test_voice_session_title_swedish() -> None: + from app.services.language_helpers import voice_session_title + + title = voice_session_title("sv") + assert title.startswith("Röstsamtal — ") + assert " de " not in title + + 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", "tr"] + supported = ["es", "fr", "pt", "de", "it", "pl", "nl", "ro", "ru", "tr", "sv"] for lang in supported: title = voice_session_title(lang) assert title.strip(), f"Empty title for language '{lang}'" diff --git a/backend/tests/test_dashboard_banner.py b/backend/tests/test_dashboard_banner.py index 3f22f0f7d..62718f1ca 100644 --- a/backend/tests/test_dashboard_banner.py +++ b/backend/tests/test_dashboard_banner.py @@ -6,7 +6,7 @@ from app.schemas.dashboard_banner import DashboardBannerTranslationResponse from app.services.llm_adapter import LLMError -LOCALES = ("en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr") +LOCALES = ("en", "es", "fr", "pt", "de", "it", "ru", "nl", "pl", "ro", "tr", "sv") def banner_translations(label: str = "Notice") -> dict[str, dict[str, str]]: @@ -53,6 +53,7 @@ async def test_existing_ten_locale_banner_remains_readable_until_turkish_is_adde _, headers = admin_user legacy_translations = banner_translations() legacy_translations.pop("tr") + legacy_translations.pop("sv") db_session.add( DashboardBanner( id=1, @@ -83,6 +84,37 @@ async def test_existing_ten_locale_banner_remains_readable_until_turkish_is_adde assert missing_tr.status_code == 422 +@pytest.mark.asyncio +async def test_existing_eleven_locale_banner_remains_readable_until_swedish_is_added( + client, admin_user, db_session +): + _, headers = admin_user + legacy_translations = banner_translations() + legacy_translations.pop("sv") + db_session.add( + DashboardBanner( + id=1, + source_locale="en", + is_active=True, + translations=legacy_translations, + revision=5, + ) + ) + await db_session.commit() + + response = await client.get("/api/admin/dashboard-banner", headers=headers) + assert response.status_code == 200 + assert response.json()["translations"] == legacy_translations + assert response.json()["revision"] == 5 + + missing_sv = await client.put( + "/api/admin/dashboard-banner", + headers=headers, + json=banner_update(translations=legacy_translations), + ) + assert missing_sv.status_code == 422 + + @pytest.mark.asyncio async def test_dashboard_banner_admin_endpoints_require_admin(client, test_user): _, user_headers = test_user @@ -147,6 +179,7 @@ async def fake_structured_output(messages, schema): "description": "Service returns shortly.", } assert "tr" in translations + assert "sv" in translations # Translation is a preview and must not create the singleton. banner_response = await client.get("/api/admin/dashboard-banner", headers=headers) @@ -159,7 +192,7 @@ async def test_translate_banner_accepts_turkish_source(client, admin_user, monke generated = banner_translations("Generated") async def fake_structured_output(messages, schema): - assert "all eleven requested locales" in messages[0]["content"] + assert "all twelve requested locales" in messages[0]["content"] assert "Source locale: tr" in messages[0]["content"] return schema(translations=generated) diff --git a/frontend/src/app/(app)/admin/system/page.tsx b/frontend/src/app/(app)/admin/system/page.tsx index 2a8634529..b0d977306 100644 --- a/frontend/src/app/(app)/admin/system/page.tsx +++ b/frontend/src/app/(app)/admin/system/page.tsx @@ -22,6 +22,7 @@ const BANNER_LOCALES = [ 'pl', 'ro', 'tr', + 'sv', ] as const type BannerLocale = (typeof BANNER_LOCALES)[number] diff --git a/frontend/src/app/(app)/admin/users/page.tsx b/frontend/src/app/(app)/admin/users/page.tsx index 19d09c145..c4e47edbf 100644 --- a/frontend/src/app/(app)/admin/users/page.tsx +++ b/frontend/src/app/(app)/admin/users/page.tsx @@ -55,6 +55,7 @@ const LANGUAGES = [ 'ro', 'ru', 'tr', + 'sv', ] 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 ec6236bd5..cf01c69d0 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -23,6 +23,7 @@ const LANGUAGES = [ 'ro', 'ru', 'tr', + 'sv', ] as const type SelectedPlan = 'monthly' | 'yearly' diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index c43d4928d..8b9299777 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -115,7 +115,7 @@ export default async function RootLayout({