diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1ce7cc..4644c3e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,33 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad ## [Unreleased] +### Added +- **Canadian French (`fr-CA`) on the booker-facing surfaces.** A visitor whose browser asks + for `fr-CA` now gets Canadian French rather than the France copy; `fr` and `fr-FR` are + unaffected. It is the first regional locale, and a separate file rather than a fallback + because the differences are real: `courriel` rather than `e-mail`, `reporter`/`report` + rather than `reprogrammer`, `renseignements personnels` (the Quebec statutory term) rather + than `données personnelles`, no space before `!` `?` `;` where France puts one, and CLDR + itself spells July `juill.` here against `juil.` in France. + + ⚠️ **The wording is an unreviewed draft**, like every non-English locale in this + repository: the structure is verified by the same three guards (same keys, printf-verb + parity, date tables cross-checked against CLDR), but no native Canadian French speaker has + read the copy. Corrections are welcome and easy to merge — see CONTRIBUTING. + +- **`FRAME_ANCESTORS`: embed the admin UI in your own console.** Space-separated origins + (`https://console.example.com 'self'`); when set, `/admin/` sends + `Content-Security-Policy: frame-ancestors `. The public booking pages are + untouched and still deny framing outright — this is about the console, not the pages + that take card details. + + Two deliberate refusals. An entry that is not `https://host[:port]` or `'self'` stops + the app booting rather than being ignored, because a browser drops a source list it + cannot parse, which would leave the admin UI *more* embeddable than the setting being + unset. And no `X-Frame-Options` is sent beside it: that header has no allow-list form, + so the only value it could carry is `SAMEORIGIN`, which browsers honour instead of the + CSP and would break the embedding this exists for. + ### Fixed - **The Zoom setup text no longer promises that an unpublished app works for "your own team".** Zoom only lets users inside the Zoom account that owns an unpublished app @@ -50,19 +77,6 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad pool could satisfy are all excluded, so the explanation never appears attached to the wrong cause. Three new/changed keys in all eight locales. -- **`FRAME_ANCESTORS`: embed the admin UI in your own console.** Space-separated origins - (`https://console.example.com 'self'`); when set, `/admin/` sends - `Content-Security-Policy: frame-ancestors `. The public booking pages are - untouched and still deny framing outright — this is about the console, not the pages - that take card details. - - Two deliberate refusals. An entry that is not `https://host[:port]` or `'self'` stops - the app booting rather than being ignored, because a browser drops a source list it - cannot parse, which would leave the admin UI *more* embeddable than the setting being - unset. And no `X-Frame-Options` is sent beside it: that header has no allow-list form, - so the only value it could carry is `SAMEORIGIN`, which browsers honour instead of the - CSP and would break the embedding this exists for. - ### Fixed - **Constraint violations are recognised by SQLite's error code rather than by its English message.** Thirteen call sites asked `strings.Contains(err.Error(), "UNIQUE diff --git a/CLAUDE.md b/CLAUDE.md index 234d4ad1..514448d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ no string plumbing with the Go templates), and admin-authored content (event nam 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 de it pt nl sv`. Full detail: ARCHITECTURE §23. +reminders match. Ships `en es fr fr-CA de it pt nl sv`. Full detail: ARCHITECTURE §23. **Adding a locale = adding `internal/i18n/locales/.json`.** Nothing else. `init()` globs the directory; the switcher, the fallback dropdown and the public API payload all read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 148c9d24..76870aa3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,7 @@ prefer additive, nullable/defaulted columns. ## Translations -Calnode ships 8 locales (`en es fr de it pt nl sv`) across the booker-facing surfaces: +Calnode ships 9 locales (`en es fr fr-CA de it pt nl 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. diff --git a/README.md b/README.md index 7af16c7f..ac5c67d0 100644 --- a/README.md +++ b/README.md @@ -190,12 +190,12 @@ domains, Resend email, Google & Microsoft OAuth, Litestream backups, troubleshoo - **Zoom** — per-host OAuth; a Zoom-located booking mints a meeting under the assigned host's account - **Built-in video meetings (LiveKit)** — in-browser rooms as a booking location (no app or account for guests); host controls (end-for-all, hand-off **and reclaim** host, attendee screen-share toggle), **meeting recording** straight to your own **Litestream backup bucket** (the same one you already use for DB backup — no extra storage to provision) with in-app downloads, **recording consent** (notice + consent-or-leave), and an **AI notetaker** (Deepgram transcript → LLM notes). Headless-consumable: MCP `get_meeting_notes`/`get_transcript` + `recording.completed`/`transcript.ready`/`notes.ready` webhooks. BYO LiveKit endpoint (Cloud or self-hosted); configured in Settings → Video — see [docs/VIDEO.md](docs/VIDEO.md) -- **8 languages** on every booker-facing surface - booking page, manage/reschedule page, +- **9 languages** on every booker-facing surface - booking page, manage/reschedule page, embed widget, all four emails, and the calendar invite: **English · Spanish · French · - German · Italian · Portuguese · Dutch · Swedish**. Picked from `Accept-Language` with a - footer switcher and an operator-set fallback language; the booker's choice is stored on - the booking, so reminders arrive in the language they booked in. Adding a language is - adding one JSON file - no code change. *(The admin UI and the built-in video room are + Canadian French · German · Italian · Portuguese · Dutch · Swedish**. Picked from + `Accept-Language` with a footer switcher and an operator-set fallback language; the + booker's choice is stored on the booking, so reminders arrive in the language they + booked in. Adding a language is adding one JSON file - no code change. *(The admin UI and the built-in video room are English-only. Non-English translations are machine drafts without native review - corrections by PR are very welcome.)* - Embeddable booking widget (Shadow-DOM web component; inline + popup) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58defaab..11c450c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -837,7 +837,7 @@ as the desired state: patching. The **public** booking surfaces are unaffected and are verified on mobile; this is admin-only. Deferred 2026-08-22, not a regression. - **LiveKit room is not translated** - the booking surfaces, emails and calendar invites - ship in 8 languages (§23), but the in-browser meeting UI is ~45 hardcoded English + ship in 9 languages (§23), but the in-browser meeting UI is ~45 hardcoded English strings. It has its own vanilla-JS asset pipeline and shares no string plumbing with the Go templates, so it needs a small runtime `t()` of its own. Separate work, not hard. - **Plural rules are 2-form only** (§23) - shipping Polish, Russian or Arabic correctly @@ -1079,7 +1079,18 @@ LLM summary) — the next build; consent-gated (§8.11/§15 of the PRD). ## 23. Languages (i18n) -Calnode ships **8 locales**: `en` (source) · `es` · `fr` · `de` · `it` · `pt` · `nl` · `sv`. +Calnode ships **9 locales**: `en` (source) · `es` · `fr` · `fr-CA` · `de` · `it` · `pt` · `nl` · `sv`. + +`fr-CA` is the first **regional** locale, and it is a separate file rather than a fallback +because the differences are real: `courriel` not `e-mail`, `reporter`/`report` not +`reprogrammer`/`reprogrammation`, `renseignements personnels` never `données personnelles` +(the Quebec statutory term), no space before `!` `?` `;` where France puts one, and CLDR +itself disagrees on one abbreviation — `month_short_jul` is `juill.` in fr-CA and `juil.` in +fr, which is exactly what `TestDateTablesMatchCLDR` exists to catch. Both keep the 24-hour +clock and the day-month `date_format`. Currency and percent take a non-breaking space before +`$` and `%` in Canadian French; no key carries either today, so the rule is recorded here +rather than applied. A visitor sending `fr-FR` or plain `fr` is unaffected — the matcher +picks the exact tag first (pinned in `TestResolve`). ### What is translated, and what is not diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go index 0d0d9ca8..e3afc581 100644 --- a/internal/i18n/i18n_test.go +++ b/internal/i18n/i18n_test.go @@ -37,6 +37,13 @@ func TestResolve(t *testing.T) { {"exact es", "es", "", "es"}, {"exact en", "en-US,en;q=0.9", "", "en"}, {"regional subtag falls back to primary", "es-MX,es;q=0.9", "", "es"}, + // fr-CA is shipped as its own file, so a Canadian browser gets Canadian French + // rather than the France copy — and a French browser is unaffected by its + // existence, which is the half that would break silently. + {"Canadian French resolves to its own locale", "fr-CA,fr;q=0.9", "", "fr-CA"}, + {"European French is unaffected by fr-CA", "fr-FR,fr;q=0.9", "", "fr"}, + {"plain fr is unaffected by fr-CA", "fr", "", "fr"}, + {"fr-CA can be selected by the ?lang override", "en", "fr-CA", "fr-CA"}, {"unsupported language falls back to English", "ja-JP,ja;q=0.9", "", "en"}, {"empty header falls back to English", "", "", "en"}, {"garbage header falls back to English", "not a real header ;;;", "", "en"}, diff --git a/internal/i18n/locales/fr-CA.json b/internal/i18n/locales/fr-CA.json new file mode 100644 index 00000000..d7725081 --- /dev/null +++ b/internal/i18n/locales/fr-CA.json @@ -0,0 +1,160 @@ +{ + "book_by_chat": "Réserver par chat", + "close": "Fermer", + "assistant_greeting": "Bonjour! Dites-moi approximativement quand vous souhaitez nous rencontrer, par exemple « mardi après-midi » ou « la semaine prochaine », et je trouverai un créneau. Vous pouvez aussi utiliser le calendrier.", + "assistant_input_placeholder": "Indiquez vos disponibilités…", + "assistant_input_aria": "Envoyer un message à l'assistant de réservation", + "send": "Envoyer", + "show_more": "Afficher plus", + "show_less": "Afficher moins", + "date_picker_aria": "Sélecteur de date", + "timezone_aria": "Fuseau horaire", + "select_day_hint": "Sélectionnez un jour pour voir les créneaux disponibles.", + "back": "Retour", + "name_label": "Nom", + "name_placeholder": "Votre nom complet", + "email_label": "Courriel", + "email_placeholder": "vous@exemple.com", + "choose_option": "Choisissez une option", + "confirm_booking": "Confirmer la réservation", + "confirming": "Confirmation…", + "booking_confirmed": "Réservation confirmée", + "confirmation_email_sent": "Un courriel de confirmation vous a été envoyé.", + "required_fields_error": "Veuillez remplir tous les champs obligatoires.", + "slot_taken_error": "Ce créneau vient d'être réservé. Veuillez en choisir un autre.", + "booking_failed_error": "La réservation a échoué. Veuillez réessayer.", + "network_error": "Erreur de connexion. Vérifiez votre connexion internet.", + "loading": "Chargement…", + "no_available_times": "Aucun créneau disponible le %s.", + "no_available_times_host": "%[1]s n'a aucun créneau disponible le %[2]s.", + "slot_taken": "déjà réservé", + "all_times_taken": "Tous les horaires de cette journée sont réservés.", + "min_notice_hint": "Les réservations doivent être effectuées au moins %s à l'avance.", + "could_not_load_times": "Impossible de charger les créneaux. Veuillez réessayer.", + "times_shown_in": "Horaires affichés en ", + "payment_received": "Paiement reçu", + "payment_confirmed_sub": "Votre réservation est confirmée; un courriel de confirmation va vous parvenir.", + "assistant_error": "Désolé, une erreur est survenue. Veuillez utiliser le calendrier ci-dessous.", + "assistant_disclosure": "Vous discutez avec un assistant automatisé, pas avec une personne.", + "other": "autre", + "others": "autres", + "list_separator": ", ", + "list_conjunction": " et ", + "language_switcher_aria": "Langue", + "cookie_consent_aria": "Consentement aux cookies", + "cookie_text": "Nous utilisons des cookies analytiques pour comprendre l'utilisation de cette page de réservation; ils ne sont activés qu'avec votre accord.", + "privacy_policy": "Politique de confidentialité", + "decline": "Refuser", + "accept": "Accepter", + "privacy": "Confidentialité", + "terms": "Conditions", + "cookie_settings": "Paramètres des cookies", + "prev_month_aria": "Mois précédent", + "next_month_aria": "Mois suivant", + "demo_public_label": "Démo publique", + "demo_public_text": "les renseignements saisis ici sont visibles par tous et sont réinitialisés automatiquement. N'y saisissez aucun renseignement personnel.", + "demo_view_source": "Voir le code source", + "powered_by": "Propulsé par", + "duration_min": "%d min", + "duration_hour_one": "1 heure", + "duration_hours": "%d heures", + "duration_hr_min": "%d h %d min", + "location_video_meeting": "Visioconférence", + "location_phone": "Appel téléphonique", + "location_in_person": "En personne", + "location_video_call": "Visioconférence", + "manage_booking_title": "Gérer la réservation", + "link_expired_title": "Lien expiré ou invalide", + "link_expired_text": "Ce lien de gestion a expiré ou n'est plus valide. Consultez votre courriel de confirmation pour un lien valide, ou contactez l'organisateur pour en obtenir un nouveau.", + "booking_already_cancelled_title": "Réservation déjà annulée", + "booking_already_cancelled_text": "Cette réservation a été annulée. Pour réserver un nouveau créneau, rendez-vous sur la page de l'événement.", + "scheduled_for": "Prévu le", + "reschedule": "Reporter", + "cancel_booking": "Annuler la réservation", + "select_a_date": "Sélectionnez une date", + "pick_day_hint": "Choisissez un jour dans le calendrier.", + "confirm_reschedule_question": "Confirmer le report?", + "was_label": "Avant", + "now_label": "Maintenant", + "confirm_reschedule": "Confirmer le report", + "rescheduling": "Report en cours…", + "cancel_this_booking_question": "Annuler cette réservation?", + "cancel_booking_notice": "Cette action est irréversible. Un courriel d'annulation vous sera envoyé, ainsi qu'à l'organisateur.", + "reason_optional_label": "Motif (facultatif)", + "reason_placeholder": "ex. conflit d'horaire", + "yes_cancel_booking": "Oui, annuler la réservation", + "cancelling": "Annulation…", + "booking_rescheduled_title": "Réservation reportée", + "booking_rescheduled_prefix": "Votre réservation a été déplacée au ", + "booking_cancelled_title": "Réservation annulée", + "booking_cancelled_text": "Votre réservation a été annulée.", + "slot_no_longer_available_error": "Ce créneau n'est plus disponible. Veuillez en choisir un autre.", + "reschedule_failed_error": "Le report a échoué. Veuillez réessayer.", + "already_cancelled_error": "Cette réservation est déjà annulée.", + "cancellation_failed_error": "L'annulation a échoué. Veuillez réessayer.", + "dow_short_sun": "dim.", + "dow_short_mon": "lun.", + "dow_short_tue": "mar.", + "dow_short_wed": "mer.", + "dow_short_thu": "jeu.", + "dow_short_fri": "ven.", + "dow_short_sat": "sam.", + "month_short_jan": "janv.", + "month_short_feb": "févr.", + "month_short_mar": "mars", + "month_short_apr": "avr.", + "month_short_may": "mai", + "month_short_jun": "juin", + "month_short_jul": "juill.", + "month_short_aug": "août", + "month_short_sep": "sept.", + "month_short_oct": "oct.", + "month_short_nov": "nov.", + "month_short_dec": "déc.", + "clock_format": "24h", + "date_format": "%[1]s %[2]d %[3]s %[4]d", + "email_greeting": "Bonjour %s,", + "email_confirmed_lead": "Votre réservation est confirmée.", + "email_cancelled_lead": "Votre réservation a été annulée.", + "email_rescheduled_lead": "Votre réservation a été reportée.", + "email_reminder_lead": "Ceci est un rappel : votre rendez-vous approche.", + "email_label_event": "Événement", + "email_label_with": "Avec", + "email_label_start": "Début", + "email_label_end": "Fin", + "email_label_when": "Quand", + "email_label_location": "Lieu", + "email_label_reason": "Motif", + "email_label_was": "Avant", + "email_label_now": "Maintenant", + "email_add_to_calendar": "Ajoutez à votre calendrier :", + "email_add_to_calendar_updated": "Ajoutez à votre calendrier (horaire mis à jour) :", + "email_calendar_google": "Google Calendar", + "email_calendar_outlook": "Outlook", + "email_booking_reference": "Référence de la réservation : %s", + "email_manage_link_intro": "Pour reporter ou annuler, rendez-vous sur :", + "email_manage_link_intro_reschedule": "Pour reporter ou annuler à nouveau, rendez-vous sur :", + "email_cancel_link_intro": "Pour annuler, rendez-vous sur :", + "email_rebook_intro": "Pour réserver à nouveau, rendez-vous sur :", + "email_manage_button": "Gérer la réservation", + "email_rebook_button": "Réserver à nouveau", + "email_subject_confirmed": "Réservation confirmée : %s", + "email_subject_cancelled": "Réservation annulée : %s", + "email_subject_rescheduled": "Réservation reportée : %s", + "email_subject_reminder": "Rappel : %s approche", + "email_booking_with": "Réservation avec %s", + "email_manage_this_booking": "Gérer cette réservation :", + "calendar_event_summary": "%s avec %s", + "calendar_event_booking_id": "Référence de la réservation : %s", + "err_booking_limit": "Vous avez déjà atteint le nombre maximum de réservations à venir pour cet événement. Annulez une réservation existante ou attendez qu'elle soit passée avant d'en créer une nouvelle.", + "err_email_throttled": "Trop de réservations récentes depuis cette adresse courriel. Veuillez réessayer plus tard.", + "err_required_field": "le champ obligatoire %q est manquant", + "err_required_checkbox": "%q doit être coché", + "err_invalid_option": "option non valide pour %[1]q : %[2]q n'est pas un choix autorisé", + "assistant_unavailable": "L'assistant de réservation n'est pas disponible pour le moment. Choisissez un créneau dans le calendrier ci-dessous.", + "assistant_trouble": "Désolé, nous rencontrons un problème. Choisissez un créneau dans le calendrier ci-dessous.", + "assistant_iteration_cap": "Simplifions les choses : choisissez un créneau dans le calendrier ci-dessous.", + "assistant_status_slots": "Vérification des disponibilités…", + "assistant_status_book": "Réservation…", + "assistant_status_working": "En cours…" +}