From cd55ed5b6df1f9b2c6bc25f224bd5c6b5457de83 Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER Date: Tue, 3 Nov 2026 00:21:40 +0000 Subject: [PATCH 1/4] feat(admin): add French localization of the admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SvelteKit admin is no longer English-only. A small client-side catalog (English source of truth, French by a native speaker) drives chrome, list pages, the event-type editor, settings, and auth screens. Locale is chosen in Settings → Profile (localStorage) with a navigator.languages fallback; missing keys fall back to English. Dates and times follow the admin locale. The LiveKit room and booker-facing catalogs are unchanged. --- CHANGELOG.md | 9 + CONTRIBUTING.md | 24 +- docs/ARCHITECTURE.md | 24 +- .../src/lib/components/demo-banner.svelte | 18 +- .../components/event-types/EmbedPanel.svelte | 20 +- .../event-types/QuestionsPanel.svelte | 99 +- .../components/ui/calendar/calendar.svelte | 11 +- .../components/ui/combobox/combobox.svelte | 14 +- .../ui/confirm-dialog/confirm-dialog.svelte | 17 +- .../ui/date-picker/date-picker.svelte | 9 +- .../ui/dialog/dialog-content.svelte | 3 +- frontend/src/lib/i18n/catalog.test.ts | 99 + frontend/src/lib/i18n/catalog.ts | 97 + frontend/src/lib/i18n/en.json | 1889 +++++++++++++++++ frontend/src/lib/i18n/fr.json | 1889 +++++++++++++++++ frontend/src/lib/i18n/i18n.test.ts | 99 + frontend/src/lib/i18n/index.ts | 84 + frontend/src/lib/prefs.ts | 10 +- frontend/src/routes/+layout.svelte | 81 +- frontend/src/routes/+page.svelte | 43 +- frontend/src/routes/api-keys/+page.svelte | 45 +- frontend/src/routes/availability/+page.svelte | 143 +- frontend/src/routes/bookings/+page.svelte | 172 +- frontend/src/routes/calendar/+page.svelte | 157 +- frontend/src/routes/claim/+page.svelte | 19 +- frontend/src/routes/connections/+page.svelte | 44 +- frontend/src/routes/event-types/+page.svelte | 97 +- .../routes/event-types/[slug]/+page.svelte | 516 +++-- .../src/routes/forgot-password/+page.svelte | 23 +- .../src/routes/invite/[token]/+page.svelte | 24 +- frontend/src/routes/login/+page.svelte | 63 +- frontend/src/routes/members/+page.svelte | 174 +- frontend/src/routes/recordings/+page.svelte | 121 +- .../src/routes/reset-password/+page.svelte | 25 +- frontend/src/routes/settings/+layout.svelte | 33 +- frontend/src/routes/settings/ai/+page.svelte | 80 +- .../src/routes/settings/branding/+page.svelte | 118 +- .../src/routes/settings/email/+page.svelte | 157 +- .../src/routes/settings/google/+page.svelte | 87 +- .../settings/notifications/+page.svelte | 33 +- .../src/routes/settings/payments/+page.svelte | 70 +- .../src/routes/settings/profile/+page.svelte | 109 +- .../src/routes/settings/storage/+page.svelte | 72 +- .../src/routes/settings/tracking/+page.svelte | 118 +- .../src/routes/settings/video/+page.svelte | 104 +- .../src/routes/settings/zoom/+page.svelte | 72 +- frontend/src/routes/teams/+page.svelte | 81 +- frontend/src/routes/webhooks/+page.svelte | 152 +- frontend/tsconfig.json | 3 +- frontend/vitest.config.ts | 1 + 50 files changed, 5801 insertions(+), 1651 deletions(-) create mode 100644 frontend/src/lib/i18n/catalog.test.ts create mode 100644 frontend/src/lib/i18n/catalog.ts create mode 100644 frontend/src/lib/i18n/en.json create mode 100644 frontend/src/lib/i18n/fr.json create mode 100644 frontend/src/lib/i18n/i18n.test.ts create mode 100644 frontend/src/lib/i18n/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6426f4..7303c611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad ## [Unreleased] +### Added +- **French localization of the admin UI.** The SvelteKit admin (`frontend/`) now has a + lightweight i18n layer (`frontend/src/lib/i18n/`) with English as the source of truth + and a complete French catalog written by a native speaker. The language is chosen in + Settings → Profile (remembered in the browser) and falls back to the browser language, + then to English. Dates and times in the admin follow that locale. Public booking pages + and emails still use the existing 11-locale booker catalogs; the LiveKit room stays + English. + ## [0.13.0] - 2026-11-03 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb3fcad8..3adf78ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,17 +76,19 @@ prefer additive, nullable/defaulted columns. Calnode ships 11 locales (`en es fr fr-CA de el it nl pl pt sv`) across the booker-facing surfaces: booking page, manage/reschedule page, embed widget, the four emails, and the calendar -invite. The admin UI and the built-in video room are English-only. +invite. The admin SPA is translated separately (`en` + `fr` today). The built-in video +room is still English-only. **Translation PRs are very welcome** - both new languages and corrections to existing ones. **Most non-English locales are machine drafts with no native review, and none has had a full native read** (Polish was contributed and grammar-checked by a native speaker), so if you speak one of them, fixes are genuinely valuable and will be merged -readily. Small, single-language PRs are easier to review than sweeping ones. +readily. Small, single-language PRs are easier to review than sweeping ones. The admin +French catalog was written by a native speaker. -**Adding a language is adding one file:** `internal/i18n/locales/.json`. No Go, -template, or frontend change is needed - `init()` globs the directory, and the language -switcher, the fallback-language setting and the public API payload all read +**Adding a booker-facing language is adding one file:** `internal/i18n/locales/.json`. +No Go, template, or frontend change is needed - `init()` globs the directory, and the +language switcher, the fallback-language setting and the public API payload all read `SupportedLocales()`. 1. Copy `internal/i18n/locales/en.json` and translate the values. Keep every key, keep @@ -102,6 +104,18 @@ switcher, the fallback-language setting and the public API payload all read `http://localhost:3000/book/?lang=` and the manage page. Long words overflowing buttons is the usual surprise; check mobile width too. +**Adding an admin-UI language** is a small frontend change (the catalogs are not globbed): + +1. Copy `frontend/src/lib/i18n/en.json` to `.json` and translate the values. Keep + every key and any `{placeholders}`. English is the source of truth; missing keys fall + back to English at runtime. +2. Register the new file in `frontend/src/lib/i18n/catalog.ts` (`catalogs` + `ADMIN_LOCALES`). +3. Name the code the same way as booker locales: BCP-47 primary tag (`fr`, `de`). +4. The operator picks the language in Settings → Profile → Preferences (stored in + `localStorage`, with `navigator.languages` as the first-visit fallback). +5. Run the catalog tests (`node --experimental-strip-types --test src/lib/i18n/catalog.test.ts` + from `frontend/`). `pnpm test:visual` also runs the same assertions via Vitest. + Background and the full list of limitations (2-form plurals, no RTL) are in [ARCHITECTURE.md §23](docs/ARCHITECTURE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 73d63b6a..2f7c9855 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1257,12 +1257,22 @@ Translation covers the surfaces a **booker** sees. It deliberately stops there. | The four emails (confirm / cancel / reschedule / reminder) | yes | | Calendar invite title + description | yes | | Conversational booking assistant | yes (replies in the visitor's language) | -| **Admin SPA** (`frontend/`) | **no** - English only, by design | +| **Admin SPA** (`frontend/`) | **yes** - `en` + `fr` (`frontend/src/lib/i18n/`); English is source of truth | | **LiveKit room** (`livekit-room.html` + `livekit-room.js`) | **no** - see below | | **Admin-authored content** (event names, descriptions, intake questions, custom email copy) | **no** - stored as the operator typed it | | IANA timezone identifiers | no - shown as `Europe/Berlin`, not localized | -**The LiveKit room is the one real hole.** ~45 strings in the in-browser meeting UI +The admin SPA is a separate catalog from the booker locales. `frontend/src/lib/i18n/` +ships nested JSON (`en.json` source of truth, `fr.json` a native-speaker translation). +`t` / `tr` look up a dotted key, interpolate `{placeholders}`, and fall back to English +then to the key itself. Locale resolution is client-side: a stored preference +(`localStorage` key `calnode-admin-locale`, set from Settings → Profile → Preferences) +wins, then `navigator.languages` (`fr-CA` → `fr`), then English. Dates and times in the +admin use `Intl` with that tag; the operator's existing `date_format` / `time_format` / +`week_start` prefs still apply. Adding an admin locale is copy-the-JSON plus two +registrations in `catalog.ts` — see CONTRIBUTING.md. + +**The LiveKit room is the remaining hole.** ~45 strings in the in-browser meeting UI (join/leave, mute, screen share, recording banner, consent prompts, chat) are hardcoded English. It was excluded because the room is vanilla JS with its own asset pipeline (`go:embed`-ed in the handler package, not the SPA build) and shares no template or string @@ -1361,6 +1371,13 @@ emails end to end. This doc tracks the code; when you change behaviour in an area above, update the matching section in the same PR. Notable rounds: +- **2026-11-02 — Admin SPA i18n (`en` + `fr`).** The SvelteKit admin (`frontend/`) is no + longer English-only. A small client-side catalog in `frontend/src/lib/i18n/` drives + chrome, list pages, the event-type editor, settings, and auth screens. Locale comes + from Settings → Profile (stored in `localStorage`) with a browser-language fallback; + missing keys fall back to English. Booker-facing locales (`internal/i18n/locales`) + are unchanged. The LiveKit room stays English (§18). + - **2026-08-20 — Multi-language public surfaces (8 locales).** Booking page, manage page, embed widget, the four emails, the calendar invite and the booking assistant now render in `en es fr de it pt nl sv` (new §23). Locale is negotiated from `Accept-Language` with a @@ -1368,7 +1385,8 @@ matching section in the same PR. Notable rounds: (migration 00051) so reminders match the language the booker used. Adding a language is adding one JSON file - proven by adding six at once with no code change. Go has no locale date data, so the date tables are keys in the JSON, cross-checked against `Intl`/CLDR by a - node-backed test. **Not translated: the admin SPA and the LiveKit room** (§18). + node-backed test. **Not translated at the time: the admin SPA and the LiveKit room** + (§18). The admin SPA gained `en`/`fr` in 2026-11; the room is still English. - **2026-06-30 — Cookie consent + legal links + shared book/manage partials.** Native GA4/GTM (Settings → Tracking) is now consent-gated on book.html **and** manage.html: diff --git a/frontend/src/lib/components/demo-banner.svelte b/frontend/src/lib/components/demo-banner.svelte index 8ee0fb3b..2a95b5d3 100644 --- a/frontend/src/lib/components/demo-banner.svelte +++ b/frontend/src/lib/components/demo-banner.svelte @@ -4,6 +4,7 @@ import { authStatus } from '$lib/stores'; import { Button } from '$lib/components/ui/button'; import { ConfirmDialog } from '$lib/components/ui/confirm-dialog'; + import { t, tr } from '$lib/i18n'; let resetOpen = $state(false); let resetting = $state(false); @@ -12,10 +13,10 @@ resetting = true; try { await api.post('/v1/demo/reset'); - toast.success('Demo reset — reloading…'); + toast.success(tr('demo.toast.resetSuccess')); window.location.href = '/admin/'; } catch (e: any) { - toast.error(e.message ?? 'Reset failed'); + toast.error(e.message ?? tr('demo.toast.resetFailed')); } finally { resetting = false; } @@ -24,23 +25,22 @@

