diff --git a/CHANGELOG.md b/CHANGELOG.md index b496eae..bbf1534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad `-copy-2`, `-copy-3`, ...) slug, and keeps `price_cents`/`currency` verbatim: zeroing a copied price is how a paid meeting quietly starts selling for nothing. Bookings are not copied. +- **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. + - **Empty days and minimum-notice gaps now explain themselves** on all three booking surfaces (booking page, manage/reschedule page, embed widget). Closes [#20](https://github.com/Calnode/calnode/issues/20). diff --git a/CLAUDE.md b/CLAUDE.md index 91ab512..c788374 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 148c9d2..76870aa 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e91d5db..d401a8e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1048,7 +1048,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 0d0d9ca..e3afc58 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 0000000..d772508 --- /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..." +}

AltStyle によって変換されたページ (->オリジナル) /