Plan — Frontend i18n (French + English) for narvik-front
Context
The user wants Narvik to support translation. Today the app is French-only with zero i18n infrastructure on either side:
- Frontend (
narvik-front, Nuxt 4 SPA, ssr: false): no i18n library, no locale files, no $t. ~103 of ~140 .vue files contain hardcoded French (~650 lines), in both templates and script logic (toasts, validation messages, step labels). Enum labels are hand-mapped to French in scattered places.
- Backend (
narvik-back, Symfony/API Platform): no symfony/translation, no locale source. Returns hardcoded English validation/exception prose and machine-value enums; emails are hardcoded French Twig.
Decision (confirmed with user)
Best practice for this stack is frontend-owned UI translation. The API should return stable machine codes/enums (it already does for enums), and the frontend maps them to localized text — the backend should not translate response prose. The only genuine backend-i18n case is emails, which is explicitly out of scope for v1.
v1 scope: Full frontend sweep. Add @nuxtjs/i18n, extract all hardcoded French into fr + en locale catalogs, add a language switcher, and localize date/currency formatting. Locale is browser-detected + manually switchable + cookie-persisted (no backend/account changes, no user locale field).
Explicitly out of scope for v1: backend symfony/translation, localized emails, a User.locale field, and turning backend English error prose into codes (tracked as follow-up below).
Status: plan only — do not implement yet. This document is saved for later execution.
Translation file format — decision
Use JSON catalogs as the source of truth. Not .po.
@nuxtjs/i18n is built on vue-i18n, which reads JSON/JS natively. .po (gettext) requires a .po → JSON build step every load — a conversion layer for no real benefit here.
- Modern translation platforms (Crowdin, Weblate, Lokalise) edit JSON directly; Poedit can too.
.po is not required for a good translator UX.
- Escape hatch: if an external agency insists on gettext, the extraction script (below) can additionally emit a
.pot template and we add a .po → JSON converter (e.g. i18next-conv-style). But JSON stays canonical; .po would be an export artifact only.
Catalogs live at narvik-front/i18n/locales/{fr,en}.json, namespaced by domain (login.*, member.*, loan.*, sale.*, presence.*, common.*, validation.*, enum.*).
Extraction tooling — scripts
Two distinct scripts, both committed under narvik-front/scripts/i18n/:
A. One-time migration extractor (extract-hardcoded.mjs)
Purpose: turn the ~650 existing hardcoded French literals into a starting catalog + a per-file worklist, so the Phase-4 sweep is mechanical rather than hunt-and-peck. It does not auto-rewrite code (too risky for template vs. script context) — it produces drafts a human wires up.
- Scan
app/**/*.vue, app/utils/*.ts, app/types/api/**/*.ts for string literals containing French accented chars ([àâäéèêëîïôöùûüçœ]) or known French tokens, in both <template> text/attribute bindings and <script> literals.
- Emit:
i18n/locales/fr.draft.json — proposed namespaced keys (namespace derived from file path, e.g. components/Loan/* → loan.*) mapped to the extracted French string.
en.draft.json — same keys, values empty (to be filled by translator).
scripts/i18n/worklist.md — per-file table: line number, original string, proposed key, context (template/script). This is the checklist that drives Phase 4.
- Optional
--pot flag → also write messages.pot for a gettext export.
- De-duplicate identical strings into shared
common.* keys; flag near-duplicates for manual merge.
- Node ESM script (repo already uses
"type": "module"); parse .vue with a lightweight SFC split + regex, no heavy AST dependency required for a one-shot tool.
B. Ongoing key linter (vue-i18n-extract)
Purpose: after migration, keep catalogs honest. Add the vue-i18n-extract dev dependency (purpose-built for vue-i18n) and a pnpm i18n:check script that reports missing keys (used in code, absent from catalog) and unused keys (in catalog, never referenced). Wire it into CI / the lint step so drift is caught on every PR.
Approach
Phase 1 — Infrastructure (~1 day)
- Install & configure
@nuxtjs/i18n (v9+/v10, Nuxt 4 compatible). Add to modules in narvik-front/nuxt.config.ts:21.
defaultLocale: 'fr', locales fr + en.
strategy: 'no_prefix' — this is an authenticated SPA, not a marketing site; URLs must not carry a locale prefix (keeps existing routes/robots/sitemap in nuxt.config.ts:118-124 untouched).
detectBrowserLanguage: { useCookie: true, cookieKey: 'narvik_locale', fallbackLocale: 'fr', redirectOn: 'no prefix' }.
lazy: true, langDir pointing at the locale catalogs below.
- Locale catalogs — new
narvik-front/i18n/locales/fr.json and en.json. Namespace keys by domain to mirror the component tree (e.g. login.*, member.*, loan.*, sale.*, presence.*, common.*, validation.*, enum.*). Do not dump everything flat.
- Nuxt UI v4 component locale — Nuxt UI ships its own i18n for built-in component strings; wire
<UApp :locale> (or the @nuxt/ui locale option) to the active @nuxtjs/i18n locale so date-pickers/pagination/etc. follow the switch.
- Language switcher component — new
narvik-front/app/components/LocaleSwitcher.vue, modeled exactly on the existing app/components/ThemeSwitcher.vue UFieldGroup/UButton pattern. Place it wherever ThemeSwitcher already renders (header/nav).
- Build the migration extractor (
scripts/i18n/extract-hardcoded.mjs, script A above) and run it once to generate fr.draft.json, en.draft.json, and worklist.md — the input to Phase 4. Also add vue-i18n-extract + the pnpm i18n:check script (script B).
Phase 2 — Localize formatting utilities (~0.5 day)
These centralize locale-dependent output and must consume the active locale instead of the hardcoded 'fr-FR':
app/utils/string.ts — formatMonetary() uses toLocaleString('fr-FR', …) and fallback 'Non défini'. Drive the locale from the current i18n locale; move 'Non défini' to a common.* key.
app/utils/date.ts — formatDateReadable/formatTimeReadable/formatDateTimeReadable use native Intl 'fr-FR' and a hardcoded " à " separator. Parameterize the Intl locale; move " à " to a key. Numeric DD/MM/YYYY formats (dayjs) can stay, or become locale-driven if EN users should see MM/DD/YYYY (confirm during impl — default: keep numeric format stable).
- dayjs currently has no locale configured and is only used for numeric formats, so no dayjs locale plugin is strictly required unless readable dayjs output is later introduced.
Phase 3 — Extract enum/label maps (~1 day)
Convert the three existing French-label patterns into enum.* catalog keys, keeping the same public helper signatures so callers don't change:
app/types/api/item/club.ts — getAvailableClubRoles() / getAvailableClubRole() ({text,value} builders) and clubPlugins. Return t('enum.clubRole.*') instead of literals. Fix the missing CLUB_BADGER label while here.
app/utils/loan.ts — LOAN_ITEM_STATUS_LABELS record → catalog keys. De-duplicate: app/components/Loan/LoanItemForm.vue:66-71 re-declares this map inline (and drops loaned) — make it consume the shared helper.
app/types/api/permissions.ts — permissionSections inline label/name French strings → enum.permission.* keys, rendered by app/components/Member/MemberPermissions.vue.
- Note: helpers that return translated strings must call
useI18n()/t — where these run outside component setup (plain util modules), use the global nuxtApp.$i18n.t accessor so they work in non-setup contexts.
Phase 4 — Full component/page sweep (~1–2 weeks, the bulk)
Extract all remaining hardcoded French across ~103 .vue files into the namespaced catalogs. Two mechanical patterns repeat everywhere:
- Templates:
Champ requis → {{ $t('validation.required') }} / :label="$t('member.form.securityCode')".
- Script logic (toasts, pushed validation errors, step labels):
useI18n()'s t(), e.g. message: t('login.badCredentials'). Representative hotspots found: app/pages/login/password-reset.vue (validation + toast titles), app/pages/login/index.vue:40 (error branches), plus every domain folder under app/components/ (Member/, Loan/, Sale/, Presence/, Email/, …).
Driven by worklist.md from the extractor: promote reviewed keys from fr.draft.json into fr.json and wire each source line to $t()/t(). Sweep domain-by-domain (login → member → loan → sale → presence → …) so each slice is independently reviewable and testable, even though all land in v1. Fill en.json with real English per slice (untranslated keys fall back to FR via fallbackLocale). Run pnpm i18n:check after each slice to confirm no missing/unused keys.
Representative files to modify (pattern repeats — not exhaustive):
app/components/ThemeSwitcher.vue (Clair/Sombre) and new LocaleSwitcher.vue
app/pages/login/*.vue, app/pages/index.vue
- One file per domain under
app/components/*/
nuxt.config.ts, app/utils/{string,date}.ts, app/types/api/{item/club,permissions}.ts, app/utils/loan.ts
Follow-up (NOT in v1 — backend, when emails/account-level locale are wanted)
Deferred by decision. When picked up later: add symfony/translation + a locale source (Accept-Language listener and/or a User.locale field + migration), localize the templates/email/*.twig templates and hardcoded subjects, and convert the ~15 hardcoded English UniqueEntity/exception messages (e.g. Member.php:52 'Licence already registered') into stable codes the frontend maps under validation.*.
Verification
rtk pnpm install then rtk pnpm dev — app boots with the i18n module, no console errors.
- Switch test: toggle the
LocaleSwitcher FR↔EN and confirm live re-render of: a form with validation, a toast (trigger a login error), enum labels (club role dropdown, loan item status badge, permissions screen), and a date/currency display.
- Persistence: reload after switching → cookie
narvik_locale keeps the choice. Fresh browser with Accept-Language: en → app starts in English.
- Fallback: temporarily remove a key from
en.json → confirm it falls back to French rather than showing the raw key.
- No-regression:
rtk pnpm lint clean; rtk pnpm test:e2e (Playwright) passes — update any specs asserting French literals to assert via keys or the FR catalog.
- Coverage check: grep for remaining accented French literals in
app/**/*.vue to catch anything the sweep missed.
Plan — Frontend i18n (French + English) for narvik-front
Context
The user wants Narvik to support translation. Today the app is French-only with zero i18n infrastructure on either side:
narvik-front, Nuxt 4 SPA,ssr: false): no i18n library, no locale files, no$t. ~103 of ~140.vuefiles contain hardcoded French (~650 lines), in both templates and script logic (toasts, validation messages, step labels). Enum labels are hand-mapped to French in scattered places.narvik-back, Symfony/API Platform): nosymfony/translation, no locale source. Returns hardcoded English validation/exception prose and machine-value enums; emails are hardcoded French Twig.Decision (confirmed with user)
Best practice for this stack is frontend-owned UI translation. The API should return stable machine codes/enums (it already does for enums), and the frontend maps them to localized text — the backend should not translate response prose. The only genuine backend-i18n case is emails, which is explicitly out of scope for v1.
v1 scope: Full frontend sweep. Add
@nuxtjs/i18n, extract all hardcoded French intofr+enlocale catalogs, add a language switcher, and localize date/currency formatting. Locale is browser-detected + manually switchable + cookie-persisted (no backend/account changes, no userlocalefield).Explicitly out of scope for v1: backend
symfony/translation, localized emails, aUser.localefield, and turning backend English error prose into codes (tracked as follow-up below).Translation file format — decision
Use JSON catalogs as the source of truth. Not
.po.@nuxtjs/i18nis built on vue-i18n, which reads JSON/JS natively..po(gettext) requires a.po → JSONbuild step every load — a conversion layer for no real benefit here..pois not required for a good translator UX..pottemplate and we add a.po → JSONconverter (e.g.i18next-conv-style). But JSON stays canonical;.powould be an export artifact only.Catalogs live at
narvik-front/i18n/locales/{fr,en}.json, namespaced by domain (login.*,member.*,loan.*,sale.*,presence.*,common.*,validation.*,enum.*).Extraction tooling — scripts
Two distinct scripts, both committed under
narvik-front/scripts/i18n/:A. One-time migration extractor (
extract-hardcoded.mjs)Purpose: turn the ~650 existing hardcoded French literals into a starting catalog + a per-file worklist, so the Phase-4 sweep is mechanical rather than hunt-and-peck. It does not auto-rewrite code (too risky for template vs. script context) — it produces drafts a human wires up.
app/**/*.vue,app/utils/*.ts,app/types/api/**/*.tsfor string literals containing French accented chars ([àâäéèêëîïôöùûüçœ]) or known French tokens, in both<template>text/attribute bindings and<script>literals.i18n/locales/fr.draft.json— proposed namespaced keys (namespace derived from file path, e.g.components/Loan/*→loan.*) mapped to the extracted French string.en.draft.json— same keys, values empty (to be filled by translator).scripts/i18n/worklist.md— per-file table: line number, original string, proposed key, context (template/script). This is the checklist that drives Phase 4.--potflag → also writemessages.potfor a gettext export.common.*keys; flag near-duplicates for manual merge."type": "module"); parse.vuewith a lightweight SFC split + regex, no heavy AST dependency required for a one-shot tool.B. Ongoing key linter (
vue-i18n-extract)Purpose: after migration, keep catalogs honest. Add the
vue-i18n-extractdev dependency (purpose-built for vue-i18n) and apnpm i18n:checkscript that reports missing keys (used in code, absent from catalog) and unused keys (in catalog, never referenced). Wire it into CI / thelintstep so drift is caught on every PR.Approach
Phase 1 — Infrastructure (~1 day)
@nuxtjs/i18n(v9+/v10, Nuxt 4 compatible). Add tomodulesinnarvik-front/nuxt.config.ts:21.defaultLocale: 'fr', localesfr+en.strategy: 'no_prefix'— this is an authenticated SPA, not a marketing site; URLs must not carry a locale prefix (keeps existing routes/robots/sitemapinnuxt.config.ts:118-124untouched).detectBrowserLanguage: { useCookie: true, cookieKey: 'narvik_locale', fallbackLocale: 'fr', redirectOn: 'no prefix' }.lazy: true,langDirpointing at the locale catalogs below.narvik-front/i18n/locales/fr.jsonanden.json. Namespace keys by domain to mirror the component tree (e.g.login.*,member.*,loan.*,sale.*,presence.*,common.*,validation.*,enum.*). Do not dump everything flat.<UApp :locale>(or the@nuxt/uilocale option) to the active@nuxtjs/i18nlocale so date-pickers/pagination/etc. follow the switch.narvik-front/app/components/LocaleSwitcher.vue, modeled exactly on the existingapp/components/ThemeSwitcher.vueUFieldGroup/UButtonpattern. Place it whereverThemeSwitcheralready renders (header/nav).scripts/i18n/extract-hardcoded.mjs, script A above) and run it once to generatefr.draft.json,en.draft.json, andworklist.md— the input to Phase 4. Also addvue-i18n-extract+ thepnpm i18n:checkscript (script B).Phase 2 — Localize formatting utilities (~0.5 day)
These centralize locale-dependent output and must consume the active locale instead of the hardcoded
'fr-FR':app/utils/string.ts—formatMonetary()usestoLocaleString('fr-FR', …)and fallback'Non défini'. Drive the locale from the current i18n locale; move'Non défini'to acommon.*key.app/utils/date.ts—formatDateReadable/formatTimeReadable/formatDateTimeReadableuse nativeIntl'fr-FR'and a hardcoded" à "separator. Parameterize theIntllocale; move" à "to a key. NumericDD/MM/YYYYformats (dayjs) can stay, or become locale-driven if EN users should seeMM/DD/YYYY(confirm during impl — default: keep numeric format stable).Phase 3 — Extract enum/label maps (~1 day)
Convert the three existing French-label patterns into
enum.*catalog keys, keeping the same public helper signatures so callers don't change:app/types/api/item/club.ts—getAvailableClubRoles()/getAvailableClubRole()({text,value}builders) andclubPlugins. Returnt('enum.clubRole.*')instead of literals. Fix the missingCLUB_BADGERlabel while here.app/utils/loan.ts—LOAN_ITEM_STATUS_LABELSrecord → catalog keys. De-duplicate:app/components/Loan/LoanItemForm.vue:66-71re-declares this map inline (and dropsloaned) — make it consume the shared helper.app/types/api/permissions.ts—permissionSectionsinlinelabel/nameFrench strings →enum.permission.*keys, rendered byapp/components/Member/MemberPermissions.vue.useI18n()/t— where these run outside component setup (plain util modules), use the globalnuxtApp.$i18n.taccessor so they work in non-setup contexts.Phase 4 — Full component/page sweep (~1–2 weeks, the bulk)
Extract all remaining hardcoded French across ~103
.vuefiles into the namespaced catalogs. Two mechanical patterns repeat everywhere:Champ requis→{{ $t('validation.required') }}/:label="$t('member.form.securityCode')".useI18n()'st(), e.g.message: t('login.badCredentials'). Representative hotspots found:app/pages/login/password-reset.vue(validation + toast titles),app/pages/login/index.vue:40(error branches), plus every domain folder underapp/components/(Member/,Loan/,Sale/,Presence/,Email/, …).Driven by
worklist.mdfrom the extractor: promote reviewed keys fromfr.draft.jsonintofr.jsonand wire each source line to$t()/t(). Sweep domain-by-domain (login → member → loan → sale → presence → …) so each slice is independently reviewable and testable, even though all land in v1. Fillen.jsonwith real English per slice (untranslated keys fall back to FR viafallbackLocale). Runpnpm i18n:checkafter each slice to confirm no missing/unused keys.Representative files to modify (pattern repeats — not exhaustive):
app/components/ThemeSwitcher.vue(Clair/Sombre) and newLocaleSwitcher.vueapp/pages/login/*.vue,app/pages/index.vueapp/components/*/nuxt.config.ts,app/utils/{string,date}.ts,app/types/api/{item/club,permissions}.ts,app/utils/loan.tsFollow-up (NOT in v1 — backend, when emails/account-level locale are wanted)
Deferred by decision. When picked up later: add
symfony/translation+ a locale source (Accept-Languagelistener and/or aUser.localefield + migration), localize thetemplates/email/*.twigtemplates and hardcoded subjects, and convert the ~15 hardcoded EnglishUniqueEntity/exception messages (e.g.Member.php:52'Licence already registered') into stable codes the frontend maps undervalidation.*.Verification
rtk pnpm installthenrtk pnpm dev— app boots with the i18n module, no console errors.LocaleSwitcherFR↔EN and confirm live re-render of: a form with validation, a toast (trigger a login error), enum labels (club role dropdown, loan item status badge, permissions screen), and a date/currency display.narvik_localekeeps the choice. Fresh browser withAccept-Language: en→ app starts in English.en.json→ confirm it falls back to French rather than showing the raw key.rtk pnpm lintclean;rtk pnpm test:e2e(Playwright) passes — update any specs asserting French literals to assert via keys or the FR catalog.app/**/*.vueto catch anything the sweep missed.