([])
@@ -494,7 +495,7 @@ export default function AdminFeedbackPage() {
{t('by')} {entry.author.display_name}
- · {formatDate(entry.created_at)}
+ · {formatDate(entry.created_at, locale)}
@@ -595,7 +596,7 @@ export default function AdminFeedbackPage() {
{t('by')} {entry.author.display_name}
- · {formatDate(entry.created_at)}
+ · {formatDate(entry.created_at, locale)}
diff --git a/frontend/src/app/(app)/admin/reviews/page.tsx b/frontend/src/app/(app)/admin/reviews/page.tsx
index ac939c972..eded76cab 100644
--- a/frontend/src/app/(app)/admin/reviews/page.tsx
+++ b/frontend/src/app/(app)/admin/reviews/page.tsx
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import {
CheckCircle2,
FilterX,
@@ -32,8 +32,8 @@ const PAGE_SIZE = 10
type ApprovalFilter = 'all' | 'pending' | 'approved'
-function formatDate(iso: string): string {
- return new Date(iso).toLocaleDateString(undefined, {
+function formatDate(iso: string, locale: string): string {
+ return new Date(iso).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
@@ -51,6 +51,7 @@ function stars(rating: number): string {
export default function AdminReviewsPage() {
const t = useTranslations('adminReviews')
const tAdmin = useTranslations('admin')
+ const locale = useLocale()
const [reviews, setReviews] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
@@ -264,7 +265,7 @@ export default function AdminReviewsPage() {
{t('learningLanguage', {
language: languageLabel(review.target_language),
})}{' '}
- · {formatDate(review.created_at)}
+ · {formatDate(review.created_at, locale)}
{review.comment || t('ratingOnly')}
diff --git a/frontend/src/app/(app)/admin/system/page.tsx b/frontend/src/app/(app)/admin/system/page.tsx
index d6f83e75f..68ba08326 100644
--- a/frontend/src/app/(app)/admin/system/page.tsx
+++ b/frontend/src/app/(app)/admin/system/page.tsx
@@ -310,9 +310,11 @@ export default function AdminSystemPage() {
- setSourceLocale(event.target.value as BannerLocale)
- }
+ onChange={(event) => {
+ const locale = event.target.value as BannerLocale
+ setSourceLocale(locale)
+ setSource({ ...translations[locale] })
+ }}
className="border-fl-border bg-fl-bg text-fl-fg w-full border px-3 py-2"
>
{BANNER_LOCALES.map((locale) => (
diff --git a/frontend/src/app/(app)/admin/users/[id]/page.tsx b/frontend/src/app/(app)/admin/users/[id]/page.tsx
index 521ef0ff6..ee0b12284 100644
--- a/frontend/src/app/(app)/admin/users/[id]/page.tsx
+++ b/frontend/src/app/(app)/admin/users/[id]/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import {
Activity,
BadgeCheck,
@@ -157,6 +157,7 @@ function subscriptionStatusClass(status: string) {
}
export default function AdminUserStatsPage() {
+ const locale = useLocale()
const t = useTranslations('admin')
const tLang = useTranslations('languages')
const tBilling = useTranslations('billing')
@@ -433,7 +434,7 @@ export default function AdminUserStatsPage() {
- {t('statsXp')}: {pl.xp_total.toLocaleString()}
+ {t('statsXp')}: {pl.xp_total.toLocaleString(locale)}
{t('statsStreak')}: {pl.streak_current} {t('statsDays')}
@@ -615,7 +616,7 @@ export default function AdminUserStatsPage() {
>
)}
@@ -756,7 +757,9 @@ export default function AdminUserStatsPage() {
{user.subscription_ends_at && (
)}
{user.stripe_customer_id && (
diff --git a/frontend/src/app/(app)/assessment/page.tsx b/frontend/src/app/(app)/assessment/page.tsx
index 822fee619..a03097143 100644
--- a/frontend/src/app/(app)/assessment/page.tsx
+++ b/frontend/src/app/(app)/assessment/page.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { apiFetch } from '@/lib/api'
import { useLanguageStore } from '@/store/language'
import { isSubscribed, useAuthStore } from '@/store/auth'
@@ -87,6 +87,7 @@ function adjustLevel(current: CEFRLevel, direction: 'up' | 'down'): CEFRLevel {
export default function AssessmentPage() {
const t = useTranslations('assessment')
const tCommon = useTranslations('common')
+ const locale = useLocale()
const router = useRouter()
const activeLanguage = useLanguageStore((s) => s.activeLanguage)
const user = useAuthStore((s) => s.user)
@@ -380,7 +381,7 @@ export default function AssessmentPage() {
// ── Existing plan ─────────────────────────────────────────────────────────
if (step === 'existing' && existingPlan) {
const assessedDate = new Date(existingPlan.created_at).toLocaleDateString(
- undefined,
+ locale,
{
year: 'numeric',
month: 'long',
diff --git a/frontend/src/app/(app)/feedback/page.tsx b/frontend/src/app/(app)/feedback/page.tsx
index 11c98c51f..994fb2ed8 100644
--- a/frontend/src/app/(app)/feedback/page.tsx
+++ b/frontend/src/app/(app)/feedback/page.tsx
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { apiFetch } from '@/lib/api'
import { useAuthStore } from '@/store/auth'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
@@ -59,8 +59,8 @@ const STATUS_STYLES: Record = {
// Helpers
// ---------------------------------------------------------------------------
-function formatDate(iso: string): string {
- return new Date(iso).toLocaleDateString(undefined, {
+function formatDate(iso: string, locale: string): string {
+ return new Date(iso).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
@@ -258,6 +258,7 @@ function DetailView({
onEntryDeleted,
}: DetailViewProps) {
const t = useTranslations('feedback')
+ const locale = useLocale()
const [entry, setEntry] = useState(initialEntry)
const [comments, setComments] = useState([])
const [commentBody, setCommentBody] = useState('')
@@ -375,7 +376,7 @@ function DetailView({
{t('by')} {entry.author.display_name}
-
· {formatDate(entry.created_at)}
+
· {formatDate(entry.created_at, locale)}
{/* Vote button — only for features */}
{entry.type === 'feature' && (
@@ -416,7 +417,7 @@ function DetailView({
{c.author.display_name}
- · {formatDate(c.created_at)}
+ · {formatDate(c.created_at, locale)}
{currentUserId === c.author.id && (
s.user?.id)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
@@ -810,7 +812,7 @@ export default function FeedbackPage() {
{t('by')} {entry.author.display_name}
- · {formatDate(entry.created_at)}
+ · {formatDate(entry.created_at, locale)}
{entry.comment_count > 0 && (
diff --git a/frontend/src/app/(app)/flashcards/page.tsx b/frontend/src/app/(app)/flashcards/page.tsx
index 146589f7d..9cb9c5b53 100644
--- a/frontend/src/app/(app)/flashcards/page.tsx
+++ b/frontend/src/app/(app)/flashcards/page.tsx
@@ -276,7 +276,7 @@ export default function FlashcardsPage() {
<>
- {current + 1} / {cards.length} due
+ {current + 1} / {cards.length} {t('due')}
{/* Mode toggle */}
diff --git a/frontend/src/app/(app)/grammar/page.tsx b/frontend/src/app/(app)/grammar/page.tsx
index 6c774d0be..1ec926f31 100644
--- a/frontend/src/app/(app)/grammar/page.tsx
+++ b/frontend/src/app/(app)/grammar/page.tsx
@@ -121,7 +121,7 @@ export default function GrammarIndexPage() {
diff --git a/frontend/src/app/(app)/progress/page.tsx b/frontend/src/app/(app)/progress/page.tsx
index ab532057f..6fbcb34e4 100644
--- a/frontend/src/app/(app)/progress/page.tsx
+++ b/frontend/src/app/(app)/progress/page.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { PageLoading } from '@/components/ui/page-loading'
import { apiFetch } from '@/lib/api'
import { useLanguageStore } from '@/store/language'
@@ -156,6 +156,7 @@ function UnitCompetencyBlock({
// ── Page ──────────────────────────────────────────────────────────────────────
export default function ProgressPage() {
+ const locale = useLocale()
const t = useTranslations('progress')
const tVocab = useTranslations('vocabulary')
const activeLanguage = useLanguageStore((s) => s.activeLanguage)
@@ -259,8 +260,11 @@ export default function ProgressPage() {
{summary && (
{[
- { label: t('xp'), value: summary.total_xp.toLocaleString() },
- { label: t('streak'), value: `${summary.current_streak}d 🔥` },
+ {
+ label: t('xp'),
+ value: summary.total_xp.toLocaleString(locale),
+ },
+ { label: t('streak'), value: `${summary.current_streak} 🔥` },
{ label: t('lessons'), value: summary.total_lessons },
{
label: t('accuracy'),
diff --git a/frontend/src/app/(app)/settings/languages/page.tsx b/frontend/src/app/(app)/settings/languages/page.tsx
index 797410cba..6c7b27666 100644
--- a/frontend/src/app/(app)/settings/languages/page.tsx
+++ b/frontend/src/app/(app)/settings/languages/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { useLanguageStore } from '@/store/language'
import {
getLanguageByCode,
@@ -16,6 +16,7 @@ import { PageLoading } from '@/components/ui/page-loading'
import type { UserLanguageInfo } from '@/store/language'
export default function MyLanguagesPage() {
+ const locale = useLocale()
const t = useTranslations('languages')
const tTarget = useTranslations('targetLanguages')
const tSettings = useTranslations('settings')
@@ -207,7 +208,8 @@ export default function MyLanguagesPage() {
{progress && (
<>
- {t('xpLabel')}: {progress.total_xp.toLocaleString()}
+ {t('xpLabel')}:{' '}
+ {progress.total_xp.toLocaleString(locale)}
{t('streakLabel')}: {progress.current_streak}d
diff --git a/frontend/src/app/(app)/vocabulary/page.tsx b/frontend/src/app/(app)/vocabulary/page.tsx
index 8da9aa33c..ef98ebe9e 100644
--- a/frontend/src/app/(app)/vocabulary/page.tsx
+++ b/frontend/src/app/(app)/vocabulary/page.tsx
@@ -83,7 +83,7 @@ export default function VocabularyIndexPage() {
- {vocabSets.length} {t('sets')} · {totalWords} {t('words')}
+ {t('sets', { count: vocabSets.length })} · {totalWords} {t('words')}
{usedLevels.length > 0 && (
<>
{' · '}
@@ -142,7 +142,7 @@ export default function VocabularyIndexPage() {
- {sets.length} set{sets.length !== 1 ? 's' : ''} ·{' '}
+ {t('sets', { count: sets.length })} ·{' '}
{sets.reduce((a, s) => a + s.words.length, 0)} {t('words')}
diff --git a/frontend/src/app/(auth)/onboarding/page.tsx b/frontend/src/app/(auth)/onboarding/page.tsx
index ba841744a..fcd351fe9 100644
--- a/frontend/src/app/(auth)/onboarding/page.tsx
+++ b/frontend/src/app/(auth)/onboarding/page.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { splitYearlyCta, type BillingInterval } from '@/lib/billing-copy'
@@ -38,6 +38,7 @@ export default function OnboardingPage() {
const t = useTranslations('onboarding')
const tCommon = useTranslations('common')
const tLang = useTranslations('targetLanguages')
+ const locale = useLocale()
const router = useRouter()
const searchParams = useSearchParams()
const setUser = useAuthStore((s) => s.setUser)
@@ -171,7 +172,7 @@ export default function OnboardingPage() {
const trialEligible = !user?.trial_used
const yearlyCta = splitYearlyCta(
t(trialEligible ? 'trialCtaYearly' : 'trialCtaYearlyTrialUsed', {
- price: String(priceYearly),
+ price: new Intl.NumberFormat(locale).format(priceYearly),
})
)
@@ -395,7 +396,9 @@ export default function OnboardingPage() {
? 'trialCtaMonthly'
: 'trialCtaMonthlyTrialUsed',
{
- price: String(priceMonthly),
+ price: new Intl.NumberFormat(locale).format(
+ priceMonthly
+ ),
}
)
) : (
diff --git a/frontend/src/components/billing/PaywallBanner.tsx b/frontend/src/components/billing/PaywallBanner.tsx
index 41d44b1ba..f36ee0c07 100644
--- a/frontend/src/components/billing/PaywallBanner.tsx
+++ b/frontend/src/components/billing/PaywallBanner.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import {
BookOpen,
GraduationCap,
@@ -55,6 +55,7 @@ export function PaywallBanner({
compact = false,
}: PaywallBannerProps) {
const t = useTranslations('billing')
+ const locale = useLocale()
const router = useRouter()
const user = useAuthStore((s) => s.user)
const stripeEnabled = useConfigStore((s) => s.stripeEnabled)
@@ -66,7 +67,9 @@ export function PaywallBanner({
const [error, setError] = useState
(null)
const paymentRecovery = needsPaymentRecovery(user)
const yearlyCta = splitYearlyCta(
- t('planYearly', { price: String(priceYearly) })
+ t('planYearly', {
+ price: new Intl.NumberFormat(locale).format(priceYearly),
+ })
)
if (!stripeEnabled || isSubscribed(user, stripeEnabled)) return null
@@ -187,7 +190,9 @@ export function PaywallBanner({
>
{loading === 'monthly'
? '...'
- : t('planMonthly', { price: String(priceMonthly) })}
+ : t('planMonthly', {
+ price: new Intl.NumberFormat(locale).format(priceMonthly),
+ })}
)}
diff --git a/frontend/src/components/billing/PricingSection.tsx b/frontend/src/components/billing/PricingSection.tsx
index 8f4c7a7da..497b7e2f6 100644
--- a/frontend/src/components/billing/PricingSection.tsx
+++ b/frontend/src/components/billing/PricingSection.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { useEffect, useState } from 'react'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { Circle, CircleDot, Diamond, Check, Minus } from 'lucide-react'
import { PageLoading } from '@/components/ui/page-loading'
import { getLandingSubscriptionState } from '@/lib/landing-subscription'
@@ -34,6 +34,7 @@ export default function PricingSection({
}: PricingSectionProps) {
const tBilling = useTranslations('billing')
const tLanding = useTranslations('landing')
+ const locale = useLocale()
const [subscribed, setSubscribed] = useState
(
hasSession ? null : false
)
@@ -223,12 +224,18 @@ export default function PricingSection({
<>
{tBilling('priceOriginal', {
- price: plan.originalPrice,
+ price: new Intl.NumberFormat(locale).format(
+ plan.originalPrice
+ ),
period: plan.priceLabel,
})}
- {tBilling('priceAmount', { amount: plan.price })}
+ {tBilling('priceAmount', {
+ amount: new Intl.NumberFormat(locale).format(
+ plan.price
+ ),
+ })}
{tBilling('pricePerPeriod', {
period: plan.priceLabel,
diff --git a/frontend/src/components/billing/SubscriptionPlanButtons.tsx b/frontend/src/components/billing/SubscriptionPlanButtons.tsx
index 7b189a62e..30dd8a7b2 100644
--- a/frontend/src/components/billing/SubscriptionPlanButtons.tsx
+++ b/frontend/src/components/billing/SubscriptionPlanButtons.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { apiFetch } from '@/lib/api'
import { splitYearlyCta, type BillingInterval } from '@/lib/billing-copy'
import { useConfigStore } from '@/store/config'
@@ -14,12 +14,15 @@ export function SubscriptionPlanButtons({
className = '',
}: SubscriptionPlanButtonsProps) {
const tBilling = useTranslations('billing')
+ const locale = useLocale()
const priceMonthly = useConfigStore((s) => s.priceMonthly)
const priceYearly = useConfigStore((s) => s.priceYearly)
const [loading, setLoading] = useState(null)
const [error, setError] = useState(null)
const yearlyCta = splitYearlyCta(
- tBilling('planYearly', { price: String(priceYearly) })
+ tBilling('planYearly', {
+ price: new Intl.NumberFormat(locale).format(priceYearly),
+ })
)
async function startCheckout(plan: BillingInterval) {
@@ -74,7 +77,9 @@ export function SubscriptionPlanButtons({
>
{loading === 'monthly'
? '...'
- : tBilling('planMonthly', { price: String(priceMonthly) })}
+ : tBilling('planMonthly', {
+ price: new Intl.NumberFormat(locale).format(priceMonthly),
+ })}
{error && {error}
}
diff --git a/frontend/src/components/conversation/ConversationMode.tsx b/frontend/src/components/conversation/ConversationMode.tsx
index ec42a2cc1..0151f2b98 100644
--- a/frontend/src/components/conversation/ConversationMode.tsx
+++ b/frontend/src/components/conversation/ConversationMode.tsx
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useMicVAD } from '@ricky0123/vad-react'
import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { useAuthStore } from '@/store/auth'
import { resolveVadRedemptionMs } from '@/lib/conversation-vad'
import { useConfigStore } from '@/store/config'
@@ -162,13 +162,16 @@ function QuotaPill({
function TrialPremiumCta() {
const t = useTranslations('billing')
const tConversation = useTranslations('conversation')
+ const locale = useLocale()
const router = useRouter()
const priceMonthly = useConfigStore((s) => s.priceMonthly)
const priceYearly = useConfigStore((s) => s.priceYearly)
const [loading, setLoading] = useState(null)
const [error, setError] = useState(null)
const yearlyCta = splitYearlyCta(
- t('planYearly', { price: String(priceYearly) })
+ t('planYearly', {
+ price: new Intl.NumberFormat(locale).format(priceYearly),
+ })
)
async function handleCheckout(interval: BillingInterval) {
@@ -229,7 +232,9 @@ function TrialPremiumCta() {
>
{loading === 'monthly'
? '...'
- : t('planMonthly', { price: String(priceMonthly) })}
+ : t('planMonthly', {
+ price: new Intl.NumberFormat(locale).format(priceMonthly),
+ })}
{error && (
diff --git a/frontend/src/components/feedback/AdminAuthorBadge.tsx b/frontend/src/components/feedback/AdminAuthorBadge.tsx
index bf405f4a1..962840ef8 100644
--- a/frontend/src/components/feedback/AdminAuthorBadge.tsx
+++ b/frontend/src/components/feedback/AdminAuthorBadge.tsx
@@ -1,9 +1,12 @@
+import { useTranslations } from 'next-intl'
+
export function AdminAuthorBadge({ role }: { role: string }) {
+ const t = useTranslations('admin')
if (role !== 'admin') return null
return (
- ADMIN
+ {t('roleAdmin')}
)
}
diff --git a/frontend/src/components/settings/BillingSection.tsx b/frontend/src/components/settings/BillingSection.tsx
index e5fd4f09a..12c2e844b 100644
--- a/frontend/src/components/settings/BillingSection.tsx
+++ b/frontend/src/components/settings/BillingSection.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { apiFetch } from '@/lib/api'
import { useAuthStore, isSubscribed, needsPaymentRecovery } from '@/store/auth'
import { useConfigStore } from '@/store/config'
@@ -9,6 +9,7 @@ import { SubscriptionPlanButtons } from '@/components/billing/SubscriptionPlanBu
export function BillingSection() {
const tBilling = useTranslations('billing')
+ const locale = useLocale()
const user = useAuthStore((s) => s.user)
const stripeEnabled = useConfigStore((s) => s.stripeEnabled)
const [portalLoading, setPortalLoading] = useState(false)
@@ -105,7 +106,7 @@ export function BillingSection() {
: tBilling('nextBilling')}
- {new Date(user.subscription_ends_at).toLocaleDateString()}
+ {new Date(user.subscription_ends_at).toLocaleDateString(locale)}
)}
diff --git a/frontend/tests/app/admin-query-params.test.tsx b/frontend/tests/app/admin-query-params.test.tsx
index 14a262367..9416942b1 100644
--- a/frontend/tests/app/admin-query-params.test.tsx
+++ b/frontend/tests/app/admin-query-params.test.tsx
@@ -9,6 +9,7 @@ const { mockApiFetch, mockSearchParams, navigationState } = vi.hoisted(() => ({
}))
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations: () => (key: string) => key,
}))
diff --git a/frontend/tests/app/admin-reviews.test.tsx b/frontend/tests/app/admin-reviews.test.tsx
index faf5a6061..351a928d4 100644
--- a/frontend/tests/app/admin-reviews.test.tsx
+++ b/frontend/tests/app/admin-reviews.test.tsx
@@ -4,6 +4,7 @@ import React from 'react'
import AdminReviewsPage from '@/app/(app)/admin/reviews/page'
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations:
() => (key: string, values?: Record) => {
if (key === 'learningLanguage') return `Learning ${values?.language}`
diff --git a/frontend/tests/app/admin-system-banner.test.tsx b/frontend/tests/app/admin-system-banner.test.tsx
index 800f011d3..e9134b465 100644
--- a/frontend/tests/app/admin-system-banner.test.tsx
+++ b/frontend/tests/app/admin-system-banner.test.tsx
@@ -122,6 +122,51 @@ describe('Admin system dashboard banner', () => {
expect(await screen.findByText('dashboardBanner.saveSuccess')).toBeVisible()
})
+ it('uses the selected translation as source when changing the source locale', async () => {
+ mockApiFetch.mockReset()
+ mockApiFetch
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ source_locale: 'en',
+ is_active: true,
+ revision: 1,
+ translations: generatedTranslations,
+ updated_at: '2026-08-06T10:00:00Z',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ translations: generatedTranslations }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ )
+
+ render( )
+ expect(await screen.findByText('15/15 complete')).toBeInTheDocument()
+ expect(screen.getAllByLabelText('dashboardBanner.fieldTitle')[0]).toHaveValue(
+ 'en title'
+ )
+
+ fireEvent.change(screen.getByLabelText('dashboardBanner.sourceLocale'), {
+ target: { value: 'hr' },
+ })
+ expect(screen.getAllByLabelText('dashboardBanner.fieldTitle')[0]).toHaveValue(
+ 'hr title'
+ )
+ fireEvent.click(screen.getByText('dashboardBanner.translate'))
+
+ await waitFor(() => expect(mockApiFetch).toHaveBeenCalledTimes(2))
+ expect(JSON.parse(mockApiFetch.mock.calls[1][1].body)).toEqual({
+ source_locale: 'hr',
+ title: 'hr title',
+ subtitle: 'hr subtitle',
+ description: 'hr description',
+ })
+ })
+
it('loads a legacy announcement and requires missing locales before saving it', async () => {
const { tr, sv, da, fi, hr, ...legacyTranslations } = generatedTranslations
mockApiFetch.mockReset()
diff --git a/frontend/tests/app/admin-user-detail.test.tsx b/frontend/tests/app/admin-user-detail.test.tsx
index f830acfa3..e90f4bfae 100644
--- a/frontend/tests/app/admin-user-detail.test.tsx
+++ b/frontend/tests/app/admin-user-detail.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations: () => (key: string) => key,
}))
diff --git a/frontend/tests/app/dashboard-next-step.test.tsx b/frontend/tests/app/dashboard-next-step.test.tsx
index 7317451da..a28a16580 100644
--- a/frontend/tests/app/dashboard-next-step.test.tsx
+++ b/frontend/tests/app/dashboard-next-step.test.tsx
@@ -8,6 +8,7 @@ const { mockApiFetch, mockPush } = vi.hoisted(() => ({
}))
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations: () => (key: string) => key,
}))
diff --git a/frontend/tests/app/feedback-unread-label.test.tsx b/frontend/tests/app/feedback-unread-label.test.tsx
index edb12c6b3..b141b5ce2 100644
--- a/frontend/tests/app/feedback-unread-label.test.tsx
+++ b/frontend/tests/app/feedback-unread-label.test.tsx
@@ -7,6 +7,7 @@ const { mockApiFetch, translate } = vi.hoisted(() => ({
}))
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations: () => translate,
}))
diff --git a/frontend/tests/app/flashcards-review.test.tsx b/frontend/tests/app/flashcards-review.test.tsx
index e30eebb67..e5b3f4b66 100644
--- a/frontend/tests/app/flashcards-review.test.tsx
+++ b/frontend/tests/app/flashcards-review.test.tsx
@@ -12,6 +12,7 @@ vi.mock('@/lib/api', () => ({
}))
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations: () => (key: string) => key,
}))
diff --git a/frontend/tests/app/landing.test.tsx b/frontend/tests/app/landing.test.tsx
index bb4426712..910be2654 100644
--- a/frontend/tests/app/landing.test.tsx
+++ b/frontend/tests/app/landing.test.tsx
@@ -24,6 +24,7 @@ vi.mock('next/link', () => ({
}))
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations: () => (key: string) => key,
}))
diff --git a/frontend/tests/components/BillingPaywall.test.tsx b/frontend/tests/components/BillingPaywall.test.tsx
index 11959da84..e023d4e06 100644
--- a/frontend/tests/components/BillingPaywall.test.tsx
+++ b/frontend/tests/components/BillingPaywall.test.tsx
@@ -2,8 +2,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
+const localeState = vi.hoisted(() => ({ value: 'en' }))
+
vi.mock('next-intl', () => ({
- useLocale: () => 'en',
+ useLocale: () => localeState.value,
useTranslations: () => (key: string) => key,
}))
@@ -116,6 +118,7 @@ function resetStores() {
}
beforeEach(() => {
+ localeState.value = 'en'
mockApiFetch.mockReset()
mockLandingSubscriptionState.mockReset()
resetStores()
@@ -126,6 +129,20 @@ beforeEach(() => {
})
describe('billing paywall UI', () => {
+ it('formats the subscription end date with the selected interface locale', () => {
+ localeState.value = 'fi'
+ useAuthStore.setState({
+ user: user({
+ subscription_status: 'canceled',
+ subscription_ends_at: '2099-09-24T12:00:00Z',
+ }),
+ })
+
+ render( )
+
+ expect(screen.getByText('24.9.2099')).toBeInTheDocument()
+ })
+
it('opens Customer Portal for past_due users in Settings instead of showing plan buttons', async () => {
useAuthStore.setState({
accessToken: 'token',
diff --git a/frontend/tests/components/ConversationMode.test.tsx b/frontend/tests/components/ConversationMode.test.tsx
index 8a69ffc11..b00850656 100644
--- a/frontend/tests/components/ConversationMode.test.tsx
+++ b/frontend/tests/components/ConversationMode.test.tsx
@@ -20,6 +20,7 @@ vi.mock('@ricky0123/vad-react', () => ({
},
}))
vi.mock('next-intl', () => ({
+ useLocale: () => 'en',
useTranslations: () => Object.assign((key: string) => key, { raw: () => [] }),
}))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
diff --git a/frontend/tests/i18n/admin-messages.test.ts b/frontend/tests/i18n/admin-messages.test.ts
index c565797f3..465e1f4db 100644
--- a/frontend/tests/i18n/admin-messages.test.ts
+++ b/frontend/tests/i18n/admin-messages.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
+import { createTranslator } from 'next-intl'
import de from '../../../messages/de.json'
import da from '../../../messages/da.json'
import fi from '../../../messages/fi.json'
@@ -26,3 +27,21 @@ describe('admin i18n messages', () => {
}
})
})
+
+describe('shared interface messages', () => {
+ it('renders generic errors and vocabulary counts in all fifteen locales', () => {
+ for (const [locale, messages] of Object.entries({ en, ...locales })) {
+ const errors: string[] = []
+ const t = createTranslator({
+ locale,
+ messages,
+ onError: (error) => errors.push(error.message),
+ })
+
+ expect(t('common.errorMessage'), locale).not.toBe('common.errorMessage')
+ expect(t('vocabulary.sets', { count: 1 }), locale).toContain('1')
+ expect(t('vocabulary.sets', { count: 2 }), locale).toContain('2')
+ expect(errors, locale).toEqual([])
+ }
+ })
+})
diff --git a/frontend/tests/i18n/hr-plurals.test.tsx b/frontend/tests/i18n/hr-plurals.test.tsx
index c938410ef..1e467af7a 100644
--- a/frontend/tests/i18n/hr-plurals.test.tsx
+++ b/frontend/tests/i18n/hr-plurals.test.tsx
@@ -7,24 +7,28 @@ import hr from '../../../messages/hr.json'
function PluralExamples({ count }: { count: number }) {
const grammar = useTranslations('grammar')
const billing = useTranslations('billing')
+ const vocabulary = useTranslations('vocabulary')
+ const common = useTranslations('common')
return (
<>
{grammar('topicsFound', { count })}
{billing('trialDays', { days: count })}
+ {vocabulary('sets', { count })}
+ {common('errorMessage')}
>
)
}
describe('Croatian plural messages', () => {
it.each([
- [1, 'Pronađena 1 tema', '1 dan probnog razdoblja'],
- [2, 'Pronađene 2 teme', '2 dana probnog razdoblja'],
- [5, 'Pronađeno 5 tema', '5 dana probnog razdoblja'],
- [21, 'Pronađena 21 tema', '21 dan probnog razdoblja'],
- [22, 'Pronađene 22 teme', '22 dana probnog razdoblja'],
- [25, 'Pronađeno 25 tema', '25 dana probnog razdoblja'],
- ])('formats %i with the correct Croatian forms', (count, topics, days) => {
+ [1, 'Pronađena 1 tema', '1 dan probnog razdoblja', '1 zbirka'],
+ [2, 'Pronađene 2 teme', '2 dana probnog razdoblja', '2 zbirke'],
+ [5, 'Pronađeno 5 tema', '5 dana probnog razdoblja', '5 zbirki'],
+ [21, 'Pronađena 21 tema', '21 dan probnog razdoblja', '21 zbirka'],
+ [22, 'Pronađene 22 teme', '22 dana probnog razdoblja', '22 zbirke'],
+ [25, 'Pronađeno 25 tema', '25 dana probnog razdoblja', '25 zbirki'],
+ ])('formats %i with the correct Croatian forms', (count, topics, days, sets) => {
render(
@@ -33,5 +37,7 @@ describe('Croatian plural messages', () => {
expect(screen.getByText(topics)).toBeInTheDocument()
expect(screen.getByText(days)).toBeInTheDocument()
+ expect(screen.getByText(sets)).toBeInTheDocument()
+ expect(screen.getByText(hr.common.errorMessage)).toBeInTheDocument()
})
})
diff --git a/frontend/tests/i18n/ru-plurals.test.ts b/frontend/tests/i18n/ru-plurals.test.ts
new file mode 100644
index 000000000..5986add13
--- /dev/null
+++ b/frontend/tests/i18n/ru-plurals.test.ts
@@ -0,0 +1,27 @@
+import { createTranslator } from 'next-intl'
+import { describe, expect, it } from 'vitest'
+
+import ru from '../../../messages/ru.json'
+
+describe('Russian plural messages', () => {
+ const t = createTranslator({ locale: 'ru', messages: ru })
+
+ it.each([
+ [1, 'день', 'Найдена 1 тема', 'Показана 1 ситуация', '1 набор'],
+ [2, 'дня', 'Найдены 2 темы', 'Показаны 2 ситуации', '2 набора'],
+ [5, 'дней', 'Найдено 5 тем', 'Показано 5 ситуаций', '5 наборов'],
+ [21, 'день', 'Найдена 21 тема', 'Показана 21 ситуация', '21 набор'],
+ [22, 'дня', 'Найдены 22 темы', 'Показаны 22 ситуации', '22 набора'],
+ [25, 'дней', 'Найдено 25 тем', 'Показано 25 ситуаций', '25 наборов'],
+ ])('formats %i with the correct forms', (count, day, topics, situations, sets) => {
+ expect(t('dashboard.freemiumTrialTitle', { days: count })).toBe(
+ `${count} ${day} пробного периода`
+ )
+ expect(t('freemium.trialDaysLeft', { days: count })).toBe(
+ `${count} ${day} бесплатного пробного периода`
+ )
+ expect(t('grammar.topicsFound', { count })).toBe(topics)
+ expect(t('phrasebook.situationsShown', { count })).toBe(situations)
+ expect(t('vocabulary.sets', { count })).toBe(sets)
+ })
+})
diff --git a/messages/da.json b/messages/da.json
index 4c3fb0d6a..eb300319a 100644
--- a/messages/da.json
+++ b/messages/da.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Indlæser...",
"error": "Fejl",
+ "errorMessage": "Noget gik galt. Prøv igen.",
"save": "Gem ændringer",
"saving": "Gemmer...",
"cancel": "Annuller",
@@ -827,7 +828,7 @@
"subtitle": "Ordforråd",
"all": "Alle",
"searchPlaceholder": "Søg efter ordsamlinger...",
- "sets": "samlinger",
+ "sets": "{count, plural, one {# samling} other {# samlinger}}",
"words": "ord",
"addAll": "Tilføj alle {count} som flashcards",
"adding": "Tilføjer...",
diff --git a/messages/de.json b/messages/de.json
index d90d3180b..e31e5f21a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Laden...",
"error": "Fehler",
+ "errorMessage": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
"save": "Änderungen speichern",
"saving": "Wird gespeichert...",
"cancel": "Abbrechen",
@@ -827,7 +828,7 @@
"subtitle": "Vokabular",
"all": "Alle",
"searchPlaceholder": "Sammlungen suchen...",
- "sets": "Sammlungen",
+ "sets": "{count, plural, one {# Sammlung} other {# Sammlungen}}",
"words": "Wörter",
"addAll": "Alle {count} als Karteikarten hinzufügen",
"adding": "Wird hinzugefügt...",
diff --git a/messages/en.json b/messages/en.json
index efb1a9b30..78129dfad 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Loading...",
"error": "Error",
+ "errorMessage": "Something went wrong. Please try again.",
"save": "Save changes",
"saving": "Saving...",
"cancel": "Cancel",
@@ -827,7 +828,7 @@
"subtitle": "Vocabulary",
"all": "All",
"searchPlaceholder": "Search sets...",
- "sets": "sets",
+ "sets": "{count, plural, one {# set} other {# sets}}",
"words": "words",
"addAll": "Add All {count} to Flashcards",
"adding": "Adding...",
diff --git a/messages/es.json b/messages/es.json
index cba1f1f1d..d2856dd9c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Cargando...",
"error": "Error",
+ "errorMessage": "Algo salió mal. Por favor inténtalo de nuevo.",
"save": "Guardar cambios",
"saving": "Guardando...",
"cancel": "Cancelar",
@@ -827,7 +828,7 @@
"subtitle": "Vocabulario",
"all": "Todos",
"searchPlaceholder": "Buscar conjuntos...",
- "sets": "conjuntos",
+ "sets": "{count, plural, one {# conjunto} other {# conjuntos}}",
"words": "palabras",
"addAll": "Añadir las {count} a tarjetas",
"adding": "Añadiendo...",
diff --git a/messages/fi.json b/messages/fi.json
index 9ebe40088..319b44cc7 100644
--- a/messages/fi.json
+++ b/messages/fi.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Ladataan...",
"error": "Virhe",
+ "errorMessage": "Jokin meni pieleen. Yritä uudelleen.",
"save": "Tallenna muutokset",
"saving": "Tallennetaan...",
"cancel": "Peruuta",
@@ -827,7 +828,7 @@
"subtitle": "Sanasto",
"all": "Kaikki",
"searchPlaceholder": "Hae sanakokoelmia...",
- "sets": "kokoelmaa",
+ "sets": "{count, plural, one {# kokoelma} other {# kokoelmaa}}",
"words": "sanaa",
"addAll": "Lisää kaikki {count} muistikortteihin",
"adding": "Lisätään...",
diff --git a/messages/fr.json b/messages/fr.json
index 58129daa2..c670e3ecd 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Chargement...",
"error": "Erreur",
+ "errorMessage": "Une erreur s'est produite. Veuillez réessayer.",
"save": "Sauvegarder",
"saving": "Sauvegarde...",
"cancel": "Annuler",
@@ -827,7 +828,7 @@
"subtitle": "Vocabulaire",
"all": "Tous",
"searchPlaceholder": "Rechercher des ensembles...",
- "sets": "ensembles",
+ "sets": "{count, plural, one {# ensemble} other {# ensembles}}",
"words": "mots",
"addAll": "Ajouter les {count} en fiches",
"adding": "Ajout...",
diff --git a/messages/hr.json b/messages/hr.json
index f9d928eb8..74a44abf6 100644
--- a/messages/hr.json
+++ b/messages/hr.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Učitavanje...",
"error": "Pogreška",
+ "errorMessage": "Nešto je pošlo po zlu. Pokušaj ponovno.",
"save": "Spremi promjene",
"saving": "Spremanje...",
"cancel": "Odustani",
@@ -827,7 +828,7 @@
"subtitle": "Rječnik",
"all": "Sve",
"searchPlaceholder": "Pretraži zbirke riječi...",
- "sets": "zbirki",
+ "sets": "{count, plural, one {# zbirka} few {# zbirke} other {# zbirki}}",
"words": "riječi",
"addAll": "Dodaj svih {count} u kartice",
"adding": "Dodavanje...",
diff --git a/messages/it.json b/messages/it.json
index f1b116551..841b31e4e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Caricamento...",
"error": "Errore",
+ "errorMessage": "Qualcosa è andato storto. Per favore riprova.",
"save": "Salva modifiche",
"saving": "Salvataggio...",
"cancel": "Annulla",
@@ -827,7 +828,7 @@
"subtitle": "Vocabolario",
"all": "Tutti",
"searchPlaceholder": "Cerca set...",
- "sets": "set",
+ "sets": "{count, plural, one {# set} other {# set}}",
"words": "parole",
"addAll": "Aggiungi tutti i {count} alle schede",
"adding": "Aggiunta...",
diff --git a/messages/nl.json b/messages/nl.json
index dcdb3d3b7..6066bb298 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Laden...",
"error": "Fout",
+ "errorMessage": "Er ging iets mis. Probeer opnieuw.",
"save": "Wijzigingen opslaan",
"saving": "Opslaan...",
"cancel": "Annuleren",
@@ -827,7 +828,7 @@
"subtitle": "Woordenschat",
"all": "Alle",
"searchPlaceholder": "Zoek sets...",
- "sets": "sets",
+ "sets": "{count, plural, one {# set} other {# sets}}",
"words": "woorden",
"addAll": "Alle {count} toevoegen aan flashcards",
"adding": "Toevoegen...",
diff --git a/messages/pl.json b/messages/pl.json
index cb2679a29..fcbf57388 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Ładowanie...",
"error": "Błąd",
+ "errorMessage": "Coś poszło nie tak. Spróbuj ponownie.",
"save": "Zapisz zmiany",
"saving": "Zapisywanie...",
"cancel": "Anuluj",
@@ -827,7 +828,7 @@
"subtitle": "Słownictwo",
"all": "Wszystkie",
"searchPlaceholder": "Szukaj zestawów...",
- "sets": "zestawy",
+ "sets": "{count, plural, one {# zestaw} few {# zestawy} other {# zestawów}}",
"words": "słowa",
"addAll": "Dodaj wszystkie {count} do fiszek",
"adding": "Dodawanie...",
diff --git a/messages/pt.json b/messages/pt.json
index 99a1751cf..a7a83ffe4 100644
--- a/messages/pt.json
+++ b/messages/pt.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Carregando...",
"error": "Erro",
+ "errorMessage": "Algo deu errado. Por favor, tente novamente.",
"save": "Salvar alterações",
"saving": "Salvando...",
"cancel": "Cancelar",
@@ -827,7 +828,7 @@
"subtitle": "Vocabulário",
"all": "Todos",
"searchPlaceholder": "Pesquisar conjuntos...",
- "sets": "conjuntos",
+ "sets": "{count, plural, one {# conjunto} other {# conjuntos}}",
"words": "palavras",
"addAll": "Adicionar {count} aos cartões",
"adding": "Adicionando...",
diff --git a/messages/ro.json b/messages/ro.json
index 5ce1757e0..74145ab27 100644
--- a/messages/ro.json
+++ b/messages/ro.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Se încarcă...",
"error": "Eroare",
+ "errorMessage": "Ceva a mers greșit. Încearcă din nou.",
"save": "Salvează modificările",
"saving": "Se salvează...",
"cancel": "Anulează",
@@ -827,7 +828,7 @@
"subtitle": "Vocabular",
"all": "Toate",
"searchPlaceholder": "Caută seturi...",
- "sets": "seturi",
+ "sets": "{count, plural, one {# set} few {# seturi} other {# seturi}}",
"words": "cuvinte",
"addAll": "Adaugă toate cele {count} la carduri flash",
"adding": "Se adaugă...",
diff --git a/messages/ru.json b/messages/ru.json
index 5adc39ee4..f4b869203 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Загрузка...",
"error": "Ошибка",
+ "errorMessage": "Что-то пошло не так. Попробуйте снова.",
"save": "Сохранить изменения",
"saving": "Сохранение...",
"cancel": "Отмена",
@@ -470,7 +471,7 @@
"premiumBannerPastDueTitle": "Платёж ожидает",
"premiumBannerPastDueDesc": "Обновите способ оплаты, чтобы восстановить Premium-доступ к чату, голосовым разговорам, reading и listening.",
"premiumBannerPastDueCta": "Восстановить Premium",
- "freemiumTrialTitle": "{days, plural, one {# день пробного периода} other {# дней пробного периода}}",
+ "freemiumTrialTitle": "{days, plural, one {# день пробного периода} few {# дня пробного периода} many {# дней пробного периода} other {# дня пробного периода}}",
"freemiumTrialDesc": "Наслаждайтесь неограниченным доступом к чату, голосу, listening и reading. Попробуйте всё Premium бесплатно.",
"announcementDismiss": "Закрыть объявление",
"announcementDismissError": "Не удалось закрыть объявление. Попробуйте снова."
@@ -820,14 +821,14 @@
"explanation": "Объяснение",
"backLink": "Назад к грамматике",
"notFound": "Тема не найдена.",
- "topicsFound": "{count, plural, one {# тема найдена} few {# темы найдено} other {# тем найдено}}"
+ "topicsFound": "{count, plural, one {Найдена # тема} few {Найдены # темы} many {Найдено # тем} other {Найдено # темы}}"
},
"vocabulary": {
"title": "Центр словарного запаса",
"subtitle": "Словарный запас",
"all": "Все",
"searchPlaceholder": "Поиск наборов...",
- "sets": "наборов",
+ "sets": "{count, plural, one {# набор} few {# набора} many {# наборов} other {# набора}}",
"words": "слов",
"addAll": "Добавить все {count} в карточки",
"adding": "Добавление...",
@@ -857,7 +858,7 @@
"level": "Уровень",
"register": "Регистр",
"statsLine": "{situationCount} ситуаций · {phraseCount} фраз · {range}",
- "situationsShown": "{count, plural, one {# ситуация показана} few {# ситуации показано} other {# ситуаций показано}}",
+ "situationsShown": "{count, plural, one {Показана # ситуация} few {Показаны # ситуации} many {Показано # ситуаций} other {Показано # ситуации}}",
"searchPlaceholder": "Поиск фраз..."
},
"admin": {
@@ -1495,7 +1496,7 @@
"readingLabel": "Чтение",
"voiceLabel": "Голос (мин)",
"freeLimit": "Получить Premium",
- "trialDaysLeft": "{days, plural, one {# день бесплатного пробного периода} other {# дней бесплатного пробного периода}}",
+ "trialDaysLeft": "{days, plural, one {# день бесплатного пробного периода} few {# дня бесплатного пробного периода} many {# дней бесплатного пробного периода} other {# дня бесплатного пробного периода}}",
"trialUnlimited": "Неограниченный доступ",
"requiresSubscription": "Только Premium"
},
diff --git a/messages/sv.json b/messages/sv.json
index 6e75665e6..849939fae 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Laddar...",
"error": "Fel",
+ "errorMessage": "Något gick fel. Försök igen.",
"save": "Spara ändringar",
"saving": "Sparar...",
"cancel": "Avbryt",
@@ -827,7 +828,7 @@
"subtitle": "Ordförråd",
"all": "Alla",
"searchPlaceholder": "Sök ordsamlingar...",
- "sets": "samlingar",
+ "sets": "{count, plural, one {# samling} other {# samlingar}}",
"words": "ord",
"addAll": "Lägg till alla {count} som gloskort",
"adding": "Lägger till...",
diff --git a/messages/tr.json b/messages/tr.json
index ceb3682e5..8e25588d8 100644
--- a/messages/tr.json
+++ b/messages/tr.json
@@ -54,6 +54,7 @@
"common": {
"loading": "Yükleniyor...",
"error": "Hata",
+ "errorMessage": "Bir şeyler ters gitti. Lütfen tekrar deneyin.",
"save": "Değişiklikleri kaydet",
"saving": "Kaydediliyor...",
"cancel": "İptal",
@@ -827,7 +828,7 @@
"subtitle": "Kelime Bilgisi",
"all": "Tümü",
"searchPlaceholder": "Setlerde ara...",
- "sets": "set",
+ "sets": "{count, plural, one {# set} other {# set}}",
"words": "kelime",
"addAll": "{count} Kelimenin Tümünü Bilgi Kartlarına Ekle",
"adding": "Ekleniyor...",
diff --git a/specs/api-endpoints.instructions.md b/specs/api-endpoints.instructions.md
index a38fd3653..9111549ea 100644
--- a/specs/api-endpoints.instructions.md
+++ b/specs/api-endpoints.instructions.md
@@ -72,9 +72,9 @@ Requires `role="admin"`. All endpoints return 403 for non-admin users.
## Admin Dashboard Banner — `/api/admin/dashboard-banner`
-Requires `role="admin"`. New translation previews and saves must contain exactly `en`, `es`, `fr`, `pt`, `de`, `it`, `ru`, `nl`, `pl`, `ro`, `tr`, `sv`, `da`, `fi`, and `hr`, each with nonblank plain-text `title` (max 160), `subtitle` (max 240), and `description` (max 2000). Existing ten- through fourteen-locale banners remain readable until an administrator adds missing translations; they cannot be saved again without all fifteen.
+Requires `role="admin"`. Translation previews and saves must contain exactly `en`, `es`, `fr`, `pt`, `de`, `it`, `ru`, `nl`, `pl`, `ro`, `tr`, `sv`, `da`, `fi`, and `hr`, each with nonblank plain-text `title` (max 160), `subtitle` (max 240), and `description` (max 2000). Stored ten- through fourteen-locale banners remain readable but cannot be saved without all fifteen translations.
-- **GET ``** — Rate limit: 60/min. Returns the singleton with `revision`, translations, `source_locale`, `is_active`, and timestamps, or `null` before the first save. Previously stored maps without `tr`, `sv`, `da`, `fi`, or `hr` are returned unchanged for editing.
+- **GET ``** — Rate limit: 60/min. Returns the singleton with `revision`, translations, `source_locale`, `is_active`, and timestamps, or `null` before the first save. Incomplete stored maps are returned unchanged for editing.
- **POST `/translate`** — Rate limit: 10/min. Uses the configured LLM to translate `{source_locale, title, subtitle, description}` into all fifteen UI locales, preserving the validated source text exactly after trimming. Returns `{translations}` as an editable preview without saving; LLM failure returns HTTP 502.
- **PUT ``** — Rate limit: 60/min. Creates or updates the singleton from `{source_locale, is_active, translations}`. The server starts at revision 1 and increments the revision when source locale or translated content changes; changing only `is_active` does not increment it.
diff --git a/specs/architecture-frontend.instructions.md b/specs/architecture-frontend.instructions.md
index fd974ada4..9971db7d1 100644
--- a/specs/architecture-frontend.instructions.md
+++ b/specs/architecture-frontend.instructions.md
@@ -118,8 +118,10 @@ 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.
+Visible copy, including resource counts, errors, role labels, tooltips, and accessibility text, uses
+the active UI locale's catalog. Dates and numeric prices use that locale rather than the browser's
+default. In the dashboard-banner editor, selecting a source locale loads its current translation
+into the source fields before translation.
## Streaming and media
diff --git a/specs/database-models.instructions.md b/specs/database-models.instructions.md
index adaec9fea..a53444911 100644
--- a/specs/database-models.instructions.md
+++ b/specs/database-models.instructions.md
@@ -55,7 +55,7 @@ Registration, session, billing, and Redis quota behavior is defined in `platform
Singleton global announcement managed by administrators and displayed on authenticated dashboards.
- `id` — integer primary key constrained to `1` by `ck_dashboard_banners_singleton`.
-- `translations` — required PostgreSQL JSONB object containing `title`, `subtitle`, and `description` for the fifteen UI locales: `en`, `es`, `fr`, `pt`, `de`, `it`, `ru`, `nl`, `pl`, `ro`, `tr`, `sv`, `da`, `fi`, and `hr`. New saves require all fifteen; previously stored ten- through fourteen-locale objects remain readable until missing translations are added by an administrator.
+- `translations` — required PostgreSQL JSONB object containing `title`, `subtitle`, and `description` for the fifteen UI locales: `en`, `es`, `fr`, `pt`, `de`, `it`, `ru`, `nl`, `pl`, `ro`, `tr`, `sv`, `da`, `fi`, and `hr`. Saves require all fifteen; stored objects with ten to fourteen locales remain readable until an administrator completes them.
- `source_locale` — required two-character UI locale used to create the translation set.
- `is_active` — required boolean controlling public visibility; default `false` at the ORM level.
- `revision` — required integer server revision, initially `1`. It increments when the source locale or translated content changes, but not for an active-state-only change.
diff --git a/specs/feedback.instructions.md b/specs/feedback.instructions.md
index 028ed592e..aee461f48 100644
--- a/specs/feedback.instructions.md
+++ b/specs/feedback.instructions.md
@@ -189,8 +189,8 @@ state. There is no request identity or cancellation between filter loads, so a l
can replace newer list state. Some mutations are optimistically reflected without consistently
checking non-success responses.
-Feedback UI and status/read labels are translated in all supported interface locales. The `ADMIN`
-marker and vote HTML tooltip remain fixed labels.
+Feedback UI, status/read labels, and administrator author badges use the active interface locale.
+The vote HTML tooltip remains a fixed label.
## Related specifications
diff --git a/specs/reviews.instructions.md b/specs/reviews.instructions.md
index 94aa3ce39..bb7d2b433 100644
--- a/specs/reviews.instructions.md
+++ b/specs/reviews.instructions.md
@@ -166,7 +166,7 @@ The Community section in Settings loads `/api/reviews/me`.
`/admin/reviews` uses ten-item pages and supports approval-state and rating filters. Backend language
and order filters are not exposed by this page. It shows snapshot name, status, stars, English
-language name, browser-locale date, and comment/fallback.
+language name, interface-locale date, and comment/fallback.
Administrators can approve, unapprove, and delete with confirmation. After mutation, the page reloads
the effective page so filters and page bounds remain valid. Backend authorization protects every
diff --git a/specs/voice-conversation.instructions.md b/specs/voice-conversation.instructions.md
index 057c7bef5..fd7762fc3 100644
--- a/specs/voice-conversation.instructions.md
+++ b/specs/voice-conversation.instructions.md
@@ -143,8 +143,8 @@ fallback element, and prevents stale decoding callbacks from restarting playback
A voice session creates or reuses a `Conversation`. Reuse requires a positive supplied conversation
ID owned by the user and a compatible target language. The current frontend does not send this ID,
so normal voice starts create a new `source="voice"` conversation.
-New voice conversations use a title with a native-language label and date. Danish and Finnish dates
-place an ordinal dot after the day; Croatian dates also end with a dot after the year.
+Voice conversation titles use a native-language label and date. German, Danish, Finnish, and Croatian
+dates place an ordinal dot after the day; Croatian dates also end with a dot after the year.
`ChatHistory` rows store role, content, target language, optional plan provenance, and conversation.
Voice sessions are visible in text-chat history. Continuing from text chat supplies textual context
From f6ddcee28adc2e48a0a88b4ab75ccc36bc22db88 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:06:27 +0200
Subject: [PATCH 11/14] Stabilize ReviewPrompt test with deferred review fetch
- Mock `fetchMyReview` with a deferred promise in the rating plus comment test
- Resolve the pending review lookup inside `act` before asserting validation state
- Keep the test aligned with async review loading behavior
---
frontend/tests/components/ReviewPrompt.test.tsx | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/frontend/tests/components/ReviewPrompt.test.tsx b/frontend/tests/components/ReviewPrompt.test.tsx
index 63c19ce9c..642631f9c 100644
--- a/frontend/tests/components/ReviewPrompt.test.tsx
+++ b/frontend/tests/components/ReviewPrompt.test.tsx
@@ -85,7 +85,16 @@ describe('ReviewPrompt', () => {
it('submits rating plus comment', async () => {
mockCreateReview.mockResolvedValue({ id: 1, rating: 4 })
+ let resolveReview!: (value: { has_review: boolean; review: null }) => void
+ mockFetchMyReview.mockReturnValueOnce(
+ new Promise<{ has_review: boolean; review: null }>((resolve) => {
+ resolveReview = resolve
+ })
+ )
render( {}} />)
+ await act(async () => {
+ resolveReview({ has_review: false, review: null })
+ })
await screen.findByText('Rating required')
fireEvent.click(screen.getByLabelText('4 stars'))
await waitFor(() =>
From d22e56e69386e9b621a6beba4bfa284ae14b0d71 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:19:17 +0200
Subject: [PATCH 12/14] Localize review, billing, and error messaging
- Use translated target-language labels in admin reviews and language bubbles
- Replace star and review aria labels with locale-aware rating strings
- Localize payment recovery titles and descriptions in billing banners
- Map auth, assessment, chat, onboarding, profile, and settings failures to generic translated errors
- Handle known validation and quota cases with localized messages in login, register, reset password, and chat
- Update tests and specs for localized error handling and rating labels
---
CHANGELOG.md | 2 +-
frontend/src/app/(app)/admin/reviews/page.tsx | 15 ++++--
frontend/src/app/(app)/admin/users/page.tsx | 24 ++++++---
.../app/(app)/assessment/level-test/page.tsx | 24 ++++-----
frontend/src/app/(app)/assessment/page.tsx | 44 ++++------------
frontend/src/app/(app)/chat/page.tsx | 23 ++++++---
frontend/src/app/(app)/dashboard/page.tsx | 6 +--
.../src/app/(app)/vocabulary/[setId]/page.tsx | 6 ++-
.../src/app/(auth)/forgot-password/page.tsx | 4 +-
frontend/src/app/(auth)/login/page.tsx | 7 ++-
frontend/src/app/(auth)/onboarding/page.tsx | 11 ++--
frontend/src/app/(auth)/register/page.tsx | 15 +++++-
.../src/app/(auth)/reset-password/page.tsx | 19 +++++--
frontend/src/components/LanguageBubbles.tsx | 3 +-
.../src/components/billing/PaywallBanner.tsx | 15 +++---
.../src/components/billing/PricingSection.tsx | 9 ++--
.../billing/SubscriptionPlanButtons.tsx | 7 ++-
.../conversation/ConversationMode.tsx | 7 ++-
.../src/components/reviews/ReviewPrompt.tsx | 3 +-
.../components/settings/BillingSection.tsx | 6 +--
.../settings/ConversationSection.tsx | 4 +-
.../components/settings/ProfileSection.tsx | 4 +-
frontend/tests/app/admin-reviews.test.tsx | 9 +++-
.../tests/app/chat-memory-stream.test.tsx | 25 ++++++++++
.../tests/app/reset-password-errors.test.tsx | 50 +++++++++++++++++++
.../tests/components/BillingPaywall.test.tsx | 4 +-
.../tests/components/LanguageBubbles.test.tsx | 4 +-
.../tests/components/ProfileSection.test.tsx | 6 +--
.../tests/components/ReviewPrompt.test.tsx | 13 +++--
frontend/tests/i18n/admin-messages.test.ts | 11 ++++
specs/reviews.instructions.md | 8 +--
31 files changed, 251 insertions(+), 137 deletions(-)
create mode 100644 frontend/tests/app/reset-password-errors.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c514cc36c..45a2e3519 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Dashboard announcements with ten through fourteen translations remain readable; saving requires all fifteen and completing a translation creates a new revision. The editor loads the chosen source language's text before translating.
- What's New presents the five interface languages (Croatian, Danish, Finnish, Swedish, and Turkish) in each of the fifteen interface locales alongside a general improvements note.
-- Interface text, resource counts, accessible controls, errors, administrator badges, dates, numbers, and prices follow the selected interface language. Study help displays the user's native-language name, and voice-session dates follow native-language conventions.
+- Interface text, resource counts, accessible controls, payment-recovery and error messages, administrator badges, review ratings, learning-language names, dates, numbers, and prices follow the selected interface language. Study help displays the user's native-language name, and voice-session dates follow native-language conventions.
- The review prompt waits for the existing-review lookup before showing the rating form. The Dutch, Polish, Romanian, and Russian FAQs explain the default public-registration setting.
## [1.9.20] - 2026-09-24
diff --git a/frontend/src/app/(app)/admin/reviews/page.tsx b/frontend/src/app/(app)/admin/reviews/page.tsx
index eded76cab..8b665c6cb 100644
--- a/frontend/src/app/(app)/admin/reviews/page.tsx
+++ b/frontend/src/app/(app)/admin/reviews/page.tsx
@@ -40,10 +40,6 @@ function formatDate(iso: string, locale: string): string {
})
}
-function languageLabel(code: string): string {
- return getLanguageByCode(code)?.nameEn ?? code
-}
-
function stars(rating: number): string {
return '★'.repeat(rating) + '☆'.repeat(Math.max(0, 5 - rating))
}
@@ -51,7 +47,14 @@ function stars(rating: number): string {
export default function AdminReviewsPage() {
const t = useTranslations('adminReviews')
const tAdmin = useTranslations('admin')
+ const tTarget = useTranslations('targetLanguages')
+ const tStars = useTranslations('landingReviews')
const locale = useLocale()
+
+ function languageLabel(code: string): string {
+ const language = getLanguageByCode(code)
+ return language ? tTarget(language.code) : code
+ }
const [reviews, setReviews] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
@@ -256,7 +259,9 @@ export default function AdminReviewsPage() {
{stars(review.rating)}
diff --git a/frontend/src/app/(app)/admin/users/page.tsx b/frontend/src/app/(app)/admin/users/page.tsx
index 8c49d0412..0d7336eb2 100644
--- a/frontend/src/app/(app)/admin/users/page.tsx
+++ b/frontend/src/app/(app)/admin/users/page.tsx
@@ -86,6 +86,7 @@ function statusBadgeClass(status: string) {
export default function AdminUsersPage() {
const t = useTranslations('admin')
const tCommon = useTranslations('common')
+ const tRegister = useTranslations('auth.register')
const tBilling = useTranslations('billing')
const tLang = useTranslations('languages')
const tTarget = useTranslations('targetLanguages')
@@ -331,7 +332,13 @@ export default function AdminUsersPage() {
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
- throw new Error(data.detail || t('createUserError'))
+ throw new Error(
+ data.detail === 'Username already taken'
+ ? tRegister('usernameTaken')
+ : data.detail === 'Email already taken'
+ ? tRegister('emailTaken')
+ : t('createUserError')
+ )
}
setShowCreate(false)
setForm({
@@ -350,8 +357,13 @@ export default function AdminUsersPage() {
roleFilter,
activeFilter
)
- } catch (err: unknown) {
- setError(err instanceof Error ? err.message : t('createUserError'))
+ } catch (err) {
+ const knownErrors = [tRegister('usernameTaken'), tRegister('emailTaken')]
+ setError(
+ err instanceof Error && knownErrors.includes(err.message)
+ ? err.message
+ : t('createUserError')
+ )
} finally {
setCreateSaving(false)
}
@@ -367,8 +379,7 @@ export default function AdminUsersPage() {
setActivePending(null)
setActionBusy(null)
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- setError(data.detail || t('updateUserError'))
+ setError(t('updateUserError'))
return
}
await loadUsers(
@@ -388,8 +399,7 @@ export default function AdminUsersPage() {
setDeletePending(null)
setActionBusy(null)
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- setError(data.detail || t('deleteUserError'))
+ setError(t('deleteUserError'))
} else {
const newTotal = total - 1
const maxPage = Math.max(0, Math.ceil(newTotal / PAGE_SIZE) - 1)
diff --git a/frontend/src/app/(app)/assessment/level-test/page.tsx b/frontend/src/app/(app)/assessment/level-test/page.tsx
index e3651ccec..20a5f8976 100644
--- a/frontend/src/app/(app)/assessment/level-test/page.tsx
+++ b/frontend/src/app/(app)/assessment/level-test/page.tsx
@@ -109,12 +109,7 @@ export default function LevelTestPage() {
const res = await apiFetch(
`/api/assessment/level-test/questions/${planIdNum}`
)
- if (!res.ok) {
- const d = await res.json().catch(() => ({}))
- throw new Error(
- (d as { detail?: string }).detail ?? `Error ${res.status}`
- )
- }
+ if (!res.ok) throw new Error(t('levelTest.loadFailed'))
const data = (await res.json()) as {
plan_id: number
cefr_level: string
@@ -127,7 +122,11 @@ export default function LevelTestPage() {
setCefrLevel(data.cefr_level)
setStep('quiz')
} catch (err) {
- setError(err instanceof Error ? err.message : t('levelTest.loadFailed'))
+ setError(
+ err instanceof Error && err.message === t('levelTest.noQuestions')
+ ? err.message
+ : t('levelTest.loadFailed')
+ )
setStep('error')
}
}, [planId, startConfirmed, t])
@@ -183,17 +182,12 @@ export default function LevelTestPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_id: planIdNum, answers: finalAnswers }),
})
- if (!res.ok) {
- const d = await res.json().catch(() => ({}))
- throw new Error(
- (d as { detail?: string }).detail ?? `Error ${res.status}`
- )
- }
+ if (!res.ok) throw new Error(t('levelTest.submitFailed'))
const data = (await res.json()) as LevelTestResult
setResult(data)
setStep('result')
- } catch (err) {
- setError(err instanceof Error ? err.message : t('levelTest.submitFailed'))
+ } catch {
+ setError(t('levelTest.submitFailed'))
setStep('error')
}
}
diff --git a/frontend/src/app/(app)/assessment/page.tsx b/frontend/src/app/(app)/assessment/page.tsx
index a03097143..9db2d4469 100644
--- a/frontend/src/app/(app)/assessment/page.tsx
+++ b/frontend/src/app/(app)/assessment/page.tsx
@@ -250,23 +250,13 @@ export default function AssessmentPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: answersToSend }),
})
- if (!res.ok) {
- const d = await res.json().catch(() => ({}))
- throw new Error(
- (d as { detail?: string }).detail ?? `Error ${res.status}`
- )
- }
+ if (!res.ok) throw new Error(tCommon('errorMessage'))
const data = (await res.json()) as AssessmentResult
setResult(data)
setSelectedLevel(data.cefr_level as CEFRLevel)
setStep('result')
- } catch (err) {
- const msg = err instanceof Error ? err.message : ''
- setError(
- msg === 'ai_service_error' || msg === 'ai_service_unavailable'
- ? tCommon('errorMessage')
- : msg || 'Evaluation failed'
- )
+ } catch {
+ setError(tCommon('errorMessage'))
} finally {
setEvaluating(false)
}
@@ -294,12 +284,7 @@ export default function AssessmentPage() {
target_language: activeLanguage?.code ?? undefined,
}),
})
- if (!res.ok) {
- const d = await res.json().catch(() => ({}))
- throw new Error(
- (d as { detail?: string }).detail ?? `Error ${res.status}`
- )
- }
+ if (!res.ok) throw new Error(tCommon('errorMessage'))
const data = (await res.json()) as AssessmentCompleteResponse
setCreatedPlanId(data.plan_id)
if (data.voice_trial?.available && data.voice_trial.token) {
@@ -309,13 +294,8 @@ export default function AssessmentPage() {
return
}
router.push('/plan')
- } catch (err) {
- const msg = err instanceof Error ? err.message : ''
- setError(
- msg === 'ai_service_error' || msg === 'ai_service_unavailable'
- ? tCommon('errorMessage')
- : msg || 'Failed to create plan'
- )
+ } catch {
+ setError(tCommon('errorMessage'))
setSubmitting(false)
}
}
@@ -345,12 +325,7 @@ export default function AssessmentPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_language: activeLanguage?.code }),
})
- if (!res.ok) {
- const d = await res.json().catch(() => ({}))
- throw new Error(
- (d as { detail?: string }).detail ?? `Error ${res.status}`
- )
- }
+ if (!res.ok) throw new Error(tCommon('errorMessage'))
const data = (await res.json()) as AssessmentCompleteResponse
if (data.voice_trial?.available && data.voice_trial.token) {
setCreatedPlanId(data.plan_id)
@@ -360,9 +335,8 @@ export default function AssessmentPage() {
return
}
setError(tCommon('errorMessage'))
- } catch (err) {
- const msg = err instanceof Error ? err.message : ''
- setError(msg || tCommon('errorMessage'))
+ } catch {
+ setError(tCommon('errorMessage'))
} finally {
setTrialLoading(false)
}
diff --git a/frontend/src/app/(app)/chat/page.tsx b/frontend/src/app/(app)/chat/page.tsx
index 35116a4f1..9934502b6 100644
--- a/frontend/src/app/(app)/chat/page.tsx
+++ b/frontend/src/app/(app)/chat/page.tsx
@@ -49,6 +49,7 @@ export default function ChatPage() {
const t = useTranslations('chat')
const tCommon = useTranslations('common')
const tLang = useTranslations('targetLanguages')
+ const tQuota = useTranslations('conversation')
const router = useRouter()
const user = useAuthStore((s) => s.user)
const activeLanguage = useLanguageStore((s) => s.activeLanguage)
@@ -244,8 +245,18 @@ export default function ChatPage() {
})
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.detail || `Error ${res.status}`)
+ let message = t('errorMessage')
+ if (res.status === 429) {
+ const data = await res.json().catch(() => ({}))
+ if (
+ typeof data.detail === 'string' &&
+ data.detail.startsWith('Monthly token limit reached')
+ ) {
+ message = tQuota('quotaExceededTokens')
+ }
+ }
+ setError(message)
+ return
}
let assistantContent = ''
@@ -280,7 +291,7 @@ export default function ChatPage() {
}
if (data.error) {
streamCompleted = true
- setError(data.error)
+ setError(t('errorMessage'))
}
if (data.done) {
streamCompleted = true
@@ -295,8 +306,8 @@ export default function ChatPage() {
if (data.memory_updated) showMemoryToast()
}
if (!streamCompleted) throw new Error(t('errorMessage'))
- } catch (e) {
- setError(e instanceof Error ? e.message : t('errorMessage'))
+ } catch {
+ setError(t('errorMessage'))
} finally {
setSending(false)
inputRef.current?.focus()
@@ -550,7 +561,7 @@ export default function ChatPage() {
✕{' '}
{error === 'No active study plan found'
? tCommon('noActivePlan')
- : t('errorMessage')}
+ : error}
)}
diff --git a/frontend/src/app/(app)/dashboard/page.tsx b/frontend/src/app/(app)/dashboard/page.tsx
index 4176f300a..8e2a80a9e 100644
--- a/frontend/src/app/(app)/dashboard/page.tsx
+++ b/frontend/src/app/(app)/dashboard/page.tsx
@@ -193,10 +193,8 @@ export default function DashboardPage() {
if (!res.ok) throw new Error(tBilling('portalError'))
const { url } = await res.json()
window.location.assign(url)
- } catch (err) {
- setPortalError(
- err instanceof Error ? err.message : tBilling('portalError')
- )
+ } catch {
+ setPortalError(tBilling('portalError'))
setPortalLoading(false)
}
}
diff --git a/frontend/src/app/(app)/vocabulary/[setId]/page.tsx b/frontend/src/app/(app)/vocabulary/[setId]/page.tsx
index ab70c1142..6a90fd8ed 100644
--- a/frontend/src/app/(app)/vocabulary/[setId]/page.tsx
+++ b/frontend/src/app/(app)/vocabulary/[setId]/page.tsx
@@ -115,7 +115,9 @@ export default function VocabularySetPage({
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(
- (d as { detail?: string }).detail ?? `Error ${res.status}`
+ (d as { detail?: string }).detail === 'No active study plan found'
+ ? 'No active study plan found'
+ : t('addFailed')
)
}
const data = (await res.json()) as { created: number }
@@ -125,7 +127,7 @@ export default function VocabularySetPage({
setError(
msg === 'No active study plan found'
? tCommon('noActivePlan')
- : msg || 'Failed to add flashcards.'
+ : t('addFailed')
)
} finally {
setAdding(false)
diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx
index ebe3b51ee..7f4594949 100644
--- a/frontend/src/app/(auth)/forgot-password/page.tsx
+++ b/frontend/src/app/(auth)/forgot-password/page.tsx
@@ -26,8 +26,8 @@ export default function ForgotPasswordPage() {
})
if (!res.ok) throw new Error(t('error'))
setSent(true)
- } catch (err: unknown) {
- setError(err instanceof Error ? err.message : t('error'))
+ } catch {
+ setError(t('error'))
} finally {
setLoading(false)
}
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index b092a14c2..5b8336632 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -71,7 +71,12 @@ function LoginForm() {
if (meRes.ok) setUser(mapUser(await meRes.json()))
router.push('/dashboard')
} catch (err: unknown) {
- setError(err instanceof Error ? err.message : t('loginFailed'))
+ setError(
+ err instanceof Error &&
+ (err.message === t('error') || err.message === t('invalidEmail'))
+ ? err.message
+ : t('loginFailed')
+ )
} finally {
setLoading(false)
}
diff --git a/frontend/src/app/(auth)/onboarding/page.tsx b/frontend/src/app/(auth)/onboarding/page.tsx
index fcd351fe9..aa85a0ac2 100644
--- a/frontend/src/app/(auth)/onboarding/page.tsx
+++ b/frontend/src/app/(auth)/onboarding/page.tsx
@@ -126,8 +126,8 @@ export default function OnboardingPage() {
} else {
router.push('/dashboard')
}
- } catch (err: unknown) {
- setError(err instanceof Error ? err.message : t('saveFailed'))
+ } catch {
+ setError(t('saveFailed'))
} finally {
setLoading(false)
}
@@ -156,13 +156,12 @@ export default function OnboardingPage() {
body: JSON.stringify({ plan: interval }),
})
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.detail ?? t('trialError'))
+ throw new Error(t('trialError'))
}
const { url } = await res.json()
window.location.assign(url)
- } catch (err: unknown) {
- setCheckoutError(err instanceof Error ? err.message : t('trialError'))
+ } catch {
+ setCheckoutError(t('trialError'))
setCheckoutLoading(null)
}
}
diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx
index e86c7e05f..23991ef39 100644
--- a/frontend/src/app/(auth)/register/page.tsx
+++ b/frontend/src/app/(auth)/register/page.tsx
@@ -128,7 +128,20 @@ function RegisterForm() {
selectedPlan ? `/onboarding?plan=${selectedPlan}` : '/onboarding'
)
} catch (err: unknown) {
- setError(err instanceof Error ? err.message : t('error'))
+ const knownErrors = [
+ t('error'),
+ t('usernameTaken'),
+ t('emailTaken'),
+ t('registrationClosed'),
+ t('invalidInvite'),
+ t('invalidEmail'),
+ t('invalidPassword'),
+ ]
+ setError(
+ err instanceof Error && knownErrors.includes(err.message)
+ ? err.message
+ : t('error')
+ )
} finally {
setLoading(false)
}
diff --git a/frontend/src/app/(auth)/reset-password/page.tsx b/frontend/src/app/(auth)/reset-password/page.tsx
index c53306f60..f1b59f73d 100644
--- a/frontend/src/app/(auth)/reset-password/page.tsx
+++ b/frontend/src/app/(auth)/reset-password/page.tsx
@@ -9,6 +9,8 @@ import { apiFetch } from '@/lib/api'
function ResetPasswordContent() {
const t = useTranslations('auth.resetPassword')
+ const tCommon = useTranslations('common')
+ const tRegister = useTranslations('auth.register')
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token') ?? ''
@@ -37,13 +39,24 @@ function ResetPasswordContent() {
body: JSON.stringify({ token, new_password: password }),
})
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.detail || t('error'))
+ throw new Error(
+ res.status === 400 || res.status === 404
+ ? t('error')
+ : res.status === 422
+ ? tRegister('invalidPassword')
+ : tCommon('errorMessage')
+ )
}
setDone(true)
setTimeout(() => router.push('/login'), 2000)
} catch (err: unknown) {
- setError(err instanceof Error ? err.message : t('error'))
+ setError(
+ err instanceof Error &&
+ (err.message === t('error') ||
+ err.message === tRegister('invalidPassword'))
+ ? err.message
+ : tCommon('errorMessage')
+ )
} finally {
setLoading(false)
}
diff --git a/frontend/src/components/LanguageBubbles.tsx b/frontend/src/components/LanguageBubbles.tsx
index 0bb3b83b5..0a1b02064 100644
--- a/frontend/src/components/LanguageBubbles.tsx
+++ b/frontend/src/components/LanguageBubbles.tsx
@@ -14,6 +14,7 @@ function circlePosition(index: number, total: number, radius: number) {
export function LanguageBubbles() {
const t = useTranslations('landing')
+ const tTarget = useTranslations('targetLanguages')
const positions = useMemo(
() =>
SUPPORTED_TARGET_LANGUAGES.map((_, i) =>
@@ -54,7 +55,7 @@ export function LanguageBubbles() {
({}))
- throw new Error(data.detail ?? t('checkoutError'))
+ throw new Error(t('checkoutError'))
}
const { url } = await res.json()
window.location.assign(url)
- } catch (err) {
- setError(err instanceof Error ? err.message : t('checkoutError'))
+ } catch {
+ setError(t('checkoutError'))
setLoading(null)
}
}
@@ -107,8 +106,8 @@ export function PaywallBanner({
if (!res.ok) throw new Error(t('portalError'))
const { url } = await res.json()
window.location.assign(url)
- } catch (err) {
- setError(err instanceof Error ? err.message : t('portalError'))
+ } catch {
+ setError(t('portalError'))
setPortalLoading(false)
}
}
@@ -143,11 +142,11 @@ export function PaywallBanner({
{t('paywallLabel')}
- {t(paymentRecovery ? 'premiumBannerPastDueTitle' : context.title)}
+ {t(paymentRecovery ? 'pastDueTitle' : context.title)}
{paymentRecovery
- ? t('premiumBannerPastDueDesc')
+ ? t('pastDueDesc')
: t(
context.desc ??
(trialEligible ? 'paywallDesc' : 'paywallDescTrialUsed'),
diff --git a/frontend/src/components/billing/PricingSection.tsx b/frontend/src/components/billing/PricingSection.tsx
index 497b7e2f6..82ad43705 100644
--- a/frontend/src/components/billing/PricingSection.tsx
+++ b/frontend/src/components/billing/PricingSection.tsx
@@ -88,15 +88,12 @@ export default function PricingSection({
body: JSON.stringify({ plan }),
})
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.detail ?? tBilling('checkoutError'))
+ throw new Error(tBilling('checkoutError'))
}
const { url } = await res.json()
window.location.assign(url)
- } catch (err) {
- setCheckoutError(
- err instanceof Error ? err.message : tBilling('checkoutError')
- )
+ } catch {
+ setCheckoutError(tBilling('checkoutError'))
setCheckoutLoading(null)
}
}
diff --git a/frontend/src/components/billing/SubscriptionPlanButtons.tsx b/frontend/src/components/billing/SubscriptionPlanButtons.tsx
index 30dd8a7b2..039c573c1 100644
--- a/frontend/src/components/billing/SubscriptionPlanButtons.tsx
+++ b/frontend/src/components/billing/SubscriptionPlanButtons.tsx
@@ -36,13 +36,12 @@ export function SubscriptionPlanButtons({
body: JSON.stringify({ plan }),
})
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.detail ?? tBilling('checkoutError'))
+ throw new Error(tBilling('checkoutError'))
}
const { url } = await res.json()
window.location.assign(url)
- } catch (err) {
- setError(err instanceof Error ? err.message : tBilling('checkoutError'))
+ } catch {
+ setError(tBilling('checkoutError'))
setLoading(null)
}
}
diff --git a/frontend/src/components/conversation/ConversationMode.tsx b/frontend/src/components/conversation/ConversationMode.tsx
index 0151f2b98..222914d39 100644
--- a/frontend/src/components/conversation/ConversationMode.tsx
+++ b/frontend/src/components/conversation/ConversationMode.tsx
@@ -184,13 +184,12 @@ function TrialPremiumCta() {
body: JSON.stringify({ plan: interval }),
})
if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.detail ?? t('checkoutError'))
+ throw new Error(t('checkoutError'))
}
const { url } = await res.json()
window.location.assign(url)
- } catch (err) {
- setError(err instanceof Error ? err.message : t('checkoutError'))
+ } catch {
+ setError(t('checkoutError'))
setLoading(null)
}
}
diff --git a/frontend/src/components/reviews/ReviewPrompt.tsx b/frontend/src/components/reviews/ReviewPrompt.tsx
index 4adf5e31e..9bf31817b 100644
--- a/frontend/src/components/reviews/ReviewPrompt.tsx
+++ b/frontend/src/components/reviews/ReviewPrompt.tsx
@@ -60,6 +60,7 @@ export function ReviewForm({
onSubmit,
}: ReviewFormProps) {
const t = useTranslations('reviewPrompt')
+ const tStars = useTranslations('landingReviews')
const [rating, setRating] = useState(initialReview?.rating ?? 0)
const [comment, setComment] = useState(initialReview?.comment ?? '')
const [submitting, setSubmitting] = useState(false)
@@ -104,7 +105,7 @@ export function ReviewForm({
type="button"
role="radio"
aria-checked={rating === value}
- aria-label={`${value} ${t(value === 1 ? 'star' : 'stars')}`}
+ aria-label={tStars('starsLabel', { rating: value })}
onClick={() => setRating(value)}
className={`border px-3 py-2 transition-colors ${
value <= rating
diff --git a/frontend/src/components/settings/BillingSection.tsx b/frontend/src/components/settings/BillingSection.tsx
index 12c2e844b..2844329d1 100644
--- a/frontend/src/components/settings/BillingSection.tsx
+++ b/frontend/src/components/settings/BillingSection.tsx
@@ -27,10 +27,8 @@ export function BillingSection() {
if (!res.ok) throw new Error(tBilling('portalError'))
const { url } = await res.json()
window.location.assign(url)
- } catch (err) {
- setPortalError(
- err instanceof Error ? err.message : tBilling('portalError')
- )
+ } catch {
+ setPortalError(tBilling('portalError'))
setPortalLoading(false)
}
}
diff --git a/frontend/src/components/settings/ConversationSection.tsx b/frontend/src/components/settings/ConversationSection.tsx
index da8021062..42b943f88 100644
--- a/frontend/src/components/settings/ConversationSection.tsx
+++ b/frontend/src/components/settings/ConversationSection.tsx
@@ -50,10 +50,10 @@ export function ConversationSection({ title }: { title?: string } = {}) {
const updated = await res.json()
setUser(mapUser(updated, user))
setConvMessage({ type: 'ok', text: t('conversationSaved') })
- } catch (err: unknown) {
+ } catch {
setConvMessage({
type: 'err',
- text: err instanceof Error ? err.message : t('saveFailed'),
+ text: t('saveFailed'),
})
} finally {
setSavingConv(false)
diff --git a/frontend/src/components/settings/ProfileSection.tsx b/frontend/src/components/settings/ProfileSection.tsx
index 1f05d5b3b..0b7e362e1 100644
--- a/frontend/src/components/settings/ProfileSection.tsx
+++ b/frontend/src/components/settings/ProfileSection.tsx
@@ -174,10 +174,10 @@ export function ProfileSection({ title }: { title?: string } = {}) {
document.cookie = `LOCALE_DETECTED=1; ${opts}`
window.location.reload()
}
- } catch (err: unknown) {
+ } catch {
setMessage({
type: 'err',
- text: err instanceof Error ? err.message : t('saveFailed'),
+ text: t('saveFailed'),
})
} finally {
setSaving(false)
diff --git a/frontend/tests/app/admin-reviews.test.tsx b/frontend/tests/app/admin-reviews.test.tsx
index 351a928d4..1b7f099c6 100644
--- a/frontend/tests/app/admin-reviews.test.tsx
+++ b/frontend/tests/app/admin-reviews.test.tsx
@@ -6,9 +6,13 @@ import AdminReviewsPage from '@/app/(app)/admin/reviews/page'
vi.mock('next-intl', () => ({
useLocale: () => 'en',
useTranslations:
- () => (key: string, values?: Record) => {
+ (namespace: string) => (key: string, values?: Record) => {
+ if (namespace === 'targetLanguages' && key === 'de-DE') return 'German'
if (key === 'learningLanguage') return `Learning ${values?.language}`
- if (key === 'starsLabel') return `${values?.rating} stars`
+ if (key === 'starsLabel')
+ return namespace === 'landingReviews'
+ ? `${values?.rating} out of 5 stars`
+ : `${values?.rating} stars`
if (key === 'total') return `${values?.total} total`
const labels: Record = {
approve: 'Approve',
@@ -72,6 +76,7 @@ describe('AdminReviewsPage', () => {
expect(await screen.findByText('Reviewer')).toBeDefined()
expect(screen.getByText('Strong experience')).toBeDefined()
expect(screen.getByText(/Learning German/)).toBeDefined()
+ expect(screen.getByLabelText('5 out of 5 stars')).toBeInTheDocument()
})
it('calls approve action', async () => {
diff --git a/frontend/tests/app/chat-memory-stream.test.tsx b/frontend/tests/app/chat-memory-stream.test.tsx
index ce6d69166..39ae62d8e 100644
--- a/frontend/tests/app/chat-memory-stream.test.tsx
+++ b/frontend/tests/app/chat-memory-stream.test.tsx
@@ -280,4 +280,29 @@ describe('chat memory stream', () => {
expect(await screen.findByText('memorySavedToast')).toBeInTheDocument()
expect(await screen.findByText(/errorMessage/)).toBeInTheDocument()
})
+
+ it.each([
+ [429, 'Monthly token limit reached (10/10 tokens).', 'quotaExceededTokens'],
+ [429, 'Rate limit exceeded: 30 per 1 minute', 'errorMessage'],
+ [502, 'Provider unavailable', 'errorMessage'],
+ ])('localizes HTTP %i errors instead of showing backend details', async (status, detail, label) => {
+ mocks.apiFetch.mockImplementation((path: string) =>
+ Promise.resolve(
+ path === '/api/chat'
+ ? new Response(JSON.stringify({ detail }), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ : jsonResponse([])
+ )
+ )
+ render( )
+
+ const input = await screen.findByPlaceholderText('placeholder')
+ fireEvent.change(input, { target: { value: 'Hello' } })
+ fireEvent.click(screen.getByRole('button', { name: 'send' }))
+
+ expect(await screen.findByText(new RegExp(label))).toBeInTheDocument()
+ expect(screen.queryByText(detail)).toBeNull()
+ })
})
diff --git a/frontend/tests/app/reset-password-errors.test.tsx b/frontend/tests/app/reset-password-errors.test.tsx
new file mode 100644
index 000000000..cd95826d7
--- /dev/null
+++ b/frontend/tests/app/reset-password-errors.test.tsx
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import React from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import ResetPasswordPage from '@/app/(auth)/reset-password/page'
+
+const apiFetch = vi.hoisted(() => vi.fn())
+
+vi.mock('@/lib/api', () => ({ apiFetch }))
+vi.mock('next-intl', () => ({
+ useTranslations: (namespace: string) => (key: string) => `${namespace}.${key}`,
+}))
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ useSearchParams: () => new URLSearchParams('token=valid'),
+}))
+vi.mock('next/link', () => ({
+ default: ({ href, children }: React.PropsWithChildren<{ href: string }>) => (
+ {children}
+ ),
+}))
+
+describe('reset password errors', () => {
+ beforeEach(() => apiFetch.mockReset())
+
+ it.each([
+ [400, 'Invalid or expired reset token', 'auth.resetPassword.error'],
+ [422, 'Password validation failed', 'auth.register.invalidPassword'],
+ [503, 'Service unavailable', 'common.errorMessage'],
+ ])('localizes HTTP %i without exposing the backend detail', async (status, detail, message) => {
+ apiFetch.mockResolvedValue(
+ new Response(JSON.stringify({ detail }), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ )
+ render( )
+
+ fireEvent.change(screen.getByPlaceholderText('auth.resetPassword.newPassword'), {
+ target: { value: 'ValidPwd12!' },
+ })
+ fireEvent.change(screen.getByPlaceholderText('auth.resetPassword.confirmPassword'), {
+ target: { value: 'ValidPwd12!' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'auth.resetPassword.submit' }))
+
+ expect(await screen.findByText(new RegExp(message))).toBeInTheDocument()
+ expect(screen.queryByText(detail)).toBeNull()
+ })
+})
diff --git a/frontend/tests/components/BillingPaywall.test.tsx b/frontend/tests/components/BillingPaywall.test.tsx
index e023d4e06..226e501bb 100644
--- a/frontend/tests/components/BillingPaywall.test.tsx
+++ b/frontend/tests/components/BillingPaywall.test.tsx
@@ -387,8 +387,8 @@ describe('billing paywall UI', () => {
render( )
- expect(screen.getByText('premiumBannerPastDueTitle')).toBeDefined()
- expect(screen.getByText('premiumBannerPastDueDesc')).toBeDefined()
+ expect(screen.getByText('pastDueTitle')).toBeDefined()
+ expect(screen.getByText('pastDueDesc')).toBeDefined()
expect(screen.getByText('updatePayment')).toBeDefined()
expect(screen.queryByText('planMonthly')).toBeNull()
expect(screen.queryByText('paywallNoCharge')).toBeNull()
diff --git a/frontend/tests/components/LanguageBubbles.test.tsx b/frontend/tests/components/LanguageBubbles.test.tsx
index d4566ff56..be3b046d5 100644
--- a/frontend/tests/components/LanguageBubbles.test.tsx
+++ b/frontend/tests/components/LanguageBubbles.test.tsx
@@ -5,7 +5,8 @@ import { LanguageBubbles } from '@/components/LanguageBubbles'
import { SUPPORTED_TARGET_LANGUAGES } from '@/lib/target-languages'
vi.mock('next-intl', () => ({
- useTranslations: () => (key: string) => key,
+ useTranslations: (namespace: string) => (key: string) =>
+ namespace === 'targetLanguages' ? `Localized ${key}` : key,
}))
vi.mock('next/image', () => ({
@@ -29,6 +30,7 @@ describe('LanguageBubbles', () => {
expect(screen.getAllByRole('img')).toHaveLength(
SUPPORTED_TARGET_LANGUAGES.length
)
+ expect(screen.getByAltText('Localized de-DE')).toBeInTheDocument()
})
it('positions bubbles dynamically from the supported language count', () => {
diff --git a/frontend/tests/components/ProfileSection.test.tsx b/frontend/tests/components/ProfileSection.test.tsx
index 9f842b8df..9b350c8d6 100644
--- a/frontend/tests/components/ProfileSection.test.tsx
+++ b/frontend/tests/components/ProfileSection.test.tsx
@@ -478,14 +478,14 @@ describe('ProfileSection', () => {
})
})
- it('shows error message on network failure', async () => {
+ it('shows a localized error on network failure', async () => {
mockApiFetch.mockRejectedValue(new Error('Network error'))
render( )
fireEvent.click(screen.getByText('saveChanges'))
- // The component uses err.message (the thrown Error's message) as the displayed text.
await waitFor(() => {
- expect(screen.getByText(/Network error/)).toBeDefined()
+ expect(screen.getByText(/saveFailed/)).toBeDefined()
})
+ expect(screen.queryByText(/Network error/)).toBeNull()
})
it('message type is error for failures', async () => {
diff --git a/frontend/tests/components/ReviewPrompt.test.tsx b/frontend/tests/components/ReviewPrompt.test.tsx
index 642631f9c..f81a1eb75 100644
--- a/frontend/tests/components/ReviewPrompt.test.tsx
+++ b/frontend/tests/components/ReviewPrompt.test.tsx
@@ -25,7 +25,10 @@ const translations: Record = {
submit: 'Submit',
}
-const translate = (key: string) => translations[key] ?? key
+const translate = (key: string, values?: { rating: number }) =>
+ key === 'starsLabel'
+ ? `${values?.rating} out of 5 stars`
+ : (translations[key] ?? key)
vi.mock('next-intl', () => ({
useTranslations: () => translate,
@@ -66,9 +69,9 @@ describe('ReviewPrompt', () => {
resolveReview({ has_review: false, review: null })
})
await screen.findByText('Rating required')
- fireEvent.click(screen.getByLabelText('5 stars'))
+ fireEvent.click(screen.getByLabelText('5 out of 5 stars'))
await waitFor(() =>
- expect(screen.getByLabelText('5 stars')).toHaveAttribute(
+ expect(screen.getByLabelText('5 out of 5 stars')).toHaveAttribute(
'aria-checked',
'true'
)
@@ -96,9 +99,9 @@ describe('ReviewPrompt', () => {
resolveReview({ has_review: false, review: null })
})
await screen.findByText('Rating required')
- fireEvent.click(screen.getByLabelText('4 stars'))
+ fireEvent.click(screen.getByLabelText('4 out of 5 stars'))
await waitFor(() =>
- expect(screen.getByLabelText('4 stars')).toHaveAttribute(
+ expect(screen.getByLabelText('4 out of 5 stars')).toHaveAttribute(
'aria-checked',
'true'
)
diff --git a/frontend/tests/i18n/admin-messages.test.ts b/frontend/tests/i18n/admin-messages.test.ts
index 465e1f4db..42b64cdea 100644
--- a/frontend/tests/i18n/admin-messages.test.ts
+++ b/frontend/tests/i18n/admin-messages.test.ts
@@ -39,9 +39,20 @@ describe('shared interface messages', () => {
})
expect(t('common.errorMessage'), locale).not.toBe('common.errorMessage')
+ expect(t('billing.pastDueTitle'), locale).not.toBe('billing.pastDueTitle')
+ expect(t('billing.pastDueDesc'), locale).not.toBe('billing.pastDueDesc')
expect(t('vocabulary.sets', { count: 1 }), locale).toContain('1')
expect(t('vocabulary.sets', { count: 2 }), locale).toContain('2')
expect(errors, locale).toEqual([])
}
})
+
+ it.each([
+ ['ru', ru, '2 из 5 звезд'],
+ ['pl', pl, '2 z 5 gwiazdek'],
+ ['hr', hr, 'Ocjena: 2 od 5'],
+ ])('formats rating labels in %s', (locale, messages, expected) => {
+ const t = createTranslator({ locale, messages })
+ expect(t('landingReviews.starsLabel', { rating: 2 })).toBe(expected)
+ })
})
diff --git a/specs/reviews.instructions.md b/specs/reviews.instructions.md
index bb7d2b433..f54bfd194 100644
--- a/specs/reviews.instructions.md
+++ b/specs/reviews.instructions.md
@@ -111,8 +111,8 @@ all approved reviews.
## Review prompt
`ReviewForm` is shared by automatic prompts and Settings. It provides an accessible 1-5-star radio
-group, optional comment, submit state, and generic localized errors. Rating is required; comment is
-not.
+group with locale-specific "out of five" labels, optional comment, submit state, and generic localized
+errors. Rating is required; comment is not.
When the prompt opens, it requests `/api/reviews/me`:
@@ -165,8 +165,8 @@ The Community section in Settings loads `/api/reviews/me`.
## Administrative frontend
`/admin/reviews` uses ten-item pages and supports approval-state and rating filters. Backend language
-and order filters are not exposed by this page. It shows snapshot name, status, stars, English
-language name, interface-locale date, and comment/fallback.
+and order filters are not exposed by this page. It shows snapshot name, status, localized star labels
+and learning-language name, interface-locale date, and comment/fallback.
Administrators can approve, unapprove, and delete with confirmation. After mutation, the page reloads
the effective page so filters and page bounds remain valid. Backend authorization protects every
From 1b5d206224f694af0ecb482da9dd73eb07030c17 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Thu, 24 Sep 2026 20:24:44 +0200
Subject: [PATCH 13/14] Improve locale handling and voice message localization
- Pass the selected locale into email template rendering and set HTML lang accordingly
- Update transactional email tests to verify rendered locale declarations
- Sync locale cookies on profile save and reload only when the displayed locale changes
- Enforce 10-25 character password length on reset and surface requirements in the UI
- Localize conversation error codes and hide backend transport diagnostics from users
- Add translated voice trial and speech error strings across supported locales
- Fix Croatian comment pluralization and extend plural coverage tests
- Update password reset and voice message localization tests to cover the new behavior
---
CHANGELOG.md | 9 +++
backend/app/services/email_service.py | 10 ++-
.../app/templates/email/account_deleted.html | 2 +-
backend/app/templates/email/contact.html | 2 +-
.../templates/email/feedback_submitted.html | 2 +-
.../app/templates/email/reset_password.html | 2 +-
.../app/templates/email/review_submitted.html | 2 +-
backend/app/templates/email/verify_email.html | 2 +-
backend/app/templates/email/welcome.html | 2 +-
backend/tests/test_email_service.py | 72 +++++++++++++++++++
.../src/app/(auth)/reset-password/page.tsx | 19 ++++-
.../conversation/ConversationMode.tsx | 36 +++++-----
.../components/settings/ProfileSection.tsx | 8 +--
.../tests/app/reset-password-errors.test.tsx | 52 +++++++++++++-
.../components/ConversationMode.test.tsx | 38 ++++++++++
.../tests/components/ProfileSection.test.tsx | 69 ++++++++++++++++++
frontend/tests/i18n/hr-plurals.test.tsx | 19 +++--
frontend/tests/i18n/voice-messages.test.ts | 45 ++++++++++++
messages/da.json | 5 +-
messages/de.json | 23 +++---
messages/en.json | 5 +-
messages/es.json | 5 +-
messages/fi.json | 5 +-
messages/fr.json | 23 +++---
messages/hr.json | 7 +-
messages/it.json | 23 +++---
messages/nl.json | 23 +++---
messages/pl.json | 23 +++---
messages/pt.json | 23 +++---
messages/ro.json | 23 +++---
messages/ru.json | 23 +++---
messages/sv.json | 5 +-
messages/tr.json | 5 +-
specs/feedback.instructions.md | 2 +
specs/platform.instructions.md | 10 ++-
specs/services.instructions.md | 4 ++
specs/voice-conversation.instructions.md | 5 ++
37 files changed, 502 insertions(+), 131 deletions(-)
create mode 100644 frontend/tests/i18n/voice-messages.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45a2e3519..589a8beb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Interface text, resource counts, accessible controls, payment-recovery and error messages, administrator badges, review ratings, learning-language names, dates, numbers, and prices follow the selected interface language. Study help displays the user's native-language name, and voice-session dates follow native-language conventions.
- The review prompt waits for the existing-review lookup before showing the rating form. The Dutch, Polish, Romanian, and Russian FAQs explain the default public-registration setting.
+### Fixed
+
+- Saving the interface language synchronizes locale cookies and applies the selection when the displayed language differs, including when that selection was already saved in the profile.
+- Post-assessment voice-trial prompts and Premium continuation messages are translated in French, Portuguese, German, Italian, Polish, Dutch, Romanian, and Russian.
+- Voice transcription, tutor-response, speech-generation, and transport errors display localized messages instead of raw backend or WebSocket diagnostics.
+- Croatian feedback comment counts use the correct plural forms, including counts such as 21 and 31.
+- Password reset displays the current password requirements and validates the 10-25 character length consistently with the backend, with translated errors across all fifteen interface locales.
+- Transactional emails declare the language of their rendered content in HTML, including the English fallback for unsupported locales.
+
## [1.9.20] - 2026-09-24
### Fixed
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index dba2adc5b..05092b8c8 100644
--- a/backend/app/services/email_service.py
+++ b/backend/app/services/email_service.py
@@ -1054,10 +1054,11 @@ def _get_mail_config() -> ConnectionConfig:
}
-def _render_template(name: str, context: dict) -> str:
+def _render_template(name: str, context: dict, locale: str = "en") -> str:
"""Render a plain HTML template, escaping interpolated values by default."""
path = _TEMPLATES_DIR / name
html = path.read_text(encoding="utf-8")
+ context = {**context, "locale": locale if locale in _VERIFY_I18N else "en"}
for key, value in context.items():
rendered = str(value) if isinstance(value, _SafeHtml) else escape(str(value), quote=True)
html = html.replace(f"{{{{{key}}}}}", rendered)
@@ -1097,6 +1098,7 @@ async def send_verification_email(
"url": url,
"base_url": settings.APP_BASE_URL,
},
+ locale=locale,
)
await _dispatch(strings["subject"], [to], html)
@@ -1120,6 +1122,7 @@ async def send_reset_password_email(
"url": url,
"base_url": settings.APP_BASE_URL,
},
+ locale=locale,
)
await _dispatch(strings["subject"], [to], html)
@@ -1143,6 +1146,7 @@ async def send_welcome_email(to: str, display_name: str, locale: str = "en") ->
"url": url,
"base_url": settings.APP_BASE_URL,
},
+ locale=locale,
)
await _dispatch(strings["subject"], [to], html)
@@ -1171,6 +1175,7 @@ async def send_contact_email(
"footer": strings["footer"],
"base_url": settings.APP_BASE_URL,
},
+ locale=locale,
)
message = MessageSchema(
subject=f"{strings['subject_prefix']} {subject}",
@@ -1200,6 +1205,7 @@ async def send_account_deleted_email(to: str, display_name: str, locale: str = "
"footer": strings["footer"],
"base_url": settings.APP_BASE_URL,
},
+ locale=locale,
)
await _dispatch(strings["subject"], [to], html)
@@ -1244,6 +1250,7 @@ async def send_feedback_notification(
"admin_url": admin_url,
"base_url": settings.APP_BASE_URL,
},
+ locale=locale,
)
subject_prefix = (
strings["feature_subject_prefix"]
@@ -1292,6 +1299,7 @@ async def send_review_notification(
"footer": strings["footer"],
"base_url": settings.APP_BASE_URL,
},
+ locale=locale,
)
await _dispatch(
f"{strings['subject_prefix']} {rating}/5 — {user_display_name}",
diff --git a/backend/app/templates/email/account_deleted.html b/backend/app/templates/email/account_deleted.html
index 737668ce0..7e9ee6010 100644
--- a/backend/app/templates/email/account_deleted.html
+++ b/backend/app/templates/email/account_deleted.html
@@ -1,5 +1,5 @@
-
+
diff --git a/backend/app/templates/email/contact.html b/backend/app/templates/email/contact.html
index a56fa5667..982ff4a27 100644
--- a/backend/app/templates/email/contact.html
+++ b/backend/app/templates/email/contact.html
@@ -1,5 +1,5 @@
-
+
diff --git a/backend/app/templates/email/feedback_submitted.html b/backend/app/templates/email/feedback_submitted.html
index fa3430746..05cb6b2dc 100644
--- a/backend/app/templates/email/feedback_submitted.html
+++ b/backend/app/templates/email/feedback_submitted.html
@@ -1,5 +1,5 @@
-
+
diff --git a/backend/app/templates/email/reset_password.html b/backend/app/templates/email/reset_password.html
index 5b4fbdcca..14817d4d5 100644
--- a/backend/app/templates/email/reset_password.html
+++ b/backend/app/templates/email/reset_password.html
@@ -1,5 +1,5 @@
-
+
diff --git a/backend/app/templates/email/review_submitted.html b/backend/app/templates/email/review_submitted.html
index 35976b0de..0ce904374 100644
--- a/backend/app/templates/email/review_submitted.html
+++ b/backend/app/templates/email/review_submitted.html
@@ -1,5 +1,5 @@
-
+
diff --git a/backend/app/templates/email/verify_email.html b/backend/app/templates/email/verify_email.html
index 683acad4b..40e232004 100644
--- a/backend/app/templates/email/verify_email.html
+++ b/backend/app/templates/email/verify_email.html
@@ -1,5 +1,5 @@
-
+
diff --git a/backend/app/templates/email/welcome.html b/backend/app/templates/email/welcome.html
index 46d332b42..142eeff92 100644
--- a/backend/app/templates/email/welcome.html
+++ b/backend/app/templates/email/welcome.html
@@ -1,5 +1,5 @@
-
+
diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py
index c2a31aefb..49ac712ec 100644
--- a/backend/tests/test_email_service.py
+++ b/backend/tests/test_email_service.py
@@ -1,3 +1,11 @@
+from html import escape
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.schemas.auth import SUPPORTED_UI_LOCALES
+from app.services import email_service
from app.services.email_service import _render_template, _safe_html
@@ -21,11 +29,75 @@ def test_render_template_escapes_user_controlled_values():
assert " " not in html
assert 'Click ' not in html
assert "urgent " not in html
+ assert '' in html
assert "<img src=x onerror=alert(1)>" in html
assert "<a href="https://phishing.example">Click</a>" in html
assert "<strong>urgent</strong> & dangerous" in html
+@pytest.mark.parametrize("locale", [*sorted(SUPPORTED_UI_LOCALES), "unsupported"])
+@pytest.mark.parametrize(
+ ("send", "args", "catalog"),
+ [
+ (
+ email_service.send_verification_email,
+ ("student@example.com", "Student", "test-token"),
+ email_service._VERIFY_I18N,
+ ),
+ (
+ email_service.send_reset_password_email,
+ ("student@example.com", "Student", "test-token"),
+ email_service._RESET_I18N,
+ ),
+ (
+ email_service.send_welcome_email,
+ ("student@example.com", "Student"),
+ email_service._WELCOME_I18N,
+ ),
+ (
+ email_service.send_account_deleted_email,
+ ("student@example.com", "Student"),
+ email_service._DELETION_I18N,
+ ),
+ (
+ email_service.send_contact_email,
+ ("student@example.com", "Subject", "Description"),
+ email_service._CONTACT_I18N,
+ ),
+ (
+ email_service.send_feedback_notification,
+ ("feature", "Title", "Description", "student", 1),
+ email_service._FEEDBACK_I18N,
+ ),
+ (
+ email_service.send_review_notification,
+ ("Student", 5, None, "en-GB", 1),
+ email_service._REVIEW_I18N,
+ ),
+ ],
+ ids=["verification", "reset", "welcome", "deletion", "contact", "feedback", "review"],
+)
+async def test_email_declares_the_language_of_its_rendered_content(
+ monkeypatch, locale, send, args, catalog
+):
+ send_message = AsyncMock()
+ monkeypatch.setattr(email_service.settings, "EMAIL_ENABLED", True)
+ monkeypatch.setattr(email_service.settings, "CONTACT_EMAIL", "admin@example.com")
+ monkeypatch.setattr(email_service, "_get_mail_config", lambda: None)
+ monkeypatch.setattr(
+ email_service, "FastMail", lambda _: SimpleNamespace(send_message=send_message)
+ )
+
+ await send(*args, locale=locale)
+
+ send_message.assert_awaited_once()
+ message = send_message.await_args.args[0]
+ expected_locale = locale if locale in SUPPORTED_UI_LOCALES else "en"
+ assert f'' in message.body
+ assert escape(catalog[expected_locale]["footer"], quote=True) in message.body
+ assert "{{" not in message.body
+
+
def test_render_template_preserves_explicitly_trusted_html():
html = _render_template(
"verify_email.html",
diff --git a/frontend/src/app/(auth)/reset-password/page.tsx b/frontend/src/app/(auth)/reset-password/page.tsx
index f1b59f73d..22d46023c 100644
--- a/frontend/src/app/(auth)/reset-password/page.tsx
+++ b/frontend/src/app/(auth)/reset-password/page.tsx
@@ -27,10 +27,15 @@ function ResetPasswordContent() {
setError(t('mismatch'))
return
}
- if (password.length < 8) {
+ const passwordLength = [...password].length
+ if (passwordLength < 10) {
setError(t('tooShort'))
return
}
+ if (passwordLength > 25) {
+ setError(tRegister('invalidPassword'))
+ return
+ }
setLoading(true)
try {
const res = await apiFetch('/api/auth/reset-password', {
@@ -91,7 +96,10 @@ function ResetPasswordContent() {
)}
{error && (
-
+
✕ {error}
)}
@@ -102,8 +110,15 @@ function ResetPasswordContent() {
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
+ aria-describedby="reset-password-requirements"
className="bg-fl-bg border-fl-border text-fl-fg placeholder:text-fl-muted-4 focus:border-fl-border-2 w-full border px-4 py-3 font-mono text-xs transition-colors focus:outline-none"
/>
+
+ {tRegister('invalidPassword')}
+
= {
+ auth_failed: t('errorUnauthorized'),
+ services_disabled: t('errorServicesDisabled'),
+ quota_exceeded_sessions: t('quotaExceededSessions'),
+ quota_exceeded_time: t('quotaExceededTime'),
+ quota_exceeded_tokens: t('quotaExceededTokens'),
+ no_active_plan: tCommon('noActivePlan'),
+ stt_failed: t('errorTranscription'),
+ llm_failed: t('errorResponse'),
+ tts_failed: t('errorSpeech'),
+ }
+ setErrorMsg(errorMessages[msg.code] ?? tCommon('errorMessage'))
setStatus('error')
assistantTurnActiveRef.current = false
if (
@@ -878,6 +877,7 @@ export default function ConversationMode({
finalizeSession()
}
break
+ }
case 'memory_updated':
showMemoryToast()
@@ -892,7 +892,7 @@ export default function ConversationMode({
if (!isCurrent()) return
if (!cleanEndRef.current) {
convLogger.error('ws onerror')
- setErrorMsg(`${t('errorConnection')} [onerror → ${url}]`)
+ setErrorMsg(t('errorConnection'))
setStatus('error')
finalizeSession()
}
@@ -905,9 +905,7 @@ export default function ConversationMode({
if (ev.code === 1008) {
setErrorMsg(t('errorUnauthorized'))
} else if (ev.code !== 1000) {
- setErrorMsg(
- `${t('errorConnection')} [code ${ev.code}: ${ev.reason || 'no reason'} → ${url}]`
- )
+ setErrorMsg(t('errorConnection'))
}
setStatus('error')
finalizeSession()
@@ -915,9 +913,9 @@ export default function ConversationMode({
cleanEndRef.current = false
}
},
- // eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
+ tCommon,
targetLanguage,
voiceTrialToken,
trialMode,
diff --git a/frontend/src/components/settings/ProfileSection.tsx b/frontend/src/components/settings/ProfileSection.tsx
index 0b7e362e1..85b400488 100644
--- a/frontend/src/components/settings/ProfileSection.tsx
+++ b/frontend/src/components/settings/ProfileSection.tsx
@@ -168,10 +168,10 @@ export function ProfileSection({ title }: { title?: string } = {}) {
setPassword('')
setConfirmPassword('')
- if (uiLocale !== user?.ui_locale) {
- const opts = `path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax${window.location.protocol === 'https:' ? '; Secure' : ''}`
- document.cookie = `NEXT_LOCALE=${uiLocale}; ${opts}`
- document.cookie = `LOCALE_DETECTED=1; ${opts}`
+ const opts = `path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax${window.location.protocol === 'https:' ? '; Secure' : ''}`
+ document.cookie = `NEXT_LOCALE=${uiLocale}; ${opts}`
+ document.cookie = `LOCALE_DETECTED=1; ${opts}`
+ if (uiLocale !== currentLocale) {
window.location.reload()
}
} catch {
diff --git a/frontend/tests/app/reset-password-errors.test.tsx b/frontend/tests/app/reset-password-errors.test.tsx
index cd95826d7..eb99cbeb1 100644
--- a/frontend/tests/app/reset-password-errors.test.tsx
+++ b/frontend/tests/app/reset-password-errors.test.tsx
@@ -44,7 +44,57 @@ describe('reset password errors', () => {
})
fireEvent.click(screen.getByRole('button', { name: 'auth.resetPassword.submit' }))
- expect(await screen.findByText(new RegExp(message))).toBeInTheDocument()
+ expect(await screen.findByRole('alert')).toHaveTextContent(message)
expect(screen.queryByText(detail)).toBeNull()
})
+
+ it.each([
+ ['Valid12!', 'auth.resetPassword.tooShort'],
+ ['Valid123!', 'auth.resetPassword.tooShort'],
+ [`A1!${'😀'.repeat(6)}`, 'auth.resetPassword.tooShort'],
+ [`A1!${'a'.repeat(23)}`, 'auth.register.invalidPassword'],
+ ])('rejects an invalid password length before submitting: %s', async (password, message) => {
+ render(
)
+ fireEvent.change(screen.getByPlaceholderText('auth.resetPassword.newPassword'), {
+ target: { value: password },
+ })
+ fireEvent.change(screen.getByPlaceholderText('auth.resetPassword.confirmPassword'), {
+ target: { value: password },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'auth.resetPassword.submit' }))
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(message)
+ expect(apiFetch).not.toHaveBeenCalled()
+ })
+
+ it.each(['Abcdefg1!?', `A1!${'a'.repeat(22)}`, `A1!${'😀'.repeat(22)}`])(
+ 'submits a password within the backend length limits: %s',
+ async (password) => {
+ apiFetch.mockResolvedValue(new Response(null, { status: 503 }))
+ render(
)
+ fireEvent.change(screen.getByPlaceholderText('auth.resetPassword.newPassword'), {
+ target: { value: password },
+ })
+ fireEvent.change(screen.getByPlaceholderText('auth.resetPassword.confirmPassword'), {
+ target: { value: password },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'auth.resetPassword.submit' }))
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('common.errorMessage')
+ expect(apiFetch).toHaveBeenCalledWith(
+ '/api/auth/reset-password',
+ expect.objectContaining({
+ body: JSON.stringify({ token: 'valid', new_password: password }),
+ })
+ )
+ }
+ )
+
+ it('shows the translated password requirements before submission', () => {
+ render(
)
+
+ expect(screen.getByPlaceholderText('auth.resetPassword.newPassword')).toHaveAccessibleDescription(
+ 'auth.register.invalidPassword'
+ )
+ })
})
diff --git a/frontend/tests/components/ConversationMode.test.tsx b/frontend/tests/components/ConversationMode.test.tsx
index b00850656..c8d3d0980 100644
--- a/frontend/tests/components/ConversationMode.test.tsx
+++ b/frontend/tests/components/ConversationMode.test.tsx
@@ -180,6 +180,44 @@ describe('ConversationMode session lifecycle', () => {
expect(ws.close).not.toHaveBeenCalled()
})
+ it.each([
+ ['stt_failed', 'errorTranscription'],
+ ['llm_failed', 'errorResponse'],
+ ['tts_failed', 'errorSpeech'],
+ ['auth_failed', 'errorUnauthorized'],
+ ['services_disabled', 'errorServicesDisabled'],
+ ['quota_exceeded_sessions', 'quotaExceededSessions'],
+ ['quota_exceeded_time', 'quotaExceededTime'],
+ ['quota_exceeded_tokens', 'quotaExceededTokens'],
+ ['no_active_plan', 'noActivePlan'],
+ ['unknown_server_error', 'errorMessage'],
+ ])('localizes %s without displaying the backend message', async (code, key) => {
+ render(
)
+ const ws = await start()
+
+ act(() => ws.message({
+ type: 'error',
+ code,
+ message: 'Untranslated backend failure',
+ }))
+
+ expect(screen.getByText(new RegExp(key))).toBeInTheDocument()
+ expect(screen.queryByText(/Untranslated backend failure/)).toBeNull()
+ })
+
+ it.each(['onerror', 'onclose'])('localizes %s without displaying transport diagnostics', async (event) => {
+ render(
)
+ const ws = await start()
+
+ act(() => {
+ if (event === 'onerror') ws.onerror?.()
+ else ws.onclose?.({ code: 1011, reason: 'Untranslated close reason' })
+ })
+
+ expect(screen.getByText('✕ errorConnection')).toBeInTheDocument()
+ expect(screen.queryByText(/Untranslated close reason|\[onerror|\[code/)).toBeNull()
+ })
+
it('clears visual speech and discards the unfinished segment on misfire', async () => {
render(
)
const ws = await start()
diff --git a/frontend/tests/components/ProfileSection.test.tsx b/frontend/tests/components/ProfileSection.test.tsx
index 9b350c8d6..f498e96d7 100644
--- a/frontend/tests/components/ProfileSection.test.tsx
+++ b/frontend/tests/components/ProfileSection.test.tsx
@@ -464,6 +464,75 @@ describe('ProfileSection', () => {
expect(reloadSpy).not.toHaveBeenCalled()
})
+ it('applies a saved locale when the interface is displaying another language', async () => {
+ const reloadSpy = vi.fn()
+ vi.stubGlobal('location', {
+ ...window.location,
+ reload: reloadSpy,
+ protocol: 'http:',
+ })
+ useAuthStore.setState({ user: { ...defaultUser, ui_locale: 'fi' } })
+ document.cookie = 'NEXT_LOCALE=en; path=/'
+ mockApiFetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ id: 1, ui_locale: 'fi' }),
+ })
+
+ render(
)
+ expect(selectAfterLabelText('uiLocale')).toHaveValue('fi')
+ fireEvent.click(screen.getByText('saveChanges'))
+
+ await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1))
+ expect(document.cookie).toContain('NEXT_LOCALE=fi')
+ expect(document.cookie).toContain('LOCALE_DETECTED=1')
+ })
+
+ it('synchronizes cookies without reloading when choosing the displayed locale', async () => {
+ const reloadSpy = vi.fn()
+ vi.stubGlobal('location', {
+ ...window.location,
+ reload: reloadSpy,
+ protocol: 'http:',
+ })
+ useAuthStore.setState({ user: { ...defaultUser, ui_locale: 'fi' } })
+ document.cookie = 'NEXT_LOCALE=fi; path=/'
+ mockApiFetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ id: 1, ui_locale: 'en' }),
+ })
+
+ render(
)
+ fireEvent.change(selectAfterLabelText('uiLocale'), {
+ target: { value: 'en' },
+ })
+ fireEvent.click(screen.getByText('saveChanges'))
+
+ await waitFor(() => expect(screen.getByText(/saved/)).toBeDefined())
+ expect(document.cookie).toContain('NEXT_LOCALE=en')
+ expect(reloadSpy).not.toHaveBeenCalled()
+ })
+
+ it('keeps the current locale when saving the profile fails', async () => {
+ const reloadSpy = vi.fn()
+ vi.stubGlobal('location', {
+ ...window.location,
+ reload: reloadSpy,
+ protocol: 'http:',
+ })
+ document.cookie = 'NEXT_LOCALE=en; path=/'
+ mockApiFetch.mockResolvedValue({ ok: false })
+
+ render(
)
+ fireEvent.change(selectAfterLabelText('uiLocale'), {
+ target: { value: 'hr' },
+ })
+ fireEvent.click(screen.getByText('saveChanges'))
+
+ await waitFor(() => expect(screen.getByText(/saveFailed/)).toBeDefined())
+ expect(document.cookie).toContain('NEXT_LOCALE=en')
+ expect(reloadSpy).not.toHaveBeenCalled()
+ })
+
// ── API errors ──────────────────────────────────────────────
it('shows error message on API failure', async () => {
diff --git a/frontend/tests/i18n/hr-plurals.test.tsx b/frontend/tests/i18n/hr-plurals.test.tsx
index 1e467af7a..306035b57 100644
--- a/frontend/tests/i18n/hr-plurals.test.tsx
+++ b/frontend/tests/i18n/hr-plurals.test.tsx
@@ -8,6 +8,7 @@ function PluralExamples({ count }: { count: number }) {
const grammar = useTranslations('grammar')
const billing = useTranslations('billing')
const vocabulary = useTranslations('vocabulary')
+ const feedback = useTranslations('feedback')
const common = useTranslations('common')
return (
@@ -15,6 +16,7 @@ function PluralExamples({ count }: { count: number }) {
{grammar('topicsFound', { count })}
{billing('trialDays', { days: count })}
{vocabulary('sets', { count })}
+
{feedback('comments', { count })}
{common('errorMessage')}
>
)
@@ -22,13 +24,15 @@ function PluralExamples({ count }: { count: number }) {
describe('Croatian plural messages', () => {
it.each([
- [1, 'Pronađena 1 tema', '1 dan probnog razdoblja', '1 zbirka'],
- [2, 'Pronađene 2 teme', '2 dana probnog razdoblja', '2 zbirke'],
- [5, 'Pronađeno 5 tema', '5 dana probnog razdoblja', '5 zbirki'],
- [21, 'Pronađena 21 tema', '21 dan probnog razdoblja', '21 zbirka'],
- [22, 'Pronađene 22 teme', '22 dana probnog razdoblja', '22 zbirke'],
- [25, 'Pronađeno 25 tema', '25 dana probnog razdoblja', '25 zbirki'],
- ])('formats %i with the correct Croatian forms', (count, topics, days, sets) => {
+ [1, 'Pronađena 1 tema', '1 dan probnog razdoblja', '1 zbirka', '1 komentar'],
+ [2, 'Pronađene 2 teme', '2 dana probnog razdoblja', '2 zbirke', '2 komentara'],
+ [5, 'Pronađeno 5 tema', '5 dana probnog razdoblja', '5 zbirki', '5 komentara'],
+ [11, 'Pronađeno 11 tema', '11 dana probnog razdoblja', '11 zbirki', '11 komentara'],
+ [21, 'Pronađena 21 tema', '21 dan probnog razdoblja', '21 zbirka', '21 komentar'],
+ [22, 'Pronađene 22 teme', '22 dana probnog razdoblja', '22 zbirke', '22 komentara'],
+ [25, 'Pronađeno 25 tema', '25 dana probnog razdoblja', '25 zbirki', '25 komentara'],
+ [31, 'Pronađena 31 tema', '31 dan probnog razdoblja', '31 zbirka', '31 komentar'],
+ ])('formats %i with the correct Croatian forms', (count, topics, days, sets, comments) => {
render(
@@ -38,6 +42,7 @@ describe('Croatian plural messages', () => {
expect(screen.getByText(topics)).toBeInTheDocument()
expect(screen.getByText(days)).toBeInTheDocument()
expect(screen.getByText(sets)).toBeInTheDocument()
+ expect(screen.getByText(comments)).toBeInTheDocument()
expect(screen.getByText(hr.common.errorMessage)).toBeInTheDocument()
})
})
diff --git a/frontend/tests/i18n/voice-messages.test.ts b/frontend/tests/i18n/voice-messages.test.ts
new file mode 100644
index 000000000..f032ddae1
--- /dev/null
+++ b/frontend/tests/i18n/voice-messages.test.ts
@@ -0,0 +1,45 @@
+import { createTranslator } from 'next-intl'
+import { describe, expect, it } from 'vitest'
+
+import { SUPPORTED_LOCALES } from '@/lib/locales'
+import en from '../../../messages/en.json'
+
+const messageKeys = [
+ 'assessment.voiceTrialLabel',
+ 'assessment.voiceTrialTitle',
+ 'assessment.voiceTrialDesc',
+ 'assessment.voiceTrialStart',
+ 'assessment.voiceTrialSkip',
+ 'conversation.trialBanner',
+ 'conversation.trialCtaLabel',
+ 'conversation.trialCtaTitle',
+ 'conversation.trialCtaDesc',
+ 'conversation.errorTranscription',
+ 'conversation.errorResponse',
+ 'conversation.errorSpeech',
+] as const
+
+const english = createTranslator({ locale: 'en', messages: en })
+
+describe.each(SUPPORTED_LOCALES)('voice messages in %s', (locale) => {
+ it('renders translated trial and error messages without English fallback', async () => {
+ const messages = (await import(`../../../messages/${locale}.json`)).default
+ const t = createTranslator({
+ locale,
+ messages,
+ onError: (error) => {
+ throw error
+ },
+ })
+
+ for (const key of messageKeys) {
+ const rendered = t(key, { minutes: 5 })
+ expect(rendered.trim()).not.toBe('')
+ expect(rendered).not.toBe(key)
+ expect(rendered).not.toContain('{minutes}')
+ if (locale !== 'en') {
+ expect(rendered).not.toBe(english(key, { minutes: 5 }))
+ }
+ }
+ })
+})
diff --git a/messages/da.json b/messages/da.json
index eb300319a..59787aa10 100644
--- a/messages/da.json
+++ b/messages/da.json
@@ -209,7 +209,7 @@
"saving": "Gemmer...",
"success": "Adgangskoden er opdateret. Du sendes videre til login...",
"mismatch": "Adgangskoderne stemmer ikke overens",
- "tooShort": "Adgangskoden skal være på mindst otte tegn",
+ "tooShort": "Adgangskoden skal være på mindst 10 tegn",
"missingToken": "Ugyldigt link til nulstilling. Anmod om at nulstille adgangskoden igen.",
"error": "Ugyldigt eller udløbet link til nulstilling",
"backToLogin": "Tilbage til login"
@@ -1165,6 +1165,9 @@
"statusError": "Fejl",
"errorServicesDisabled": "Stemmetjenester er ikke aktiveret på denne server.",
"errorConnection": "Forbindelsesfejl. Prøv igen.",
+ "errorTranscription": "Din tale kunne ikke omdannes til tekst. Prøv at sige det igen.",
+ "errorResponse": "Lingu kunne ikke svare. Prøv igen.",
+ "errorSpeech": "Lingus svar kunne ikke læses op. Prøv igen.",
"errorUnauthorized": "Sessionen er udløbet. Genindlæs siden.",
"errorMic": "Adgang til mikrofonen blev afvist.",
"errorVadInit": "Talegenkendelsen kunne ikke startes. Kontrollér, at din browser understøtter WebAssembly.",
diff --git a/messages/de.json b/messages/de.json
index e31e5f21a..ad80876ee 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -209,7 +209,7 @@
"saving": "Speichern...",
"success": "Passwort aktualisiert. Weiterleitung...",
"mismatch": "Passwörter stimmen nicht überein",
- "tooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
+ "tooShort": "Das Passwort muss mindestens 10 Zeichen lang sein",
"missingToken": "Ungültiger Link. Bitte fordere eine neue Zurücksetzung an.",
"error": "Ungültiger oder abgelaufener Link",
"backToLogin": "Zurück zum Login"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Wenn Sie den Stufentest vor dem Abschluss verlassen, geht Ihr Fortschritt verloren und Sie müssen ihn wiederholen.",
"startWarningConfirm": "Beginnen",
"startMyPlan": "Meinen Plan starten",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Sprachgespräch ausprobieren",
+ "voiceTrialTitle": "Probiere jetzt ein echtes Gespräch aus",
+ "voiceTrialDesc": "Sprich {minutes} Minuten lang kostenlos mit Lingu. Dein Tutor passt sich an die Stufe an, die du gerade freigeschaltet hast.",
+ "voiceTrialStart": "Kostenloses Gespräch starten",
+ "voiceTrialSkip": "Vorerst überspringen"
},
"plan": {
"unitLocked": "Einheit gesperrt",
@@ -1165,6 +1165,9 @@
"statusError": "Fehler",
"errorServicesDisabled": "Sprachdienste sind auf diesem Server nicht aktiviert.",
"errorConnection": "Verbindungsfehler. Bitte versuche es erneut.",
+ "errorTranscription": "Deine Spracheingabe konnte nicht transkribiert werden. Bitte sprich noch einmal.",
+ "errorResponse": "Lingu konnte nicht antworten. Bitte versuche es erneut.",
+ "errorSpeech": "Die Audioausgabe für Lingus Antwort konnte nicht erstellt werden. Bitte versuche es erneut.",
"errorUnauthorized": "Sitzung abgelaufen. Bitte neu laden.",
"errorMic": "Mikrofonzugriff verweigert.",
"errorVadInit": "Fehler beim Initialisieren der Spracherkennung. Überprüfe, ob dein Browser WebAssembly unterstützt.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Kostenlose Stimme: {remaining}/{limit} Min diese Woche",
"quotaTokens": "Token diesen Monat",
"memoryUpdated": "Erinnerung gespeichert",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Kostenloses Gespräch nach der Einstufung · {minutes} Min.",
+ "trialCtaLabel": "Sprich weiter",
+ "trialCtaTitle": "Übe weiter mit Lingu",
+ "trialCtaDesc": "Schalte mit Premium vollständige Sprachgespräche, den Tutor-Chat sowie Hör- und Leseübungen frei.",
"startersHint": "Oder mit einem Thema beginnen",
"starters": [
"Kunst und Kreativität",
diff --git a/messages/en.json b/messages/en.json
index 78129dfad..6f3b470c1 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -209,7 +209,7 @@
"saving": "Saving...",
"success": "Password updated. Redirecting to login...",
"mismatch": "Passwords do not match",
- "tooShort": "Password must be at least 8 characters",
+ "tooShort": "Password must be at least 10 characters",
"missingToken": "Invalid reset link. Please request a new password reset.",
"error": "Invalid or expired reset link",
"backToLogin": "Back to login"
@@ -1165,6 +1165,9 @@
"statusError": "Error",
"errorServicesDisabled": "Voice services are not enabled on this server.",
"errorConnection": "Connection error. Please try again.",
+ "errorTranscription": "Your speech could not be transcribed. Please try speaking again.",
+ "errorResponse": "Lingu could not respond. Please try again.",
+ "errorSpeech": "Could not generate audio for Lingu's reply. Please try again.",
"errorUnauthorized": "Session expired. Please refresh.",
"errorMic": "Microphone access denied.",
"errorVadInit": "Failed to initialize voice recognition. Make sure your browser supports WebAssembly.",
diff --git a/messages/es.json b/messages/es.json
index d2856dd9c..f7e035dcb 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -209,7 +209,7 @@
"saving": "Guardando...",
"success": "Contraseña actualizada. Redirigiendo al inicio de sesión...",
"mismatch": "Las contraseñas no coinciden",
- "tooShort": "La contraseña debe tener al menos 8 caracteres",
+ "tooShort": "La contraseña debe tener al menos 10 caracteres",
"missingToken": "Enlace inválido. Por favor, solicita un nuevo restablecimiento.",
"error": "Enlace inválido o caducado",
"backToLogin": "Volver al inicio de sesión"
@@ -1165,6 +1165,9 @@
"statusError": "Error",
"errorServicesDisabled": "Los servicios de voz no están habilitados en este servidor.",
"errorConnection": "Error de conexión. Por favor, inténtalo de nuevo.",
+ "errorTranscription": "No se ha podido transcribir lo que has dicho. Intenta hablar de nuevo.",
+ "errorResponse": "Lingu no ha podido responder. Inténtalo de nuevo.",
+ "errorSpeech": "No se ha podido generar el audio de la respuesta de Lingu. Inténtalo de nuevo.",
"errorUnauthorized": "Sesión expirada. Por favor, actualiza la página.",
"errorMic": "Acceso al micrófono denegado.",
"errorVadInit": "Error al inicializar el reconocimiento de voz. Comprueba que el navegador es compatible con WebAssembly.",
diff --git a/messages/fi.json b/messages/fi.json
index 319b44cc7..41f1b7dd3 100644
--- a/messages/fi.json
+++ b/messages/fi.json
@@ -209,7 +209,7 @@
"saving": "Tallennetaan...",
"success": "Salasana päivitetty. Siirryt kirjautumiseen...",
"mismatch": "Salasanat eivät täsmää",
- "tooShort": "Salasanassa on oltava vähintään kahdeksan merkkiä",
+ "tooShort": "Salasanassa on oltava vähintään 10 merkkiä",
"missingToken": "Virheellinen palautuslinkki. Pyydä uutta salasanan palautusta.",
"error": "Virheellinen tai vanhentunut palautuslinkki",
"backToLogin": "Takaisin kirjautumiseen"
@@ -1165,6 +1165,9 @@
"statusError": "Virhe",
"errorServicesDisabled": "Äänipalvelut eivät ole käytössä tällä palvelimella.",
"errorConnection": "Yhteysvirhe. Yritä uudelleen.",
+ "errorTranscription": "Puhettasi ei voitu muuntaa tekstiksi. Yritä puhua uudelleen.",
+ "errorResponse": "Lingu ei voinut vastata. Yritä uudelleen.",
+ "errorSpeech": "Lingun vastausta ei voitu muuntaa puheeksi. Yritä uudelleen.",
"errorUnauthorized": "Istunto on vanhentunut. Päivitä sivu.",
"errorMic": "Mikrofonin käyttö estetty.",
"errorVadInit": "Puheentunnistusta ei voitu käynnistää. Varmista, että selaimesi tukee WebAssemblyä.",
diff --git a/messages/fr.json b/messages/fr.json
index c670e3ecd..b8326bd7d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -209,7 +209,7 @@
"saving": "Enregistrement...",
"success": "Mot de passe mis à jour. Redirection...",
"mismatch": "Les mots de passe ne correspondent pas",
- "tooShort": "Le mot de passe doit comporter au moins 8 caractères",
+ "tooShort": "Le mot de passe doit comporter au moins 10 caractères",
"missingToken": "Lien invalide. Veuillez demander une nouvelle réinitialisation.",
"error": "Lien invalide ou expiré",
"backToLogin": "Retour à la connexion"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Si vous quittez le test de niveau avant de le terminer, votre progression sera perdue et vous devrez le repasser.",
"startWarningConfirm": "Commencer",
"startMyPlan": "Démarrer mon plan",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Essai de conversation vocale",
+ "voiceTrialTitle": "Essayez une vraie conversation maintenant",
+ "voiceTrialDesc": "Parlez gratuitement avec Lingu pendant {minutes} minutes. Votre tuteur s'adaptera au niveau que vous venez de débloquer.",
+ "voiceTrialStart": "Commencer la conversation gratuite",
+ "voiceTrialSkip": "Passer pour le moment"
},
"plan": {
"unitLocked": "Unité verrouillée",
@@ -1165,6 +1165,9 @@
"statusError": "Erreur",
"errorServicesDisabled": "Les services vocaux ne sont pas activés sur ce serveur.",
"errorConnection": "Erreur de connexion. Veuillez réessayer.",
+ "errorTranscription": "Impossible de transcrire vos paroles. Veuillez parler à nouveau.",
+ "errorResponse": "Lingu n'a pas pu répondre. Veuillez réessayer.",
+ "errorSpeech": "Impossible de générer l'audio de la réponse de Lingu. Veuillez réessayer.",
"errorUnauthorized": "Session expirée. Veuillez actualiser.",
"errorMic": "Accès au microphone refusé.",
"errorVadInit": "Échec de l'initialisation de la reconnaissance vocale. Vérifiez que votre navigateur prend en charge WebAssembly.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Voix gratuite : {remaining}/{limit} min cette semaine",
"quotaTokens": "Tokens ce mois-ci",
"memoryUpdated": "Mémoire mise à jour",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Conversation gratuite après l'évaluation · {minutes} min",
+ "trialCtaLabel": "Continuez à parler",
+ "trialCtaTitle": "Continuez à pratiquer avec Lingu",
+ "trialCtaDesc": "Débloquez les conversations vocales complètes, le chat avec le tuteur et les exercices de compréhension orale et écrite avec Premium.",
"startersHint": "Ou commencez par un sujet",
"starters": [
"Art et créativité",
diff --git a/messages/hr.json b/messages/hr.json
index 74a44abf6..249a12cc9 100644
--- a/messages/hr.json
+++ b/messages/hr.json
@@ -209,7 +209,7 @@
"saving": "Spremanje...",
"success": "Lozinka je promijenjena. Preusmjeravamo te na prijavu...",
"mismatch": "Lozinke se ne podudaraju",
- "tooShort": "Lozinka mora imati najmanje osam znakova",
+ "tooShort": "Lozinka mora imati najmanje 10 znakova",
"missingToken": "Poveznica nije valjana. Zatraži novo postavljanje lozinke.",
"error": "Poveznica nije valjana ili je istekla",
"backToLogin": "Natrag na prijavu"
@@ -1165,6 +1165,9 @@
"statusError": "Pogreška",
"errorServicesDisabled": "Glasovne usluge nisu omogućene na ovom poslužitelju.",
"errorConnection": "Pogreška veze. Pokušaj ponovno.",
+ "errorTranscription": "Nije moguće pretvoriti tvoj govor u tekst. Pokušaj ponovno govoriti.",
+ "errorResponse": "Lingu nije mogao odgovoriti. Pokušaj ponovno.",
+ "errorSpeech": "Nije moguće pretvoriti Linguov odgovor u govor. Pokušaj ponovno.",
"errorUnauthorized": "Sesija je istekla. Osvježi stranicu.",
"errorMic": "Pristup mikrofonu je odbijen.",
"errorVadInit": "Prepoznavanje govora nije bilo moguće pokrenuti. Provjeri podržava li tvoj preglednik WebAssembly.",
@@ -1598,7 +1601,7 @@
"vote": "1 glas",
"voteAction": "Glasaj",
"removeVoteAction": "Poništi glas",
- "comments": "{count} komentara",
+ "comments": "{count, plural, one {# komentar} few {# komentara} other {# komentara}}",
"comment": "1 komentar",
"by": "autor",
"statusPending": "Na čekanju",
diff --git a/messages/it.json b/messages/it.json
index 841b31e4e..bca2c797b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -209,7 +209,7 @@
"saving": "Salvataggio...",
"success": "Password aggiornata. Reindirizzamento...",
"mismatch": "Le password non corrispondono",
- "tooShort": "La password deve contenere almeno 8 caratteri",
+ "tooShort": "La password deve contenere almeno 10 caratteri",
"missingToken": "Link non valido. Richiedi un nuovo reset.",
"error": "Link non valido o scaduto",
"backToLogin": "Torna al login"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Se abbandoni il test di livello prima di completarlo, perderai i progressi e dovrai rifarlo.",
"startWarningConfirm": "Inizia",
"startMyPlan": "Inizia il mio piano",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Prova di conversazione vocale",
+ "voiceTrialTitle": "Prova subito una vera conversazione",
+ "voiceTrialDesc": "Parla gratis con Lingu per {minutes} minuti. Il tuo tutor si adatterà al livello che hai appena sbloccato.",
+ "voiceTrialStart": "Inizia la conversazione gratuita",
+ "voiceTrialSkip": "Salta per ora"
},
"plan": {
"unitLocked": "Unità bloccata",
@@ -1165,6 +1165,9 @@
"statusError": "Errore",
"errorServicesDisabled": "I servizi vocali non sono abilitati su questo server.",
"errorConnection": "Errore di connessione. Riprova.",
+ "errorTranscription": "Non è stato possibile trascrivere ciò che hai detto. Prova a parlare di nuovo.",
+ "errorResponse": "Lingu non è riuscito a rispondere. Riprova.",
+ "errorSpeech": "Non è stato possibile generare l'audio della risposta di Lingu. Riprova.",
"errorUnauthorized": "Sessione scaduta. Aggiorna la pagina.",
"errorMic": "Accesso al microfono negato.",
"errorVadInit": "Errore durante l'inizializzazione del riconoscimento vocale. Verifica che il browser supporti WebAssembly.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Voce gratis: {remaining}/{limit} min questa settimana",
"quotaTokens": "Token questo mese",
"memoryUpdated": "Memoria aggiornata",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Conversazione gratuita dopo la valutazione · {minutes} min",
+ "trialCtaLabel": "Continua a parlare",
+ "trialCtaTitle": "Continua a esercitarti con Lingu",
+ "trialCtaDesc": "Sblocca conversazioni vocali complete, chat con il tutor ed esercizi di ascolto e lettura con Premium.",
"startersHint": "O inizia con un argomento",
"starters": [
"Arte e creatività",
diff --git a/messages/nl.json b/messages/nl.json
index 6066bb298..6c0566460 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -209,7 +209,7 @@
"saving": "Opslaan...",
"success": "Wachtwoord bijgewerkt. Doorsturen...",
"mismatch": "Wachtwoorden komen niet overeen",
- "tooShort": "Het wachtwoord moet minimaal 8 tekens bevatten",
+ "tooShort": "Het wachtwoord moet minimaal 10 tekens bevatten",
"missingToken": "Ongeldige link. Vraag een nieuwe reset aan.",
"error": "Ongeldige of verlopen link",
"backToLogin": "Terug naar inloggen"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Als u de niveautest verlaat voordat u klaar bent, gaat uw voortgang verloren en moet u hem opnieuw afleggen.",
"startWarningConfirm": "Beginnen",
"startMyPlan": "Mijn plan starten",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Proefgesprek",
+ "voiceTrialTitle": "Probeer nu een echt gesprek",
+ "voiceTrialDesc": "Praat {minutes} minuten gratis met Lingu. Je tutor past zich aan het niveau aan dat je zojuist hebt ontgrendeld.",
+ "voiceTrialStart": "Start een gratis gesprek",
+ "voiceTrialSkip": "Voorlopig overslaan"
},
"plan": {
"unitLocked": "Eenheid vergrendeld",
@@ -1165,6 +1165,9 @@
"statusError": "Fout",
"errorServicesDisabled": "Spraakdiensten zijn niet ingeschakeld op deze server.",
"errorConnection": "Verbindingsfout. Probeer opnieuw.",
+ "errorTranscription": "Je spraak kon niet worden omgezet in tekst. Probeer opnieuw te spreken.",
+ "errorResponse": "Lingu kon niet antwoorden. Probeer het opnieuw.",
+ "errorSpeech": "Er kon geen audio worden gemaakt voor Lingu's antwoord. Probeer het opnieuw.",
"errorUnauthorized": "Sessie verlopen. Vernieuw de pagina.",
"errorMic": "Microfoon toegang geweigerd.",
"errorVadInit": "Initialiseren van spraakherkenning mislukt. Controleer of je browser WebAssembly ondersteunt.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Gratis stem: {remaining}/{limit} min deze week",
"quotaTokens": "Tokens deze maand",
"memoryUpdated": "Geheugen bijgewerkt",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Gratis gesprek na de niveautest · {minutes} min",
+ "trialCtaLabel": "Blijf praten",
+ "trialCtaTitle": "Blijf oefenen met Lingu",
+ "trialCtaDesc": "Ontgrendel volledige spraakgesprekken, tutorchat en luister- en leesoefeningen met Premium.",
"startersHint": "Of begin met een onderwerp",
"starters": [
"Kunst en creativiteit",
diff --git a/messages/pl.json b/messages/pl.json
index fcbf57388..b193f2671 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -209,7 +209,7 @@
"saving": "Zapisywanie...",
"success": "Hasło zaktualizowane. Przekierowanie...",
"mismatch": "Hasła nie są zgodne",
- "tooShort": "Hasło musi mieć co najmniej 8 znaków",
+ "tooShort": "Hasło musi mieć co najmniej 10 znaków",
"missingToken": "Nieprawidłowy link. Poproś o nowe resetowanie.",
"error": "Nieprawidłowy lub wygasły link",
"backToLogin": "Powrót do logowania"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Jeśli opuścisz test poziomujący przed jego ukończeniem, utracisz postęp i będziesz musiał go powtórzyć.",
"startWarningConfirm": "Rozpocznij",
"startMyPlan": "Rozpocznij mój plan",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Próbna rozmowa głosowa",
+ "voiceTrialTitle": "Wypróbuj prawdziwą rozmowę już teraz",
+ "voiceTrialDesc": "Porozmawiaj z Lingu przez {minutes} min za darmo. Twój nauczyciel dostosuje się do właśnie odblokowanego poziomu.",
+ "voiceTrialStart": "Rozpocznij bezpłatną rozmowę",
+ "voiceTrialSkip": "Na razie pomiń"
},
"plan": {
"unitLocked": "Jednostka zablokowana",
@@ -1165,6 +1165,9 @@
"statusError": "Błąd",
"errorServicesDisabled": "Usługi głosowe nie są włączone na tym serwerze.",
"errorConnection": "Błąd połączenia. Spróbuj ponownie.",
+ "errorTranscription": "Nie udało się przekształcić Twojej wypowiedzi w tekst. Spróbuj powiedzieć to jeszcze raz.",
+ "errorResponse": "Lingu nie mógł odpowiedzieć. Spróbuj ponownie.",
+ "errorSpeech": "Nie udało się wygenerować dźwięku odpowiedzi Lingu. Spróbuj ponownie.",
"errorUnauthorized": "Sesja wygasła. Odśwież stronę.",
"errorMic": "Odmówiono dostępu do mikrofonu.",
"errorVadInit": "Nie udało się zainicjować rozpoznawania mowy. Upewnij się, że Twoja przeglądarka obsługuje WebAssembly.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Darmowy głos: {remaining}/{limit} min w tym tygodniu",
"quotaTokens": "Tokeny w tym miesiącu",
"memoryUpdated": "Pamięć zaktualizowana",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Bezpłatna rozmowa po ocenie poziomu · {minutes} min",
+ "trialCtaLabel": "Mów dalej",
+ "trialCtaTitle": "Ćwicz dalej z Lingu",
+ "trialCtaDesc": "Odblokuj pełne rozmowy głosowe, czat z nauczycielem oraz ćwiczenia słuchania i czytania dzięki Premium.",
"startersHint": "Lub zacznij od tematu",
"starters": [
"Sztuka i kreatywność",
diff --git a/messages/pt.json b/messages/pt.json
index a7a83ffe4..25b45fd4e 100644
--- a/messages/pt.json
+++ b/messages/pt.json
@@ -209,7 +209,7 @@
"saving": "Salvando...",
"success": "Senha atualizada. Redirecionando...",
"mismatch": "As senhas não coincidem",
- "tooShort": "A senha deve ter pelo menos 8 caracteres",
+ "tooShort": "A senha deve ter pelo menos 10 caracteres",
"missingToken": "Link inválido. Solicite uma nova redefinição.",
"error": "Link inválido ou expirado",
"backToLogin": "Voltar ao login"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Se você sair do teste de nível antes de concluí-lo, seu progresso será perdido e você precisará refazê-lo.",
"startWarningConfirm": "Começar",
"startMyPlan": "Iniciar meu plano",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Teste de conversa por voz",
+ "voiceTrialTitle": "Experimente uma conversa real agora",
+ "voiceTrialDesc": "Fale com o Lingu durante {minutes} minutos, gratuitamente. O seu tutor adaptar-se-á ao nível que acabou de desbloquear.",
+ "voiceTrialStart": "Iniciar conversa gratuita",
+ "voiceTrialSkip": "Saltar por agora"
},
"plan": {
"unitLocked": "Unidade bloqueada",
@@ -1165,6 +1165,9 @@
"statusError": "Erro",
"errorServicesDisabled": "Os serviços de voz não estão ativados neste servidor.",
"errorConnection": "Erro de ligação. Por favor, tente novamente.",
+ "errorTranscription": "Não foi possível transcrever o que disse. Tente falar novamente.",
+ "errorResponse": "O Lingu não conseguiu responder. Tente novamente.",
+ "errorSpeech": "Não foi possível gerar áudio para a resposta do Lingu. Tente novamente.",
"errorUnauthorized": "Sessão expirada. Por favor, atualize.",
"errorMic": "Acesso ao microfone negado.",
"errorVadInit": "Falha ao inicializar o reconhecimento de voz. Verifique se o seu navegador suporta WebAssembly.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Voz grátis: {remaining}/{limit} min esta semana",
"quotaTokens": "Tokens este mês",
"memoryUpdated": "Memória atualizada",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Conversa gratuita após a avaliação · {minutes} min",
+ "trialCtaLabel": "Continue a falar",
+ "trialCtaTitle": "Continue a praticar com o Lingu",
+ "trialCtaDesc": "Desbloqueie conversas por voz completas, chat com o tutor e exercícios de compreensão oral e leitura com o Premium.",
"startersHint": "Ou comece com um tema",
"starters": [
"Arte e criatividade",
diff --git a/messages/ro.json b/messages/ro.json
index 74145ab27..f3a1dd7af 100644
--- a/messages/ro.json
+++ b/messages/ro.json
@@ -209,7 +209,7 @@
"saving": "Se salvează...",
"success": "Parolă actualizată. Redirecționare...",
"mismatch": "Parolele nu se potrivesc",
- "tooShort": "Parola trebuie să aibă cel puțin 8 caractere",
+ "tooShort": "Parola trebuie să aibă cel puțin 10 caractere",
"missingToken": "Link invalid. Solicitați o nouă resetare.",
"error": "Link invalid sau expirat",
"backToLogin": "Înapoi la autentificare"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Dacă părăsești testul de nivel înainte de a-l finaliza, progresul tău va fi pierdut și va trebui să îl refaci.",
"startWarningConfirm": "Începe",
"startMyPlan": "Pornește planul meu",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Conversație vocală de probă",
+ "voiceTrialTitle": "Încearcă o conversație reală acum",
+ "voiceTrialDesc": "Vorbește gratuit cu Lingu timp de {minutes} min. Profesorul tău se va adapta la nivelul pe care tocmai l-ai deblocat.",
+ "voiceTrialStart": "Începe conversația gratuită",
+ "voiceTrialSkip": "Omite deocamdată"
},
"plan": {
"unitLocked": "Unitate blocată",
@@ -1165,6 +1165,9 @@
"statusError": "Eroare",
"errorServicesDisabled": "Serviciile vocale nu sunt activate pe acest server.",
"errorConnection": "Eroare de conexiune. Încearcă din nou.",
+ "errorTranscription": "Nu s-a putut transcrie ce ai spus. Încearcă să vorbești din nou.",
+ "errorResponse": "Lingu nu a putut răspunde. Încearcă din nou.",
+ "errorSpeech": "Nu s-a putut genera sunetul pentru răspunsul lui Lingu. Încearcă din nou.",
"errorUnauthorized": "Sesiune expirată. Reîmprospătează pagina.",
"errorMic": "Accesul la microfon a fost refuzat.",
"errorVadInit": "Inițializarea detectării vocii a eșuat. Verifică dacă browserul tău suportă WebAssembly.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Voce gratuită: {remaining}/{limit} min săptămâna aceasta",
"quotaTokens": "Tokeni luna aceasta",
"memoryUpdated": "Memorie actualizată",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Conversație gratuită după evaluare · {minutes} min",
+ "trialCtaLabel": "Continuă să vorbești",
+ "trialCtaTitle": "Continuă să exersezi cu Lingu",
+ "trialCtaDesc": "Deblochează conversații vocale complete, chat cu profesorul și exerciții de ascultare și citire cu Premium.",
"startersHint": "Sau începe cu un subiect",
"starters": [
"Artă și creativitate",
diff --git a/messages/ru.json b/messages/ru.json
index f4b869203..6aa0b2c26 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -209,7 +209,7 @@
"saving": "Сохранение...",
"success": "Пароль обновлён. Перенаправление...",
"mismatch": "Пароли не совпадают",
- "tooShort": "Пароль должен содержать не менее 8 символов",
+ "tooShort": "Пароль должен содержать не менее 10 символов",
"missingToken": "Недействительная ссылка. Запросите новый сброс.",
"error": "Недействительная или просроченная ссылка",
"backToLogin": "Вернуться ко входу"
@@ -598,11 +598,11 @@
"startWarningMessageLevelTest": "Если вы покинете тест уровня до его завершения, ваш прогресс будет утерян и вам придётся пройти его повторно.",
"startWarningConfirm": "Начать",
"startMyPlan": "Начать мой план",
- "voiceTrialLabel": "Voice trial",
- "voiceTrialTitle": "Try a real conversation now",
- "voiceTrialDesc": "Talk with Lingu for {minutes} minutes, free. Your tutor will adapt to the level you just unlocked.",
- "voiceTrialStart": "Start free conversation",
- "voiceTrialSkip": "Skip for now"
+ "voiceTrialLabel": "Пробный голосовой разговор",
+ "voiceTrialTitle": "Попробуйте настоящий разговор прямо сейчас",
+ "voiceTrialDesc": "Поговорите с Lingu бесплатно в течение {minutes} мин. Ваш преподаватель подстроится под только что открытый уровень.",
+ "voiceTrialStart": "Начать бесплатный разговор",
+ "voiceTrialSkip": "Пока пропустить"
},
"plan": {
"unitLocked": "Модуль заблокирован",
@@ -1165,6 +1165,9 @@
"statusError": "Ошибка",
"errorServicesDisabled": "Голосовые сервисы не активированы на этом сервере.",
"errorConnection": "Ошибка соединения. Попробуйте снова.",
+ "errorTranscription": "Не удалось преобразовать вашу речь в текст. Попробуйте произнести фразу ещё раз.",
+ "errorResponse": "Lingu не удалось ответить. Попробуйте снова.",
+ "errorSpeech": "Не удалось озвучить ответ Lingu. Попробуйте снова.",
"errorUnauthorized": "Сессия истекла. Обновите страницу.",
"errorMic": "Доступ к микрофону запрещён.",
"errorVadInit": "Не удалось инициализировать обнаружение речи. Проверьте, поддерживает ли ваш браузер WebAssembly.",
@@ -1182,10 +1185,10 @@
"freemiumVoiceRemaining": "Бесплатный голос: {remaining}/{limit} мин на этой неделе",
"quotaTokens": "Токены в этом месяце",
"memoryUpdated": "Память обновлена",
- "trialBanner": "Free assessment conversation · {minutes} min",
- "trialCtaLabel": "Keep speaking",
- "trialCtaTitle": "Continue practising with Lingu",
- "trialCtaDesc": "Unlock full voice conversations, tutor chat, listening and reading practice with Premium.",
+ "trialBanner": "Бесплатный разговор после оценки уровня · {minutes} мин",
+ "trialCtaLabel": "Продолжайте говорить",
+ "trialCtaTitle": "Продолжайте практиковаться с Lingu",
+ "trialCtaDesc": "Откройте полный доступ к голосовым разговорам, чату с преподавателем, упражнениям на аудирование и чтение с Premium.",
"startersHint": "Или начните с темы",
"starters": [
"Искусство и творчество",
diff --git a/messages/sv.json b/messages/sv.json
index 849939fae..bb7563d8c 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -209,7 +209,7 @@
"saving": "Sparar...",
"success": "Lösenordet har uppdaterats. Du skickas vidare till inloggningen...",
"mismatch": "Lösenorden stämmer inte överens",
- "tooShort": "Lösenordet måste vara minst åtta tecken långt",
+ "tooShort": "Lösenordet måste vara minst 10 tecken långt",
"missingToken": "Ogiltig återställningslänk. Begär en ny lösenordsåterställning.",
"error": "Ogiltig eller utgången återställningslänk",
"backToLogin": "Tillbaka till inloggningen"
@@ -1165,6 +1165,9 @@
"statusError": "Fel",
"errorServicesDisabled": "Rösttjänster är inte aktiverade på den här servern.",
"errorConnection": "Anslutningsfel. Försök igen.",
+ "errorTranscription": "Det gick inte att omvandla ditt tal till text. Försök säga det igen.",
+ "errorResponse": "Lingu kunde inte svara. Försök igen.",
+ "errorSpeech": "Det gick inte att läsa upp Lingus svar. Försök igen.",
"errorUnauthorized": "Sessionen har gått ut. Uppdatera sidan.",
"errorMic": "Åtkomst till mikrofonen nekades.",
"errorVadInit": "Det gick inte att starta röstigenkänningen. Kontrollera att din webbläsare stöder WebAssembly.",
diff --git a/messages/tr.json b/messages/tr.json
index 8e25588d8..aaca8d03c 100644
--- a/messages/tr.json
+++ b/messages/tr.json
@@ -209,7 +209,7 @@
"saving": "Kaydediliyor...",
"success": "Parola güncellendi. Giriş sayfasına yönlendiriliyorsunuz...",
"mismatch": "Parolalar eşleşmiyor",
- "tooShort": "Parola en az 8 karakter olmalıdır",
+ "tooShort": "Parola en az 10 karakter olmalıdır",
"missingToken": "Geçersiz sıfırlama bağlantısı. Lütfen yeni bir parola sıfırlama isteyin.",
"error": "Geçersiz veya süresi dolmuş sıfırlama bağlantısı",
"backToLogin": "Girişe dön"
@@ -1165,6 +1165,9 @@
"statusError": "Hata",
"errorServicesDisabled": "Bu sunucuda ses hizmetleri etkin değil.",
"errorConnection": "Bağlantı hatası. Lütfen tekrar deneyin.",
+ "errorTranscription": "Söyledikleriniz yazıya dökülemedi. Lütfen yeniden konuşmayı deneyin.",
+ "errorResponse": "Lingu yanıt veremedi. Lütfen tekrar deneyin.",
+ "errorSpeech": "Lingu'nun yanıtı seslendirilemedi. Lütfen tekrar deneyin.",
"errorUnauthorized": "Oturumun süresi doldu. Lütfen sayfayı yenileyin.",
"errorMic": "Mikrofon erişimi reddedildi.",
"errorVadInit": "Ses tanıma başlatılamadı. Tarayıcınızın WebAssembly desteklediğinden emin olun.",
diff --git a/specs/feedback.instructions.md b/specs/feedback.instructions.md
index aee461f48..32ec9e9c4 100644
--- a/specs/feedback.instructions.md
+++ b/specs/feedback.instructions.md
@@ -190,6 +190,8 @@ can replace newer list state. Some mutations are optimistically reflected withou
checking non-success responses.
Feedback UI, status/read labels, and administrator author badges use the active interface locale.
+Croatian comment counts use ICU plural categories, including the singular form for counts such as
+21 and 31 and the appropriate forms for `few` and `other`.
The vote HTML tooltip remains a fixed label.
## Related specifications
diff --git a/specs/platform.instructions.md b/specs/platform.instructions.md
index 5724a4ee2..a7f116a99 100644
--- a/specs/platform.instructions.md
+++ b/specs/platform.instructions.md
@@ -51,6 +51,10 @@ Login uses email and password. Missing-user verification performs a dummy bcrypt
credential-enumeration timing differences. Password validation requires 10-25 characters with an
uppercase letter, number, and symbol.
+The password-reset form displays these requirements in the interface language and checks the 10-25
+character length before submitting, counting Unicode code points consistently with the backend.
+Backend validation enforces password strength; validation errors use localized interface messages.
+
The access token is an HS256 JWT containing user ID, role, and expiration. Its default lifetime is 15
minutes and the frontend stores it in Zustand memory only.
@@ -190,8 +194,10 @@ The application distinguishes UI locale, native language, learned target languag
The interface locales are `en`, `es`, `fr`, `pt`, `de`, `it`, `pl`, `nl`, `ro`, `ru`, `tr`, `sv`, `da`, `fi`, and `hr`. Locale
resolution uses `NEXT_LOCALE`, then `Accept-Language`, then English. Middleware passes the selected
-locale to server rendering; missing catalogs fall back to English. Settings updates profile locale
-and locale cookies before reload.
+locale to server rendering; missing catalogs fall back to English. After a successful profile save,
+Settings synchronizes `NEXT_LOCALE` and `LOCALE_DETECTED` with the selected interface locale. It reloads
+when that selection differs from the currently rendered locale, even if the profile already stores
+the selected value. A failed save leaves locale cookies and the displayed language unchanged.
Target-language metadata and typography are specified separately from UI localization.
diff --git a/specs/services.instructions.md b/specs/services.instructions.md
index 16a4aa514..7525ee8f6 100644
--- a/specs/services.instructions.md
+++ b/specs/services.instructions.md
@@ -160,6 +160,10 @@ notifications. User-facing locale comes from the recipient; administrator notifi
administrator's native language with English fallback. Notification failures after durable feedback or
review creation are logged without rolling back that content.
+All seven email templates declare the rendered content's locale in the HTML `lang` attribute. Omitted
+or unsupported locales use English for both the content and the language attribute. Template values,
+including the locale attribute, retain the renderer's default HTML escaping.
+
## Voice conversation pipeline
`conversation_pipeline.py` orchestrates explicit-language STT, memory-aware LLM streaming, sentence
diff --git a/specs/voice-conversation.instructions.md b/specs/voice-conversation.instructions.md
index fd7762fc3..ffdf5497d 100644
--- a/specs/voice-conversation.instructions.md
+++ b/specs/voice-conversation.instructions.md
@@ -222,6 +222,11 @@ in the browser when the session component is created.
## Error recovery and cleanup
+Visible errors are translated by code using the active interface locale. Transcription, tutor-response,
+and speech-generation failures have distinct messages; unknown server errors use a localized generic
+fallback. Raw server messages and socket close reasons remain diagnostic log data. Transport failures
+display the localized connection error without appended backend text or transport diagnostics.
+
`stt_failed`, `llm_failed`, and `tts_failed` are recoverable turn errors. The frontend cancels
playback, clears pending assistant state, releases the turn guard, keeps the session live, and clears
the visible error after the next WAV is sent successfully.
From 0e3275923dac33dddbfe0d79dfaae854007a377b Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Thu, 24 Sep 2026 23:06:32 +0200
Subject: [PATCH 14/14] Refine locale detection and pluralized translations
- Preserve supported `NEXT_LOCALE` cookies during initial browser locale detection
- Ignore `Accept-Language` entries with zero or out-of-range quality values
- Update admin language code comments to match supported native-language locales
- Add ICU plural forms for Danish, Finnish, Swedish, and Croatian messages
- Document locale resolution behavior changes in platform instructions
- Record the locale detection fix in the changelog
---
CHANGELOG.md | 1 +
backend/app/schemas/admin.py | 36 ++++++++++++++++------------------
frontend/src/app/layout.tsx | 2 +-
frontend/src/middleware.ts | 1 +
messages/da.json | 2 +-
messages/fi.json | 2 +-
messages/hr.json | 2 +-
messages/sv.json | 2 +-
specs/platform.instructions.md | 4 +++-
9 files changed, 27 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 589a8beb6..3d8fe1f2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
### Fixed
- Saving the interface language synchronizes locale cookies and applies the selection when the displayed language differs, including when that selection was already saved in the profile.
+- Initial locale detection preserves an existing supported interface-language cookie and ignores `Accept-Language` entries with zero or out-of-range quality weights.
- Post-assessment voice-trial prompts and Premium continuation messages are translated in French, Portuguese, German, Italian, Polish, Dutch, Romanian, and Russian.
- Voice transcription, tutor-response, speech-generation, and transport errors display localized messages instead of raw backend or WebSocket diagnostics.
- Croatian feedback comment counts use the correct plural forms, including counts such as 21 and 31.
diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py
index a5c3092c6..f1fdd9135 100644
--- a/backend/app/schemas/admin.py
+++ b/backend/app/schemas/admin.py
@@ -8,26 +8,24 @@
from app.core.config import settings
+# Native-language codes accepted when creating users through the admin API.
+# These match the registration and profile options, not the study-language catalog.
SUPPORTED_LANGUAGES = {
- "en",
- "es",
- "fr",
- "pt",
- "de",
- "it",
- "zh",
- "ja",
- "ko",
- "ar",
- "ru",
- "nl",
- "pl",
- "ro",
- "tr",
- "sv",
- "da",
- "fi",
- "hr",
+ "da", # Danish
+ "de", # German
+ "en", # English
+ "es", # Spanish
+ "fi", # Finnish
+ "fr", # French
+ "hr", # Croatian
+ "it", # Italian
+ "nl", # Dutch
+ "pl", # Polish
+ "pt", # Portuguese
+ "ro", # Romanian
+ "ru", # Russian
+ "sv", # Swedish
+ "tr", # Turkish
}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 153116e3f..d5053f2f3 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -115,7 +115,7 @@ export default async function RootLayout({
{process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index bd6b485f6..8c5ad5a2c 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -43,6 +43,7 @@ function detectLocale(req: NextRequest): Locale {
const lang = langTag.trim().split(/[-_]/)[0].toLowerCase()
return { lang, q: isNaN(q) ? 0 : q }
})
+ .filter(({ q }) => q > 0 && q <= 1)
.sort((a, b) => b.q - a.q)
for (const { lang } of parsed) {
diff --git a/messages/da.json b/messages/da.json
index 59787aa10..d5926772f 100644
--- a/messages/da.json
+++ b/messages/da.json
@@ -645,7 +645,7 @@
"levelCompleteDesc": "Du er nået til slutningen af din plan for niveau {level}. Tag niveautesten for at bekræfte dine fremskridt og låse op for næste niveau.",
"levelCompleteHint": "Testen tager cirka 20 minutter og dækker grammatik, ordforråd og læsning.",
"beginLevelTest": "Start niveautesten",
- "nLessons": "{count} lektioner",
+ "nLessons": "{count, plural, one {# lektion} other {# lektioner}}",
"nGrammar": "{count} grammatiklektioner",
"levelTestLabel": "Niveautest",
"unitAriaLabel": "Enhed {index}: {title}",
diff --git a/messages/fi.json b/messages/fi.json
index 41f1b7dd3..a4b39bb9b 100644
--- a/messages/fi.json
+++ b/messages/fi.json
@@ -645,7 +645,7 @@
"levelCompleteDesc": "Olet saavuttanut tason {level} suunnitelman lopun. Tee tasotesti vahvistaaksesi edistymisesi ja avataksesi seuraavan tason.",
"levelCompleteHint": "Testi kestää noin 20 minuuttia ja sisältää kielioppia, sanastoa ja lukemista.",
"beginLevelTest": "Aloita tasotesti",
- "nLessons": "{count} oppituntia",
+ "nLessons": "{count, plural, one {# oppitunti} other {# oppituntia}}",
"nGrammar": "{count} kielioppitehtävää",
"levelTestLabel": "Tasotesti",
"unitAriaLabel": "Yksikkö {index}: {title}",
diff --git a/messages/hr.json b/messages/hr.json
index 249a12cc9..dec996800 100644
--- a/messages/hr.json
+++ b/messages/hr.json
@@ -623,7 +623,7 @@
"learningRoadmap": "Put učenja",
"level": "Razina",
"duration": "Trajanje",
- "durationDetail": "{weeks} tjedana · {days} dana/tjedan",
+ "durationDetail": "{weeks, plural, one {# tjedan} few {# tjedna} other {# tjedana}} · {days, plural, one {# dan/tjedan} few {# dana/tjedan} other {# dana/tjedan}}",
"unitsLabel": "Jedinice",
"noUnitsForLevel": "Za razinu {level} nema dostupnih jedinica",
"takeAssessmentArrow": "Riješi procjenu →",
diff --git a/messages/sv.json b/messages/sv.json
index bb7563d8c..8745de9e9 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -645,7 +645,7 @@
"levelCompleteDesc": "Du har nått slutet av din plan för nivå {level}. Gör nivåtestet för att bekräfta dina framsteg och låsa upp nästa nivå.",
"levelCompleteHint": "Testet tar cirka 20 minuter och omfattar grammatik, ordförråd och läsning.",
"beginLevelTest": "Starta nivåtestet",
- "nLessons": "{count} lektioner",
+ "nLessons": "{count, plural, one {# lektion} other {# lektioner}}",
"nGrammar": "{count} grammatiklektioner",
"levelTestLabel": "Nivåtest",
"unitAriaLabel": "Enhet {index}: {title}",
diff --git a/specs/platform.instructions.md b/specs/platform.instructions.md
index a7f116a99..61942f6dc 100644
--- a/specs/platform.instructions.md
+++ b/specs/platform.instructions.md
@@ -193,7 +193,9 @@ The application distinguishes UI locale, native language, learned target languag
`UserLanguage`, and resource-owning `StudyPlan.target_language`.
The interface locales are `en`, `es`, `fr`, `pt`, `de`, `it`, `pl`, `nl`, `ro`, `ru`, `tr`, `sv`, `da`, `fi`, and `hr`. Locale
-resolution uses `NEXT_LOCALE`, then `Accept-Language`, then English. Middleware passes the selected
+resolution uses `NEXT_LOCALE`, then `Accept-Language`, then English. Only header entries with a parsed
+quality greater than 0 and no greater than 1 are considered. The initial browser script preserves a
+supported `NEXT_LOCALE` value even when `LOCALE_DETECTED` is absent. Middleware passes the selected
locale to server rendering; missing catalogs fall back to English. After a successful profile save,
Settings synchronizes `NEXT_LOCALE` and `LOCALE_DETECTED` with the selected interface locale. It reloads
when that selection differs from the currently rendered locale, even if the profile already stores