diff --git a/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/embed/page.tsx b/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/embed/page.tsx index 2f58711984..8468d13020 100644 --- a/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/embed/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/embed/page.tsx @@ -32,11 +32,7 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => { const eventLocale = props.eventData?.interfaceLanguage; const ns = "common"; - let translations; - if (eventLocale) { - const ns = "common"; - translations = await loadTranslations(eventLocale, ns); - } + const translations = await loadTranslations(eventLocale ?? "en", ns); if ((props as TeamTypePageProps)?.teamId) { return eventLocale ? ( diff --git a/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/page.tsx b/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/page.tsx index 9f8fa8a7dc..a53bb39ad9 100644 --- a/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/org/[orgSlug]/[user]/[type]/page.tsx @@ -68,11 +68,7 @@ const ServerPage = async ({ params, searchParams }: PageProps) => { const eventLocale = props.eventData?.interfaceLanguage; const ns = "common"; - let translations; - if (eventLocale) { - const ns = "common"; - translations = await loadTranslations(eventLocale, ns); - } + const translations = await loadTranslations(eventLocale ?? "en", ns); if ((props as TeamTypePageProps)?.teamId) { return eventLocale ? ( diff --git a/packages/lib/server/i18n.test.ts b/packages/lib/server/i18n.test.ts new file mode 100644 index 0000000000..6ef0cee646 --- /dev/null +++ b/packages/lib/server/i18n.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { mergeWithEnglishFallback } from "./i18n"; + +describe("mergeWithEnglishFallback", () => { + it("should merge locale translations with English fallback", () => { + const localeTranslations = { + // Override some existing English keys + hello: "Bonjour", + goodbye: "Au revoir", + // Add new locale-specific keys + locale_only: "French only text", + }; + + const result = mergeWithEnglishFallback(localeTranslations); + + // Check that locale translations override English + expect(result.hello).toBe("Bonjour"); + expect(result.goodbye).toBe("Au revoir"); + + // Check that locale-specific keys are preserved + expect(result.locale_only).toBe("French only text"); + + // Check that English fallback keys are present (using actual keys from the real file) + expect(result).toHaveProperty("welcome"); + expect(result).toHaveProperty("cancel"); + + // Verify the structure - should have both English and locale keys + expect(Object.keys(result)).toContain("hello"); // overridden + expect(Object.keys(result)).toContain("locale_only"); // locale-specific + expect(Object.keys(result).length).toBeGreaterThan(3); // Should have many keys from English + }); + + it("should use English translations as fallback when locale translations are empty", () => { + const localeTranslations = {}; + + const result = mergeWithEnglishFallback(localeTranslations); + + // Should return all English translations + expect(result).toHaveProperty("welcome"); + expect(result).toHaveProperty("cancel"); + expect(Object.keys(result).length).toBeGreaterThan(100); // English file has many keys + }); + + it("should prioritize locale translations over English when keys overlap", () => { + const localeTranslations = { + welcome: "Bienvenido", + cancel: "Cancelar", + }; + + const result = mergeWithEnglishFallback(localeTranslations); + + // Spanish should override English + expect(result.welcome).toBe("Bienvenido"); + expect(result.cancel).toBe("Cancelar"); + + // Should still have other English keys + expect(Object.keys(result).length).toBeGreaterThan(100); + }); + + it("should handle locale translations with additional keys not in English", () => { + const localeTranslations = { + // Override existing English key + welcome: "Ciao", + // Add new keys not in English + italian_greeting: "Buongiorno", + italian_phrase: "Come stai?", + }; + + const result = mergeWithEnglishFallback(localeTranslations); + + // Check overridden key + expect(result.welcome).toBe("Ciao"); + + // Check Italian-specific keys + expect(result.italian_greeting).toBe("Buongiorno"); + expect(result.italian_phrase).toBe("Come stai?"); + + // Should have English keys plus the new Italian ones + expect(Object.keys(result).length).toBeGreaterThan(100); + expect(Object.keys(result)).toContain("italian_greeting"); + expect(Object.keys(result)).toContain("italian_phrase"); + }); +}); diff --git a/packages/lib/server/i18n.ts b/packages/lib/server/i18n.ts index ccaa8e4f34..c0a6655d42 100644 --- a/packages/lib/server/i18n.ts +++ b/packages/lib/server/i18n.ts @@ -5,7 +5,6 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { fetchWithTimeout } from "../fetchWithTimeout"; import logger from "../logger"; -/* eslint-disable @typescript-eslint/no-var-requires */ const { i18n } = require("@calcom/config/next-i18next.config"); const log = logger.getSubLogger({ prefix: ["[i18n]"] }); @@ -20,6 +19,15 @@ const translationCache = new Map>(); const i18nInstanceCache = new Map(); const SUPPORTED_NAMESPACES = ["common"]; +export function mergeWithEnglishFallback(localeTranslations: Record): Record { + return { + // IMPORTANT: Spread English translations first to provide fallback for missing keys + ...englishTranslations, + // Then spread locale translations to override English when keys exist in both + ...localeTranslations, + }; +} + /** * Loads translations for a specific locale and namespace with optimized caching * English translations are bundled as englishTranslations for reliability, @@ -34,8 +42,9 @@ export async function loadTranslations(_locale: string, _ns: string) { const ns = SUPPORTED_NAMESPACES.includes(_ns) ? _ns : "common"; const cacheKey = `${locale}-${ns}`; - if (translationCache.has(cacheKey)) { - return translationCache.get(cacheKey); + const cached = translationCache.get(cacheKey); + if (cached) { + return cached; } if (locale === "en") { @@ -48,8 +57,9 @@ export async function loadTranslations(_locale: string, _ns: string) { `../../../apps/web/public/static/locales/${locale}/${ns}.json` ); - translationCache.set(cacheKey, localeTranslations); - return localeTranslations; + const mergedTranslations = mergeWithEnglishFallback(localeTranslations); + translationCache.set(cacheKey, mergedTranslations); + return mergedTranslations; } catch (dynamicImportErr) { log.warn(`Dynamic import failed for locale ${locale}:`, dynamicImportErr); @@ -64,8 +74,9 @@ export async function loadTranslations(_locale: string, _ns: string) { ); if (response.ok) { const httpTranslations = await response.json(); - translationCache.set(cacheKey, httpTranslations); - return httpTranslations; + const mergedTranslations = mergeWithEnglishFallback(httpTranslations); + translationCache.set(cacheKey, mergedTranslations); + return mergedTranslations; } } catch (httpErr) { log.error(`HTTP fallback also failed for locale ${locale}:`, httpErr);