- Public demo — data here is visible to everyone and resets - automatically. Don't enter anything private. + {$t('demo.banner.title')} — {$t('demo.banner.body')} - View source + {$t('demo.banner.viewSource')}

diff --git a/frontend/src/lib/components/event-types/EmbedPanel.svelte b/frontend/src/lib/components/event-types/EmbedPanel.svelte index a19312f2..0bb161c5 100644 --- a/frontend/src/lib/components/event-types/EmbedPanel.svelte +++ b/frontend/src/lib/components/event-types/EmbedPanel.svelte @@ -2,6 +2,7 @@ import { page } from '$app/stores'; import { Button } from '$lib/components/ui/button'; import { toast } from 'svelte-sonner'; + import { t, tr } from '$lib/i18n'; let { slug }: { slug: string } = $props(); @@ -12,23 +13,28 @@ `
-

Embed

-

Drop this booking widget on your website — it renders inline, no iframe. Paste before </body>.

+

{$t('eventTypeEditor.embed.title')}

+

{$t('eventTypeEditor.embed.subtitle')}

- {#each [{ key: 'inline', label: 'Inline', hint: 'Embeds the booking calendar directly in the page.', code: inlineSnippet }, { key: 'popup', label: 'Popup button', hint: 'A button that opens the booking widget in a modal.', code: popupSnippet }] as snip} + {#each snippets as snip}
@@ -36,12 +42,12 @@ {snip.hint}
{snip.code}
{/each} -

The widget calls this instance's public booking API. To restrict which sites may embed it, set EMBED_ALLOWED_ORIGINS.

+

{$t('eventTypeEditor.embed.footer')}

diff --git a/frontend/src/lib/components/event-types/QuestionsPanel.svelte b/frontend/src/lib/components/event-types/QuestionsPanel.svelte index 910016ac..8036ff9b 100644 --- a/frontend/src/lib/components/event-types/QuestionsPanel.svelte +++ b/frontend/src/lib/components/event-types/QuestionsPanel.svelte @@ -10,14 +10,15 @@ import * as Select from '$lib/components/ui/select'; import * as Tooltip from '$lib/components/ui/tooltip'; import { toast } from 'svelte-sonner'; + import { t, tr } from '$lib/i18n'; let { slug }: { slug: string } = $props(); - const QUESTION_TYPES = [ - { value: 'text', label: 'Text' }, - { value: 'checkbox', label: 'Checkbox (yes/no)' }, - { value: 'select', label: 'Dropdown' }, - ]; + const QUESTION_TYPES = $derived([ + { value: 'text', label: $t('eventTypeEditor.questions.types.text') }, + { value: 'checkbox', label: $t('eventTypeEditor.questions.types.checkbox') }, + { value: 'select', label: $t('eventTypeEditor.questions.types.select') }, + ]); let questions: Question[] = $state([]); let qLoading = $state(true); @@ -36,7 +37,7 @@ const res = await api.get<{ items: Question[] }>(`/v1/event-types/${slug}/questions/admin`); questions = (res.items ?? []).sort((a, b) => a.position - b.position); } catch (e: any) { - toast.error(e.message || 'Could not load questions'); + toast.error(e.message || tr('eventTypeEditor.questions.error.load')); } finally { qLoading = false; } @@ -55,9 +56,9 @@ } async function addQuestion() { - if (!qForm.label.trim()) { toast.error('Label is required.'); return; } + if (!qForm.label.trim()) { toast.error(tr('eventTypeEditor.questions.validation.label')); return; } if (qForm.type === 'select' && optionsArray(qForm.options).length === 0) { - toast.error('At least one option is required for dropdown questions.'); return; + toast.error(tr('eventTypeEditor.questions.validation.options')); return; } qAdding = true; try { @@ -70,7 +71,7 @@ qForm = { label: '', type: 'text', options: '', required: false }; await loadQuestions(); } catch (e: any) { - toast.error(e.message || 'Could not add question'); + toast.error(e.message || tr('eventTypeEditor.questions.error.add')); } finally { qAdding = false; } @@ -84,9 +85,9 @@ function cancelEditQ() { editingQId = null; } async function saveQuestion(q: Question) { - if (!editQForm.label.trim()) { toast.error('Label is required.'); return; } + if (!editQForm.label.trim()) { toast.error(tr('eventTypeEditor.questions.validation.label')); return; } if (editQForm.type === 'select' && optionsArray(editQForm.options).length === 0) { - toast.error('At least one option is required.'); return; + toast.error(tr('eventTypeEditor.questions.validation.optionsShort')); return; } qSaving = true; try { @@ -99,7 +100,7 @@ editingQId = null; await loadQuestions(); } catch (e: any) { - toast.error(e.message || 'Could not save question'); + toast.error(e.message || tr('eventTypeEditor.questions.error.save')); } finally { qSaving = false; } @@ -116,36 +117,40 @@ await api.del(`/v1/event-types/${slug}/questions/${pendingQ.id}`); await loadQuestions(); } catch (e: any) { - toast.error(e.message || 'Could not delete question'); + toast.error(e.message || tr('eventTypeEditor.questions.error.delete')); } } + + function typeLabel(value: string): string { + return QUESTION_TYPES.find((t) => t.value === value)?.label ?? value; + }
-

Intake Questions

-

Collect information from attendees when they book.

+

{$t('eventTypeEditor.questions.title')}

+

{$t('eventTypeEditor.questions.subtitle')}

{#if qLoading} -

Loading…

+

{$t('common.loading')}

{:else if questions.length > 0} - - - - + + + + @@ -157,40 +162,40 @@
#QuestionTypeRequired{$t('eventTypeEditor.questions.table.number')}{$t('eventTypeEditor.questions.table.question')}{$t('eventTypeEditor.questions.table.type')}{$t('eventTypeEditor.questions.table.required')}
- +
- + { if (v) editQForm.type = v as 'text'|'select'|'checkbox'; }}> - {QUESTION_TYPES.find((t) => t.value === editQForm.type)?.label ?? 'Select…'} + {QUESTION_TYPES.find((t) => t.value === editQForm.type)?.label ?? $t('common.select')} - {#each QUESTION_TYPES as t} - {t.label} + {#each QUESTION_TYPES as qt} + {qt.label} {/each}
{#if editQForm.type === 'select'}
- +