Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 20 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>.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/<code>.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
Expand All @@ -102,6 +104,19 @@ switcher, the fallback-language setting and the public API payload all read
`http://localhost:3000/book/<slug>?lang=<code>` 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 `<code>.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).

Expand Down
24 changes: 21 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1361,14 +1371,22 @@ 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
`?lang=` switcher and an operator fallback setting, and is **stored on the booking**
(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:
Expand Down
18 changes: 9 additions & 9 deletions frontend/src/lib/components/demo-banner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -24,23 +25,22 @@

<ConfirmDialog
bind:open={resetOpen}
title="Reset the demo now?"
description="Every event type, booking, and setting is wiped and replaced with fresh sample data. This also happens automatically on a timer."
confirmText="Reset demo"
title={$t('demo.confirm.title')}
description={$t('demo.confirm.description')}
confirmText={$t('demo.confirm.confirm')}
onConfirm={doReset}
/>

<div class="flex items-center justify-between gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900">
<p>
<span class="font-semibold">Public demo</span> — data here is visible to everyone and resets
automatically. Don't enter anything private.
<span class="font-semibold">{$t('demo.banner.title')}</span> — {$t('demo.banner.body')}
<a
href="https://github.com/Calnode/calnode"
target="_blank"
rel="noopener noreferrer"
class="ml-1 font-medium underline"
>
View source
{$t('demo.banner.viewSource')}
</a>
</p>
<Button
Expand All @@ -50,6 +50,6 @@
onclick={() => (resetOpen = true)}
disabled={resetting}
>
{resetting ? 'Resetting…' : 'Reset demo now'}
{resetting ? $t('demo.resetting') : $t('demo.resetButton')}
</Button>
</div>
20 changes: 13 additions & 7 deletions frontend/src/lib/components/event-types/EmbedPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -12,36 +13,41 @@
`<script src="${embedOrigin}/embed.js" async><\/script>\n<calnode-booking slug="${slug}"></calnode-booking>`
);
const popupSnippet = $derived(
`<script src="${embedOrigin}/embed.js" async><\/script>\n<button data-calnode-popup="${slug}">Book a call</button>`
`<script src="${embedOrigin}/embed.js" async><\/script>\n<button data-calnode-popup="${slug}">${$t('eventTypeEditor.embed.buttonText')}</button>`
);
let copied = $state('');
function copyEmbed(kind: string, text: string) {
navigator.clipboard.writeText(text).then(() => {
copied = kind;
setTimeout(() => { if (copied === kind) copied = ''; }, 1500);
}).catch(() => toast.error('Could not copy to clipboard'));
}).catch(() => toast.error(tr('common.copyClipboardError')));
}

const snippets = $derived([
{ key: 'inline', label: $t('eventTypeEditor.embed.inline.label'), hint: $t('eventTypeEditor.embed.inline.hint'), code: inlineSnippet },
{ key: 'popup', label: $t('eventTypeEditor.embed.popup.label'), hint: $t('eventTypeEditor.embed.popup.hint'), code: popupSnippet },
]);
</script>

<!-- Embed -->
<div class="mt-8">
<h2 class="mb-1 text-sm font-semibold uppercase tracking-wider text-muted-foreground">Embed</h2>
<p class="mb-3 text-sm text-muted-foreground">Drop this booking widget on your website — it renders inline, no iframe. Paste before <code>&lt;/body&gt;</code>.</p>
<h2 class="mb-1 text-sm font-semibold uppercase tracking-wider text-muted-foreground">{$t('eventTypeEditor.embed.title')}</h2>
<p class="mb-3 text-sm text-muted-foreground">{$t('eventTypeEditor.embed.subtitle')}</p>
<div class="space-y-5 rounded-lg border bg-card p-6">
{#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}
<div>
<div class="mb-1.5 flex items-center justify-between">
<div>
<span class="text-sm font-medium">{snip.label}</span>
<span class="ml-2 text-xs text-muted-foreground">{snip.hint}</span>
</div>
<Button variant="outline" size="sm" onclick={() => copyEmbed(snip.key, snip.code)}>
{copied === snip.key ? 'Copied!' : 'Copy'}
{copied === snip.key ? $t('common.copied') : $t('common.copy')}
</Button>
</div>
<pre class="overflow-x-auto rounded-md bg-muted px-3 py-2.5 text-xs leading-relaxed"><code>{snip.code}</code></pre>
</div>
{/each}
<p class="text-xs text-muted-foreground">The widget calls this instance's public booking API. To restrict which sites may embed it, set <code>EMBED_ALLOWED_ORIGINS</code>.</p>
<p class="text-xs text-muted-foreground">{$t('eventTypeEditor.embed.footer')}</p>
</div>
</div>
Loading
Loading