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/CLAUDE.md b/CLAUDE.md index b5127d96..e1c17453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,10 +134,12 @@ client SDK**, not Svelte. ## Languages (i18n) - data-driven, adding one touches no code Public surfaces (book.html, manage.html, embed.js, the four emails, calendar invite -title/description) are translated. **NOT translated: the admin SPA, the LiveKit room** -(`livekit-room.html`/`.js`, ~45 hardcoded strings - it has its own asset pipeline and shares -no string plumbing with the Go templates), and admin-authored content (event names, -descriptions, questions, custom email copy). Locale is resolved per request from +title/description) are translated. The **admin SPA has its own separate catalog** +(`frontend/src/lib/i18n/`, client-side, `en` + `fr`, English fallback per key) — a +different system from the booker locales here; see ARCHITECTURE §23. **NOT translated: +the LiveKit room** (`livekit-room.html`/`.js`, ~45 hardcoded strings - it has its own +asset pipeline and shares no string plumbing with the Go templates), and admin-authored +content (event names, descriptions, questions, custom email copy). Locale is resolved per request from `Accept-Language` + a `?lang=` override + the operator's fallback setting (`internal/handler/i18n.go`), and the booker's locale is stored on the booking so later reminders match. Ships `en es fr fr-CA de el it nl pl pt sv`. Full detail: ARCHITECTURE §23. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb3fcad8..675fcb0a 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,19 @@ 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}` (including `{console}` / `{email}`-style link + slots rendered by `LinkedText` — do not turn catalog values into HTML). 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'}
- +