From ec8f9d5f7a3d8abfb82ac0a6f097d3306ee47e7f Mon Sep 17 00:00:00 2001 From: Vijay Date: Sun, 18 May 2025 10:42:26 +0530 Subject: [PATCH] feat: option to enforce language for bookingPage (#18782) * feature eventType specific language or bookingPage language * chore * fix type checks * chore * chore: remove log * improvement: this updates in DatePicker is not required with context provider method * added e2e tests for InterfaceLanguage feature * corrected prev merge conflict issues * update to align with hooks usage pattern * chore * unrelated auto-formatting changes * unrelated auto-formatting changes * unrelated auto-formatting changes * update to affect interfaceLanguage on success page * undone prettier changes * update test for success page translation * chore * return null from memo * fix typecheck error due to atom imports in useLocale * set default interfaceLanguage as null * updated to use null instead of constant * enhancements * fix latest unit test, use prop instead of useSession hook * fix build issue * chore * fix atom build issue * fix unit test with reqd mock * update to use server i18n rendering * nit * Move import localeOptions to EventSetupTabWebWrapper * make sure we only display interface language option in web app * using customI18nProvider --------- Co-authored-by: amrit Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com> Co-authored-by: Tushar Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Peer Richelsen Co-authored-by: Ryukemeister Co-authored-by: Rajiv Sahal Co-authored-by: Benny Joo --- .../[user]/[type]/embed/page.tsx | 15 ++ .../[user]/[type]/page.tsx | 13 ++ .../[orgSlug]/[user]/[type]/embed/page.tsx | 31 +++- .../org/[orgSlug]/[user]/[type]/page.tsx | 30 +++- .../team/[slug]/[type]/embed/page.tsx | 15 ++ .../team/[slug]/[type]/page.tsx | 14 ++ .../booking/[uid]/embed/page.tsx | 15 ++ .../(use-page-wrapper)/booking/[uid]/page.tsx | 14 ++ apps/web/app/CustomI18nProvider.tsx | 33 +++++ apps/web/lib/booking.ts | 1 + .../team/[slug]/[type]/getServerSideProps.tsx | 2 + apps/web/playwright/event-types.e2e.ts | 139 ++++++++++++++++++ apps/web/public/static/locales/en/common.json | 2 + packages/features/bookings/types.ts | 1 + .../components/tabs/setup/EventSetupTab.tsx | 43 +++++- .../features/eventtypes/lib/getPublicEvent.ts | 2 + packages/features/eventtypes/lib/types.ts | 1 + packages/lib/defaultEvents.ts | 1 + packages/lib/hooks/useLocale.ts | 4 +- packages/lib/server/eventTypeSelect.ts | 1 + packages/lib/server/repository/eventType.ts | 1 + packages/lib/test/builder.ts | 1 + .../wrappers/EventSetupTabWebWrapper.tsx | 2 + .../migration.sql | 2 + packages/prisma/schema.prisma | 27 ++-- packages/prisma/zod-utils.ts | 1 + 26 files changed, 390 insertions(+), 21 deletions(-) create mode 100644 apps/web/app/CustomI18nProvider.tsx create mode 100644 packages/prisma/migrations/20250117091620_event_type_add_interface_language/migration.sql diff --git a/apps/web/app/(booking-page-wrapper)/[user]/[type]/embed/page.tsx b/apps/web/app/(booking-page-wrapper)/[user]/[type]/embed/page.tsx index 6f5d0bdc8d..253a9e4928 100644 --- a/apps/web/app/(booking-page-wrapper)/[user]/[type]/embed/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/[user]/[type]/embed/page.tsx @@ -1,7 +1,10 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps as ServerPageProps } from "app/_types"; import { cookies, headers } from "next/headers"; +import { loadTranslations } from "@calcom/lib/server/i18n"; + import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps"; @@ -13,6 +16,18 @@ const getData = withEmbedSsrAppDir(getServerSideProps); const ServerPage = async ({ params, searchParams }: ServerPageProps) => { const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const props = await getData(context); + + const locale = props.eventData?.interfaceLanguage; + if (locale) { + const ns = "common"; + const translations = await loadTranslations(locale, ns); + return ( + + + + ); + } + return ; }; diff --git a/apps/web/app/(booking-page-wrapper)/[user]/[type]/page.tsx b/apps/web/app/(booking-page-wrapper)/[user]/[type]/page.tsx index d9a727318b..d4ad78504d 100644 --- a/apps/web/app/(booking-page-wrapper)/[user]/[type]/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/[user]/[type]/page.tsx @@ -1,9 +1,11 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import { withAppDirSsr } from "app/WithAppDirSsr"; import type { PageProps } from "app/_types"; import { generateMeetingMetadata } from "app/_utils"; import { headers, cookies } from "next/headers"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { loadTranslations } from "@calcom/lib/server/i18n"; import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx"; @@ -55,6 +57,17 @@ const ServerPage = async ({ params, searchParams }: PageProps) => { const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const props = await getData(legacyCtx); + const locale = props.eventData?.interfaceLanguage; + if (locale) { + const ns = "common"; + const translations = await loadTranslations(locale, ns); + return ( + + + + ); + } + return ; }; 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 51d21aeaf0..bcb8e7e3ca 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 @@ -1,7 +1,10 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps as ServerPageProps } from "app/_types"; import { cookies, headers } from "next/headers"; +import { loadTranslations } from "@calcom/lib/server/i18n"; + import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps"; @@ -17,8 +20,32 @@ export type ClientPageProps = UserTypePageProps | TeamTypePageProps; const ServerPage = async ({ params, searchParams }: ServerPageProps) => { const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const props = await getData(context); - if ((props as TeamTypePageProps)?.teamId) return ; - return ; + + const eventLocale = props.eventData?.interfaceLanguage; + const ns = "common"; + let translations; + if (eventLocale) { + const ns = "common"; + translations = await loadTranslations(eventLocale, ns); + } + + if ((props as TeamTypePageProps)?.teamId) { + return eventLocale ? ( + + + + ) : ( + + ); + } + + return eventLocale ? ( + + + + ) : ( + + ); }; export default ServerPage; 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 7125f1a1d2..9f8fa8a7dc 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 @@ -1,9 +1,11 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import { withAppDirSsr } from "app/WithAppDirSsr"; import type { PageProps } from "app/_types"; import { generateMeetingMetadata } from "app/_utils"; import { cookies, headers } from "next/headers"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { loadTranslations } from "@calcom/lib/server/i18n"; import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps"; @@ -63,10 +65,32 @@ const ServerPage = async ({ params, searchParams }: PageProps) => { const props = await getData( buildLegacyCtx(await headers(), await cookies(), await params, await searchParams) ); - if ((props as TeamTypePageProps)?.teamId) { - return ; + + const eventLocale = props.eventData?.interfaceLanguage; + const ns = "common"; + let translations; + if (eventLocale) { + const ns = "common"; + translations = await loadTranslations(eventLocale, ns); } - return ; + + if ((props as TeamTypePageProps)?.teamId) { + return eventLocale ? ( + + + + ) : ( + + ); + } + + return eventLocale ? ( + + + + ) : ( + + ); }; export default ServerPage; diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/embed/page.tsx b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/embed/page.tsx index fdfd8df197..f88dd8637a 100644 --- a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/embed/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/embed/page.tsx @@ -1,7 +1,10 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps as ServerPageProps } from "app/_types"; import { cookies, headers } from "next/headers"; +import { loadTranslations } from "@calcom/lib/server/i18n"; + import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps"; @@ -12,6 +15,18 @@ const getData = withEmbedSsrAppDir(getServerSideProps); const ServerPage = async ({ params, searchParams }: ServerPageProps) => { const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const props = await getData(context); + + const eventLocale = props.eventData?.interfaceLanguage; + if (eventLocale) { + const ns = "common"; + const translations = await loadTranslations(eventLocale, ns); + return ( + + + + ); + } + return ; }; diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx index 6f9436b8a7..abd581b2da 100644 --- a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx @@ -1,9 +1,11 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import { withAppDirSsr } from "app/WithAppDirSsr"; import type { PageProps } from "app/_types"; import { generateMeetingMetadata } from "app/_utils"; import { cookies, headers } from "next/headers"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { loadTranslations } from "@calcom/lib/server/i18n"; import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps"; @@ -54,6 +56,18 @@ const ServerPage = async ({ params, searchParams }: PageProps) => { const props = await getData( buildLegacyCtx(await headers(), await cookies(), await params, await searchParams) ); + + const eventLocale = props.eventData?.interfaceLanguage; + if (eventLocale) { + const ns = "common"; + const translations = await loadTranslations(eventLocale, ns); + return ( + + + + ); + } + return ; }; export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/booking/[uid]/embed/page.tsx b/apps/web/app/(use-page-wrapper)/booking/[uid]/embed/page.tsx index 0520153bf9..bf6437bd73 100644 --- a/apps/web/app/(use-page-wrapper)/booking/[uid]/embed/page.tsx +++ b/apps/web/app/(use-page-wrapper)/booking/[uid]/embed/page.tsx @@ -1,7 +1,10 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps as ServerPageProps } from "app/_types"; import { cookies, headers } from "next/headers"; +import { loadTranslations } from "@calcom/lib/server/i18n"; + import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import OldPage from "~/bookings/views/bookings-single-view"; @@ -15,6 +18,18 @@ const getEmbedData = withEmbedSsrAppDir(getServerSideProps); const ServerPage = async ({ params, searchParams }: ServerPageProps) => { const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const props = await getEmbedData(context); + + const eventLocale = props.eventType?.interfaceLanguage; + if (eventLocale) { + const ns = "common"; + const translations = await loadTranslations(eventLocale, ns); + return ( + + + + ); + } + return ; }; diff --git a/apps/web/app/(use-page-wrapper)/booking/[uid]/page.tsx b/apps/web/app/(use-page-wrapper)/booking/[uid]/page.tsx index 7ba494ac8c..00fe058a28 100644 --- a/apps/web/app/(use-page-wrapper)/booking/[uid]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/booking/[uid]/page.tsx @@ -1,9 +1,11 @@ +import { CustomI18nProvider } from "app/CustomI18nProvider"; import { withAppDirSsr } from "app/WithAppDirSsr"; import type { PageProps as _PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { cookies, headers } from "next/headers"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { loadTranslations } from "@calcom/lib/server/i18n"; import { BookingStatus } from "@calcom/prisma/enums"; import { buildLegacyCtx } from "@lib/buildLegacyCtx"; @@ -36,6 +38,18 @@ const getData = withAppDirSsr(getServerSideProps); const ServerPage = async ({ params, searchParams }: _PageProps) => { const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const props = await getData(context); + + const eventLocale = props.eventType?.interfaceLanguage; + if (eventLocale) { + const ns = "common"; + const translations = await loadTranslations(eventLocale, ns); + return ( + + + + ); + } + return ; }; export default ServerPage; diff --git a/apps/web/app/CustomI18nProvider.tsx b/apps/web/app/CustomI18nProvider.tsx new file mode 100644 index 0000000000..aacb30b266 --- /dev/null +++ b/apps/web/app/CustomI18nProvider.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { createContext, useMemo } from "react"; +import type { ReactNode } from "react"; + +type CustomI18nContextType = { + translations: Record; + ns: string; + locale: string; +}; + +export const CustomI18nContext = createContext(null); + +export function CustomI18nProvider({ + children, + translations, + locale, + ns, +}: CustomI18nContextType & { + children: ReactNode; +}) { + // Memoize the value to prevent re-renders unless the data changes + const value = useMemo( + () => ({ + translations, + locale, + ns, + }), + [locale, ns] + ); + + return {children}; +} diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index 608660aff0..78f712e0c6 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -26,6 +26,7 @@ export const getEventTypesFromDB = async (id: number) => { id: true, title: true, description: true, + interfaceLanguage: true, length: true, eventName: true, recurringEvent: true, diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 1d075be0cc..5831f1bc36 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -141,6 +141,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => title: eventData.title, users: eventHostsUserData, hidden: eventData.hidden, + interfaceLanguage: eventData.interfaceLanguage, }, booking, user: teamSlug, @@ -206,6 +207,7 @@ const getTeamWithEventsData = async ( hidden: true, disableCancelling: true, disableRescheduling: true, + interfaceLanguage: true, hosts: { take: 3, select: { diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index 3c79f4a642..c11b8a04c5 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -421,6 +421,145 @@ test.describe("Event Types tests", () => { await expect(offerSeatsToggle).toBeDisabled(); }); }); + + test.describe("Interface Language Tests", () => { + test.use({ + locale: "en", + }); + + test("by default the Interface language has 'Visitor's browser language' selected", async ({ + page, + users, + }) => { + await test.step("should create a en user", async () => { + const user = await users.create({ + locale: "en", + }); + await user.apiLogin(); + await page.goto("/event-types"); + await page.waitForSelector('[data-testid="event-types"]'); + }); + await test.step("should open first eventType and check Interface Language", async () => { + await gotoFirstEventType(page); + const interfaceLanguageValue = page + .getByTestId("event-interface-language") + .locator('div[class$="-singleValue"]'); + await expect(interfaceLanguageValue).toHaveText("Visitor's browser language"); + }); + }); + + test("user can change the interface language to any other language and the booking page should be rendered in that language", async ({ + page, + users, + }) => { + await test.step("should create a en user", async () => { + const user = await users.create({ + locale: "en", + }); + await user.apiLogin(); + await page.goto("/event-types"); + await page.waitForSelector('[data-testid="event-types"]'); + }); + + await test.step("should open first eventType and change Interface Language to Deutsche", async () => { + await gotoFirstEventType(page); + await page.getByTestId("event-interface-language").click(); + await page.locator(`text="Deutsch"`).click(); + await saveEventType(page); + }); + + await test.step("should open corresponding booking page and ensure language rendered is Deutsche", async () => { + await gotoBookingPage(page); + //expect the slot selection page to be rendered in 'Deutsch' + await expect(page.locator(`text="So"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Mo"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Di"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Mi"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Do"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Fr"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Sa"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="12 Std"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="24 Std"`).nth(0)).toBeVisible(); + + await selectFirstAvailableTimeSlotNextMonth(page); + //expect the booking inputs page to be rendered in 'Deutsch' + await expect(page.locator(`text="Ihr Name"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="E-Mail Adresse"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Zusätzliche Notizen"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="+ Weitere Gäste"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Zurück"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Bestätigen"`).nth(0)).toBeVisible(); + }); + + await test.step("should be able to book successfully and ensure success page is rendered in Deutsche", async () => { + await bookTimeSlot(page); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator(`text="Dieser Termin ist geplant"`).nth(0)).toBeVisible(); + }); + }); + + test("user locale setting is overridden by event type language setting for booking page", async ({ + page, + users, + }) => { + await test.step("should create a de user and ensure app is rendered in de", async () => { + const user = await users.create({ + locale: "de", + }); + await user.apiLogin(); + await page.goto("/event-types"); + await page.waitForSelector('[data-testid="event-types"]'); + { + const locator = page.getByText("Ereignistypen", { exact: true }).first(); // "general" + await expect(locator).toBeVisible(); + } + }); + + await test.step("should open first eventType and change Interface Language to Español", async () => { + await page.goto("/event-types"); + await page.waitForSelector('[data-testid="event-types"]'); + await gotoFirstEventType(page); + await page.getByTestId("event-interface-language").click(); + await page.getByTestId("select-option-es").click(); + await saveEventType(page); + }); + + await test.step("should go to booking page and verify the Interface language is Español", async () => { + await gotoBookingPage(page); + //expect the slot selection page to be rendered in 'Español' + await expect(page.locator(`text="dom"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="lun"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="mar"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="mié"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="jue"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="vie"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="sáb"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="12 h"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="24hs"`).nth(0)).toBeVisible(); + + await selectFirstAvailableTimeSlotNextMonth(page); + //expect the booking inputs page to be rendered in 'Español' + await expect(page.locator(`text="Tu Nombre"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Email"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Notas Adicionales"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Añadir invitados"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Atrás"`).nth(0)).toBeVisible(); + await expect(page.locator(`text="Confirmar"`).nth(0)).toBeVisible(); + + await bookTimeSlot(page); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + }); + + await test.step("ensure other components of the App is still rendered in de and not affected by setting eventType Interface Language to Español", async () => { + await page.goto("/event-types"); + await page.waitForSelector('[data-testid="event-types"]'); + { + const locator = page.getByText("Ereignistypen", { exact: true }).first(); // "general" + await expect(locator).toBeVisible(); + } + }); + }); + }); }); const selectAttendeePhoneNumber = async (page: Page) => { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a02c36b87f..8cafd6a522 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3118,6 +3118,8 @@ "could_not_find_slug_to_publish_org": "Could not find slug to publish the organization", "picklist": "Picklist", "most_cancelled_bookings": "Most Cancelled Bookings", + "interface_language": "Interface Language", + "visitors_browser_language": "Visitor's browser language", "name_or_email": "Name or Email", "salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists", "credit_purchase_failed": "Credit purchase failed. Please try again or contact support.", diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 464b454582..178c769c1c 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -61,6 +61,7 @@ export type BookerEvent = Pick< | "autoTranslateDescriptionEnabled" | "disableCancelling" | "disableRescheduling" + | "interfaceLanguage" > & { subsetOfUsers: BookerEventUser[]; showInstantEventConnectNowModal: boolean; diff --git a/packages/features/eventtypes/components/tabs/setup/EventSetupTab.tsx b/packages/features/eventtypes/components/tabs/setup/EventSetupTab.tsx index b5ed290036..92ddd55b7e 100644 --- a/packages/features/eventtypes/components/tabs/setup/EventSetupTab.tsx +++ b/packages/features/eventtypes/components/tabs/setup/EventSetupTab.tsx @@ -18,7 +18,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import { slugify } from "@calcom/lib/slugify"; import turndown from "@calcom/lib/turndownService"; -import { Skeleton } from "@calcom/ui/components/skeleton"; import classNames from "@calcom/ui/classNames"; import { Editor } from "@calcom/ui/components/editor"; import { TextAreaField } from "@calcom/ui/components/form"; @@ -26,6 +25,7 @@ import { Label } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form"; import { Select } from "@calcom/ui/components/form"; import { SettingsToggle } from "@calcom/ui/components/form"; +import { Skeleton } from "@calcom/ui/components/skeleton"; export type EventSetupTabCustomClassNames = { wrapper?: string; @@ -58,12 +58,23 @@ export type EventSetupTabProps = Pick< customClassNames?: EventSetupTabCustomClassNames; }; export const EventSetupTab = ( - props: EventSetupTabProps & { urlPrefix: string; hasOrgBranding: boolean; orgId?: number } + props: EventSetupTabProps & { + urlPrefix: string; + hasOrgBranding: boolean; + orgId?: number; + localeOptions?: { value: string; label: string }[]; + } ) => { const { t } = useLocale(); const isPlatform = useIsPlatform(); const formMethods = useFormContext(); const { eventType, team, urlPrefix, hasOrgBranding, customClassNames, orgId } = props; + + const interfaceLanguageOptions = + props.localeOptions && props.localeOptions.length > 0 + ? [{ label: t("visitors_browser_language"), value: "" }, ...props.localeOptions] + : []; + const [multipleDuration, setMultipleDuration] = useState( formMethods.getValues("metadata")?.multipleDuration ); @@ -161,6 +172,34 @@ export const EventSetupTab = ( /> )} + {!isPlatform && interfaceLanguageOptions.length > 0 && ( +
+ + {t("interface_language")} + {shouldLockIndicator("interfaceLanguage")} + + ( + + data-testid="event-interface-language" + className="capitalize" + options={interfaceLanguageOptions} + onChange={(option) => { + onChange(option?.value); + }} + value={interfaceLanguageOptions.find((option) => option.value === value)} + /> + )} + /> +
+ )} { id: true, title: true, description: true, + interfaceLanguage: true, eventName: true, slug: true, isInstantEvent: true, @@ -527,6 +528,7 @@ export const getPublicEvent = async ( assignAllTeamMembers: event.assignAllTeamMembers, disableCancelling: event.disableCancelling, disableRescheduling: event.disableRescheduling, + interfaceLanguage: event.interfaceLanguage, }; }; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 5f98411b6b..3d84a9ca70 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -72,6 +72,7 @@ export type FormValues = { eventTitle: string; eventName: string; slug: string; + interfaceLanguage: string | null; isInstantEvent: boolean; instantMeetingParameters: string[]; instantMeetingExpiryTimeOffsetInSeconds: number; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index c46be1f102..f310b70b7c 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -130,6 +130,7 @@ const commons = { includeNoShowInRRCalculation: false, useEventLevelSelectedCalendars: false, rrResetInterval: null, + interfaceLanguage: null, customReplyToEmail: null, }; diff --git a/packages/lib/hooks/useLocale.ts b/packages/lib/hooks/useLocale.ts index 8fcd3efe34..fed348eb38 100644 --- a/packages/lib/hooks/useLocale.ts +++ b/packages/lib/hooks/useLocale.ts @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext"; import { AppRouterI18nContext } from "@calcom/web/app/AppRouterI18nProvider"; +import { CustomI18nContext } from "@calcom/web/app/CustomI18nProvider"; type useLocaleReturnType = { i18n: i18n; @@ -32,10 +33,11 @@ const serverI18nInstances = new Map(); export const useLocale = (): useLocaleReturnType => { const appRouterContext = useContext(AppRouterI18nContext); + const customI18nContext = useContext(CustomI18nContext); const clientI18n = useClientLocale(); if (appRouterContext) { - const { translations, locale, ns } = appRouterContext; + const { translations, locale, ns } = customI18nContext ?? appRouterContext; const instanceKey = `${locale}-${ns}`; // Check if we already have an instance for this locale and namespace diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index 53ca0cfad2..a94ad4d256 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -7,6 +7,7 @@ export const eventTypeSelect = Prisma.validator()({ userId: true, metadata: true, description: true, + interfaceLanguage: true, hidden: true, slug: true, length: true, diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 15a102a23d..f70f140234 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -470,6 +470,7 @@ export class EventTypeRepository { title: true, slug: true, description: true, + interfaceLanguage: true, length: true, isInstantEvent: true, instantMeetingExpiryTimeOffsetInSeconds: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index c53c693eb3..34af319093 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -88,6 +88,7 @@ export const buildEventType = (eventType?: Partial): EventType => { title: faker.lorem.sentence(), slug: faker.lorem.slug(), description: faker.lorem.paragraph(), + interfaceLanguage: null, position: 1, isInstantEvent: false, instantMeetingParameters: [], diff --git a/packages/platform/atoms/event-types/wrappers/EventSetupTabWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventSetupTabWebWrapper.tsx index b424bac22f..fb9452a713 100644 --- a/packages/platform/atoms/event-types/wrappers/EventSetupTabWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventSetupTabWebWrapper.tsx @@ -4,6 +4,7 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid import type { EventSetupTabProps } from "@calcom/features/eventtypes/components/tabs/setup/EventSetupTab"; import { EventSetupTab } from "@calcom/features/eventtypes/components/tabs/setup/EventSetupTab"; import { WEBSITE_URL } from "@calcom/lib/constants"; +import { localeOptions } from "@calcom/lib/i18n"; const EventSetupTabWebWrapper = (props: EventSetupTabProps) => { const orgBranding = useOrgBranding(); @@ -16,6 +17,7 @@ const EventSetupTabWebWrapper = (props: EventSetupTabProps) => { urlPrefix={urlPrefix} hasOrgBranding={!!orgBranding} orgId={session.data?.user.org?.id} + localeOptions={localeOptions} {...props} /> ); diff --git a/packages/prisma/migrations/20250117091620_event_type_add_interface_language/migration.sql b/packages/prisma/migrations/20250117091620_event_type_add_interface_language/migration.sql new file mode 100644 index 0000000000..d27bfd9376 --- /dev/null +++ b/packages/prisma/migrations/20250117091620_event_type_add_interface_language/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "interfaceLanguage" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1582bfbf22..41eba56c23 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -69,23 +69,24 @@ model Host { } model EventType { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @zod.min(1) - title String + title String /// @zod.custom(imports.eventTypeSlug) - slug String - description String? - position Int @default(0) + slug String + description String? + interfaceLanguage String? + position Int @default(0) /// @zod.custom(imports.eventTypeLocations) - locations Json? + locations Json? /// @zod.min(1) - length Int - offsetStart Int @default(0) - hidden Boolean @default(false) - hosts Host[] - users User[] @relation("user_eventtype") - owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) - userId Int? + length Int + offsetStart Int @default(0) + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? profileId Int? profile Profile? @relation(fields: [profileId], references: [id], onDelete: Cascade) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 6fbaafa8dc..60930fe835 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -609,6 +609,7 @@ export const downloadLinkSchema = z.object({ export const allManagedEventTypeProps: { [k in keyof Omit]: true } = { title: true, description: true, + interfaceLanguage: true, isInstantEvent: true, instantMeetingParameters: true, instantMeetingExpiryTimeOffsetInSeconds: true,