diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index 50bde13463..51d3bfd0ea 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes"; import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app"; import type { ParsedUrlQuery } from "querystring"; import type { PropsWithChildren, ReactNode } from "react"; +import { useEffect } from "react"; import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider"; import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic"; @@ -75,6 +76,22 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => { const session = useSession(); const locale = session?.data?.user.locale ?? props.pageProps.newLocale; + useEffect(() => { + window.document.documentElement.lang = locale; + + let direction = window.document.dir || "ltr"; + + try { + const intlLocale = new Intl.Locale(locale); + // @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute + direction = intlLocale.textInfo?.direction; + } catch (error) { + console.error(error); + } + + window.document.dir = direction; + }, [locale]); + const clientViewerI18n = useViewerI18n(locale); const i18n = clientViewerI18n.data?.i18n; @@ -82,6 +99,7 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => { ...props, pageProps: { ...props.pageProps, + ...i18n, }, }; diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 31250050fd..89d53107ec 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -2,7 +2,6 @@ import type { IncomingMessage } from "http"; import type { AppContextType } from "next/dist/shared/lib/utils"; import React from "react"; -import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { trpc } from "@calcom/trpc/react"; import type { AppProps } from "@lib/app-providers"; @@ -28,6 +27,7 @@ MyApp.getInitialProps = async (ctx: AppContextType) => { let newLocale = "en"; if (req) { + const { getLocale } = await import("@calcom/features/auth/lib/getLocale"); newLocale = await getLocale(req as IncomingMessage & { cookies: Record }); } else if (typeof window !== "undefined" && window.calNewLocale) { newLocale = window.calNewLocale; diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx index c30f120b15..e8c73a21b7 100644 --- a/apps/web/pages/_document.tsx +++ b/apps/web/pages/_document.tsx @@ -4,7 +4,6 @@ import type { DocumentContext, DocumentProps } from "next/document"; import Document, { Head, Html, Main, NextScript } from "next/document"; import { z } from "zod"; -import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { IS_PRODUCTION } from "@calcom/lib/constants"; import { csp } from "@lib/csp"; @@ -28,9 +27,12 @@ class MyDocument extends Document { setHeader(ctx, "x-csp", "initialPropsOnly"); } - const newLocale = ctx.req - ? await getLocale(ctx.req as IncomingMessage & { cookies: Record }) - : "en"; + const getLocaleModule = ctx.req ? await import("@calcom/features/auth/lib/getLocale") : null; + + const newLocale = + ctx.req && getLocaleModule + ? await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record }) + : "en"; const asPath = ctx.asPath || ""; // Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only diff --git a/apps/web/playwright/locale.e2e.ts b/apps/web/playwright/locale.e2e.ts new file mode 100644 index 0000000000..c5261c90fc --- /dev/null +++ b/apps/web/playwright/locale.e2e.ts @@ -0,0 +1,259 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "serial" }); + +test.describe("unauthorized user sees correct translations (de)", async () => { + test.use({ + locale: "de", + }); + + test("should use correct translations and html attributes", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + + await page.locator("html[lang=de]").waitFor({ state: "attached" }); + await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("Willkommen zurück", { exact: true }); + expect(await locator.count()).toEqual(1); + } + + { + const locator = page.getByText("Welcome back", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); +}); + +test.describe("unauthorized user sees correct translations (ar)", async () => { + test.use({ + locale: "ar", + }); + + test("should use correct translations and html attributes", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + + await page.locator("html[lang=ar]").waitFor({ state: "attached" }); + await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("أهلاً بك من جديد", { exact: true }); + expect(await locator.count()).toEqual(1); + } + + { + const locator = page.getByText("Welcome back", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); +}); + +test.describe("authorized user sees correct translations (de) [locale1]", async () => { + test.use({ + locale: "en", + }); + + test("should return correct translations and html attributes", async ({ page, users }) => { + await test.step("should create a de user", async () => { + const user = await users.create({ + locale: "de", + }); + await user.apiLogin(); + }); + + await test.step("should navigate to /event-types and show German translations", async () => { + await page.goto("/event-types"); + + await page.waitForLoadState("networkidle"); + + await page.locator("html[lang=de]").waitFor({ state: "attached" }); + await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("Ereignistypen", { exact: true }); + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Event Types", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); + + await test.step("should navigate to /bookings and show German translations", async () => { + await page.goto("/bookings"); + + await page.waitForLoadState("networkidle"); + + await page.locator("html[lang=de]").waitFor({ state: "attached" }); + await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("Buchungen", { exact: true }); + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Bookings", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); + + await test.step("should reload the /bookings and show German translations", async () => { + await page.reload(); + + await page.waitForLoadState("networkidle"); + + await page.locator("html[lang=de]").waitFor({ state: "attached" }); + await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("Buchungen", { exact: true }); + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Bookings", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); + }); +}); + +test.describe("authorized user sees correct translations (ar)", async () => { + test.use({ + locale: "en", + }); + + test("should return correct translations and html attributes", async ({ page, users }) => { + await test.step("should create a de user", async () => { + const user = await users.create({ + locale: "ar", + }); + await user.apiLogin(); + }); + + await test.step("should navigate to /event-types and show German translations", async () => { + await page.goto("/event-types"); + + await page.waitForLoadState("networkidle"); + + await page.locator("html[lang=ar]").waitFor({ state: "attached" }); + await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("أنواع الحدث", { exact: true }); + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Event Types", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); + + await test.step("should navigate to /bookings and show German translations", async () => { + await page.goto("/bookings"); + + await page.waitForLoadState("networkidle"); + + await page.locator("html[lang=ar]").waitFor({ state: "attached" }); + await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("عمليات الحجز", { exact: true }); + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Bookings", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); + + await test.step("should reload the /bookings and show German translations", async () => { + await page.reload(); + + await page.waitForLoadState("networkidle"); + + await page.locator("html[lang=ar]").waitFor({ state: "attached" }); + await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("عمليات الحجز", { exact: true }); + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Bookings", { exact: true }); + expect(await locator.count()).toEqual(0); + } + }); + }); +}); + +test.describe("authorized user sees changed translations (de->ar)", async () => { + test.use({ + locale: "en", + }); + + test("should return correct translations and html attributes", async ({ page, users }) => { + await test.step("should create a de user", async () => { + const user = await users.create({ + locale: "de", + }); + await user.apiLogin(); + }); + + await test.step("should change the language and show Arabic translations", async () => { + await page.goto("/settings/my-account/general"); + + await page.waitForLoadState("networkidle"); + + await page.locator(".bg-default > div > div:nth-child(2)").first().click(); + await page.locator("#react-select-2-option-0").click(); + + await page.getByRole("button", { name: "Aktualisieren" }).click(); + + await page + .getByRole("button", { name: "Einstellungen erfolgreich aktualisiert" }) + .waitFor({ state: "visible" }); + + await page.locator("html[lang=ar]").waitFor({ state: "attached" }); + await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("عام", { exact: true }); // "general" + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Allgemein", { exact: true }); // "general" + expect(await locator.count()).toEqual(0); + } + }); + + await test.step("should reload and show Arabic translations", async () => { + await page.reload(); + + await page.waitForLoadState("networkidle"); + + await page.locator("html[lang=ar]").waitFor({ state: "attached" }); + await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); + + { + const locator = page.getByText("عام", { exact: true }); // "general" + expect(await locator.count()).toBeGreaterThanOrEqual(1); + } + + { + const locator = page.getByText("Allgemein", { exact: true }); // "general" + expect(await locator.count()).toEqual(0); + } + }); + }); +});