diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0fe5584..863c96e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Onboarding goals question**: the learning-goals step of onboarding asked "What do you want to use English for?" regardless of the selected target language. The question now names the language the learner actually chose, using the localized language name, in all ten UI languages. - **Completion slot and level test**: the reserved final plan slot no longer generates a substitute review lesson. `GET /api/study-plan/today` reports an end-of-plan `completion` state (`in_progress`, `ready`, or `taken`), and the dashboard and My Plan route eligible learners to the real level test or show the persisted result. The test unlocks by reaching the final plan day with no lesson from a passed day still pending (skipped lessons keep it locked until completed) and is enforced by the backend (403 before the final position or with pending lessons); legacy final-slot lessons and results are preserved, including plans that stored the slot with the pre-v1.7.0 `level-test` unit id. Once a result exists, pending lessons no longer list the legacy final-slot lesson and `pending_count` stays consistent with `GET /api/study-plan/pending-lessons`, and the dashboard and the level-test result present the next-level step as a retake-assessment action, shown only when the plan's level has a next level. - **Level-test result**: the level completion test result screen renders every label, message, and error through the ten-locale catalogs, and its recommendation copy now reflects the real action: advancing points to retaking the assessment to set up the next plan, while extend and repeat point to reviewing My Plan instead of claiming a plan was already created. - **Study-plan distribution**: new plans spread teaching lessons evenly across all curriculum units, preserve unit order, restart each unit's own lesson-type cycle, and reserve the final position for the completion test. Earlier units no longer consume slots needed by later units. Existing plans keep their stored schedules. diff --git a/frontend/src/app/(auth)/onboarding/page.tsx b/frontend/src/app/(auth)/onboarding/page.tsx index a0c44a993..1bd9d896d 100644 --- a/frontend/src/app/(auth)/onboarding/page.tsx +++ b/frontend/src/app/(auth)/onboarding/page.tsx @@ -34,6 +34,7 @@ function getSelectedPlan(plan: string | null): BillingInterval | null { export default function OnboardingPage() { const t = useTranslations('onboarding') const tCommon = useTranslations('common') + const tLang = useTranslations('targetLanguages') const router = useRouter() const searchParams = useSearchParams() const setUser = useAuthStore((s) => s.setUser) @@ -266,7 +267,9 @@ export default function OnboardingPage() { {step === 2 && (

- {t('goals.subtitle')} + {t('goals.subtitle', { + language: tLang(targetLanguage), + })}

{LEARNING_GOALS.map((goal) => { diff --git a/frontend/tests/app/onboarding-goals-subtitle.test.tsx b/frontend/tests/app/onboarding-goals-subtitle.test.tsx new file mode 100644 index 000000000..05e69bea2 --- /dev/null +++ b/frontend/tests/app/onboarding-goals-subtitle.test.tsx @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import enMessages from '../../../messages/en.json' + +const { mockApiFetch, mockPush } = vi.hoisted(() => ({ + mockApiFetch: vi.fn(), + mockPush: vi.fn(), +})) + +// Resolve real catalog strings with real {variable} interpolation; the +// repo's usual next-intl mock echoes keys and would hide this bug. +vi.mock('next-intl', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslations: (namespace: string) => { + const messages = enMessages as Record + const scope = messages[namespace] as Record + return (key: string, values?: Record) => { + let raw: unknown = scope?.[key] + if (raw === undefined && key.includes('.')) { + raw = key + .split('.') + .reduce< + Record | undefined + >((node, part) => (node && typeof node === 'object' ? (node[part] as Record) : undefined), scope) + } + let text = typeof raw === 'string' ? raw : key + for (const [name, value] of Object.entries(values ?? {})) { + text = text.replaceAll(`{${name}}`, value) + } + return text + } + }, + // real useLocale needs a provider; pin the UI locale + useLocale: () => 'en', + } +}) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => '/onboarding', +})) + +vi.mock('@/lib/api', () => ({ + apiFetch: mockApiFetch, +})) + +vi.mock('@/store/auth', () => ({ + useAuthStore: (selector: (state: Record) => unknown) => + selector({ + setUser: vi.fn(), + user: { + id: 1, + username: 'tester', + email: 'tester@example.com', + role: 'admin', + native_language: 'en', + target_language: 'es-ES', + is_active: true, + is_verified: true, + }, + isSubscribed: () => false, + isFreemiumTrialActive: () => false, + }), + isSubscribed: () => false, + isFreemiumTrialActive: () => false, +})) + +vi.mock('@/store/config', () => ({ + useConfigStore: (selector: (state: Record) => unknown) => + selector({ + stripeEnabled: false, + stripeTrialDays: 7, + priceMonthly: '14.95', + priceYearly: '149.50', + load: vi.fn(), + }), +})) + +const mockLanguageState = { + fetchLanguages: vi.fn().mockResolvedValue(undefined), + availableLanguageCodes: ['de-DE', 'en-GB', 'en-US', 'es-ES', 'fr-FR'], +} + +vi.mock('@/store/language', () => { + const useLanguageStore = ( + selector: (state: Record) => unknown + ) => selector(mockLanguageState) + ;(useLanguageStore as unknown as Record).getState = () => + mockLanguageState + return { useLanguageStore } +}) + +import OnboardingPage from '@/app/(auth)/onboarding/page' + +describe('onboarding goals subtitle', () => { + beforeEach(() => { + mockApiFetch.mockReset() + mockPush.mockReset() + }) + + it('names the selected language on the goals step', async () => { + render(React.createElement(OnboardingPage)) + + // step 1: pick Spanish, continue + await waitFor(() => + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + ) + fireEvent.click(screen.getByRole('button', { name: /Spanish/i })) + fireEvent.click(screen.getByRole('button', { name: /^next$/i })) + + // step 2: the subtitle must name the picked language, not English + await waitFor(() => { + expect(screen.getByText(/Select all that apply/)).toBeDefined() + }) + const subtitle = screen.getByText(/Select all that apply/).textContent + expect(subtitle).toBe( + 'What do you want to use Spanish for? Select all that apply.' + ) + expect(subtitle).not.toContain('English') + }) +}) diff --git a/frontend/tests/i18n/onboarding-messages.test.ts b/frontend/tests/i18n/onboarding-messages.test.ts new file mode 100644 index 000000000..b065376d2 --- /dev/null +++ b/frontend/tests/i18n/onboarding-messages.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import de from '../../../messages/de.json' +import en from '../../../messages/en.json' +import es from '../../../messages/es.json' +import fr from '../../../messages/fr.json' +import itMessages from '../../../messages/it.json' +import nl from '../../../messages/nl.json' +import pl from '../../../messages/pl.json' +import pt from '../../../messages/pt.json' +import ro from '../../../messages/ro.json' +import ru from '../../../messages/ru.json' + +const locales = { + de, + en, + es, + fr, + it: itMessages, + nl, + pl, + pt, + ro, + ru, +} + +describe('onboarding goals subtitle', () => { + it('templates the selected language instead of naming one in every locale', () => { + for (const [locale, messages] of Object.entries(locales)) { + const subtitle = messages.onboarding.goals.subtitle as string + expect(subtitle, locale).toContain('{language}') + expect(subtitle, locale).not.toMatch( + /English|Englisch|inglés|anglais|inglese|Engels|angielskiego|inglês|engleza|английский/i + ) + } + }) +}) diff --git a/messages/de.json b/messages/de.json index 1c53e7f9f..5e3949427 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1295,7 +1295,7 @@ "saveFailed": "Einstellungen konnten nicht gespeichert werden", "goals": { "title": "Deine Lernziele", - "subtitle": "Wozu möchtest du Englisch nutzen? Wähle alles Zutreffende.", + "subtitle": "Wozu möchtest du {language} nutzen? Wähle alles Zutreffende.", "skip": "Überspringen", "travel": "Reisen & Tourismus", "work": "Beruf & Karriere", diff --git a/messages/en.json b/messages/en.json index f04efdeeb..a6cc7c732 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1295,7 +1295,7 @@ "saveFailed": "Failed to save preferences", "goals": { "title": "Your Learning Goals", - "subtitle": "What do you want to use English for? Select all that apply.", + "subtitle": "What do you want to use {language} for? Select all that apply.", "skip": "Skip", "travel": "Travel & Tourism", "work": "Work & Professional", diff --git a/messages/es.json b/messages/es.json index 245892dd2..ff8ad060c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1295,7 +1295,7 @@ "saveFailed": "Error al guardar las preferencias", "goals": { "title": "Tus objetivos de aprendizaje", - "subtitle": "¿Para qué quieres usar el inglés? Selecciona todos los que apliquen.", + "subtitle": "¿Para qué quieres usar {language}? Selecciona todos los que apliquen.", "skip": "Omitir", "travel": "Viajes y turismo", "work": "Trabajo y carrera", diff --git a/messages/fr.json b/messages/fr.json index 8d1377e9e..40a45d555 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1295,7 +1295,7 @@ "saveFailed": "Échec de l'enregistrement des préférences", "goals": { "title": "Vos objectifs d'apprentissage", - "subtitle": "Pour quoi voulez-vous utiliser l'anglais ? Sélectionnez tout ce qui s'applique.", + "subtitle": "Pour quoi voulez-vous utiliser {language} ? Sélectionnez tout ce qui s'applique.", "skip": "Passer", "travel": "Voyages et tourisme", "work": "Travail et carrière", diff --git a/messages/it.json b/messages/it.json index f7b10c718..b24476484 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1295,7 +1295,7 @@ "saveFailed": "Impossibile salvare le preferenze", "goals": { "title": "I tuoi obiettivi", - "subtitle": "Per cosa vuoi usare l'inglese? Seleziona tutto ciò che si applica.", + "subtitle": "Per cosa vuoi usare {language}? Seleziona tutto ciò che si applica.", "skip": "Salta", "travel": "Viaggi e turismo", "work": "Lavoro e carriera", diff --git a/messages/nl.json b/messages/nl.json index 17cf10a93..6408521bd 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1295,7 +1295,7 @@ "saveFailed": "Opslaan van voorkeuren mislukt", "goals": { "title": "Jouw leerdoelen", - "subtitle": "Waarvoor wil je Engels gebruiken? Selecteer alles wat van toepassing is.", + "subtitle": "Waarvoor wil je {language} gebruiken? Selecteer alles wat van toepassing is.", "skip": "Overslaan", "travel": "Reizen en toerisme", "work": "Werk en carrière", diff --git a/messages/pl.json b/messages/pl.json index 0047ba281..0e2b26e64 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1295,7 +1295,7 @@ "saveFailed": "Nie udało się zapisać preferencji", "goals": { "title": "Twoje cele nauki", - "subtitle": "Do czego chcesz używać angielskiego? Zaznacz wszystkie, które dotyczą Ciebie.", + "subtitle": "Do czego chcesz używać {language}? Zaznacz wszystkie, które dotyczą Ciebie.", "skip": "Pominąć", "travel": "Podróże i turystyka", "work": "Praca i kariera", diff --git a/messages/pt.json b/messages/pt.json index 527505b8b..880829ee6 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -1295,7 +1295,7 @@ "saveFailed": "Falha ao salvar as preferências", "goals": { "title": "Seus objetivos de aprendizado", - "subtitle": "Para quê você quer usar o inglês? Selecione tudo que se aplica.", + "subtitle": "Para quê você quer usar {language}? Selecione tudo que se aplica.", "skip": "Pular", "travel": "Viagens e turismo", "work": "Trabalho e carreira", diff --git a/messages/ro.json b/messages/ro.json index e89274a20..e7a612bcc 100644 --- a/messages/ro.json +++ b/messages/ro.json @@ -1295,7 +1295,7 @@ "saveFailed": "Salvarea preferințelor a eșuat", "goals": { "title": "Obiectivele tale de învățare", - "subtitle": "Pentru ce vrei să folosești engleza? Selectează tot ce ți se potrivește.", + "subtitle": "Pentru ce vrei să folosești {language}? Selectează tot ce ți se potrivește.", "skip": "Sari", "travel": "Călătorii și turism", "work": "Muncă și carieră", diff --git a/messages/ru.json b/messages/ru.json index 37e6af6d5..4e4968ba6 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1295,7 +1295,7 @@ "saveFailed": "Не удалось сохранить настройки", "goals": { "title": "Твои цели обучения", - "subtitle": "Для чего ты хочешь использовать английский? Выбери всё, что подходит.", + "subtitle": "Для чего ты хочешь использовать {language}? Выбери всё, что подходит.", "skip": "Пропустить", "travel": "Путешествия и туризм", "work": "Работа и карьера",