diff --git a/.env.example b/.env.example index 3690d058f9..6d71f204ea 100644 --- a/.env.example +++ b/.env.example @@ -283,3 +283,9 @@ E2E_TEST_OIDC_USER_EMAIL= E2E_TEST_OIDC_USER_PASSWORD= # *********************************************************************************************************** + +# provide a value between 0 and 100 to ensure the percentage of traffic +# redirected from the legacy to the future pages +AB_TEST_BUCKET_PROBABILITY=50 +# whether we redirect to the future/event-types from event-types or not +APP_ROUTER_EVENT_TYPES_ENABLED=1 diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts index bb2115e47a..5e300cf0d6 100644 --- a/apps/web/abTest/middlewareFactory.ts +++ b/apps/web/abTest/middlewareFactory.ts @@ -1,16 +1,21 @@ import { getBucket } from "abTest/utils"; import type { NextMiddleware, NextRequest } from "next/server"; -import { NextResponse } from "next/server"; +import { NextResponse, URLPattern } from "next/server"; import z from "zod"; -const ROUTES: [RegExp, boolean][] = [ - [/^\/event-types$/, Boolean(process.env.APP_ROUTER_EVENT_TYPES_ENABLED)], -]; +const ROUTES: [URLPattern, boolean][] = [ + ["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const, +].map(([pathname, enabled]) => [ + new URLPattern({ + pathname, + }), + enabled, +]); const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override"; const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled"; -const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]).default("legacy"); +const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]); export const abTestMiddlewareFactory = (next: (req: NextRequest) => Promise>): NextMiddleware => @@ -21,7 +26,7 @@ export const abTestMiddlewareFactory = const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME); - const route = ROUTES.find(([regExp]) => regExp.test(pathname)) ?? null; + const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null; const enabled = route !== null ? route[1] || override : false; @@ -35,16 +40,29 @@ export const abTestMiddlewareFactory = if (!safeParsedBucket.success) { // cookie does not exist or it has incorrect value + const bucket = getBucket(); - const res = NextResponse.next(response); - res.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, getBucket(), { expires: 1000 * 60 * 30 }); // 30 min in ms - return res; + response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, { + expires: Date.now() + 1000 * 60 * 30, + httpOnly: true, + }); // 30 min in ms + + if (bucket === "legacy") { + return response; + } + + const url = req.nextUrl.clone(); + url.pathname = `future${pathname}/`; + + return NextResponse.rewrite(url, response); } - const bucketUrlPrefix = safeParsedBucket.data === "future" ? "future" : ""; + if (safeParsedBucket.data === "legacy") { + return response; + } const url = req.nextUrl.clone(); - url.pathname = `${bucketUrlPrefix}${pathname}/`; + url.pathname = `future${pathname}/`; return NextResponse.rewrite(url, response); }; diff --git a/apps/web/app/_trpc/HydrateClient.tsx b/apps/web/app/_trpc/HydrateClient.tsx new file mode 100644 index 0000000000..16cbeb6b9a --- /dev/null +++ b/apps/web/app/_trpc/HydrateClient.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { createHydrateClient } from "app/_trpc/createHydrateClient"; +import superjson from "superjson"; + +export const HydrateClient = createHydrateClient({ + transformer: superjson, +}); diff --git a/apps/web/app/_trpc/client.ts b/apps/web/app/_trpc/client.ts new file mode 100644 index 0000000000..6f1cb3a2eb --- /dev/null +++ b/apps/web/app/_trpc/client.ts @@ -0,0 +1,5 @@ +import type { AppRouter } from "@calcom/trpc/server/routers/_app"; + +import { createTRPCReact } from "@trpc/react-query"; + +export const trpc = createTRPCReact({}); diff --git a/apps/web/app/_trpc/createHydrateClient.tsx b/apps/web/app/_trpc/createHydrateClient.tsx new file mode 100644 index 0000000000..a281737896 --- /dev/null +++ b/apps/web/app/_trpc/createHydrateClient.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { type DehydratedState, Hydrate } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import type { DataTransformer } from "@trpc/server"; + +export function createHydrateClient(opts: { transformer?: DataTransformer }) { + return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) { + const { state, children } = props; + + const transformedState: DehydratedState = useMemo(() => { + if (opts.transformer) { + return opts.transformer.deserialize(state); + } + return state; + }, [state]); + + return {children}; + }; +} diff --git a/apps/web/app/_trpc/trpc-provider.tsx b/apps/web/app/_trpc/trpc-provider.tsx new file mode 100644 index 0000000000..f6d2ed2817 --- /dev/null +++ b/apps/web/app/_trpc/trpc-provider.tsx @@ -0,0 +1,128 @@ +import { type DehydratedState, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { HydrateClient } from "app/_trpc/HydrateClient"; +import { trpc } from "app/_trpc/client"; +import { useState } from "react"; +import superjson from "superjson"; + +import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink"; +import { httpLink } from "@calcom/trpc/client/links/httpLink"; +import { loggerLink } from "@calcom/trpc/client/links/loggerLink"; +import { splitLink } from "@calcom/trpc/client/links/splitLink"; + +const ENDPOINTS = [ + "admin", + "apiKeys", + "appRoutingForms", + "apps", + "auth", + "availability", + "appBasecamp3", + "bookings", + "deploymentSetup", + "eventTypes", + "features", + "insights", + "payments", + "public", + "saml", + "slots", + "teams", + "organizations", + "users", + "viewer", + "webhook", + "workflows", + "appsRouter", + "googleWorkspace", +] as const; +export type Endpoint = (typeof ENDPOINTS)[number]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const resolveEndpoint = (links: any) => { + // TODO: Update our trpc routes so they are more clear. + // This function parses paths like the following and maps them + // to the correct API endpoints. + // - viewer.me - 2 segment paths like this are for logged in requests + // - viewer.public.i18n - 3 segments paths can be public or authed + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (ctx: any) => { + const parts = ctx.op.path.split("."); + let endpoint; + let path = ""; + if (parts.length == 2) { + endpoint = parts[0] as keyof typeof links; + path = parts[1]; + } else { + endpoint = parts[1] as keyof typeof links; + path = parts.splice(2, parts.length - 2).join("."); + } + return links[endpoint]({ ...ctx, op: { ...ctx.op, path } }); + }; +}; + +export const TrpcProvider: React.FC<{ children: React.ReactNode; dehydratedState?: DehydratedState }> = ({ + children, + dehydratedState, +}) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { staleTime: 5000 } }, + }) + ); + const url = + typeof window !== "undefined" + ? "/api/trpc" + : process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}/api/trpc` + : `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`; + + const [trpcClient] = useState(() => + trpc.createClient({ + links: [ + // adds pretty logs to your console in development and logs errors in production + loggerLink({ + enabled: (opts) => + !!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error), + }), + splitLink({ + // check for context property `skipBatch` + condition: (op) => !!op.context.skipBatch, + // when condition is true, use normal request + true: (runtime) => { + const links = Object.fromEntries( + ENDPOINTS.map((endpoint) => [ + endpoint, + httpLink({ + url: `${url}/${endpoint}`, + })(runtime), + ]) + ); + return resolveEndpoint(links); + }, + // when condition is false, use batch request + false: (runtime) => { + const links = Object.fromEntries( + ENDPOINTS.map((endpoint) => [ + endpoint, + httpBatchLink({ + url: `${url}/${endpoint}`, + })(runtime), + ]) + ); + return resolveEndpoint(links); + }, + }), + ], + transformer: superjson, + }) + ); + + return ( + + + {dehydratedState ? {children} : children} + + + ); +}; diff --git a/apps/web/app/_utils.tsx b/apps/web/app/_utils.tsx new file mode 100644 index 0000000000..da2b389593 --- /dev/null +++ b/apps/web/app/_utils.tsx @@ -0,0 +1,40 @@ +import { type TFunction } from "i18next"; +import { headers } from "next/headers"; + +import { constructGenericImage } from "@calcom/lib/OgImages"; +import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants"; +import { getFixedT } from "@calcom/lib/server/getFixedT"; + +import { preparePageMetadata } from "@lib/metadata"; + +export const _generateMetadata = async ( + getTitle: (t: TFunction) => string, + getDescription: (t: TFunction) => string +) => { + const h = headers(); + const canonical = h.get("x-pathname") ?? ""; + const locale = h.get("x-locale") ?? "en"; + + const t = await getFixedT(locale, "common"); + + const title = getTitle(t); + const description = getDescription(t); + + const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL); + + const image = + SEO_IMG_OGIMG + + constructGenericImage({ + title, + description, + }); + + return preparePageMetadata({ + title, + canonical, + image, + description, + siteName: APP_NAME, + metadataBase, + }); +}; diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000000..b804d677ac --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,64 @@ +"use client"; + +/** + * Typescript class based component for custom-error + * @link https://nextjs.org/docs/advanced-features/custom-error-page + */ +import type { NextPage } from "next"; +import type { ErrorProps } from "next/error"; +import React from "react"; + +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { redactError } from "@calcom/lib/redactError"; + +import { ErrorPage } from "@components/error/error-page"; + +type NextError = Error & { digest?: string }; + +// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props +export type DefaultErrorProps = { + error: NextError; + reset: () => void; // A function to reset the error boundary +}; + +type AugmentedError = NextError | HttpError | null; + +type CustomErrorProps = { + err?: AugmentedError; + statusCode?: number; + message?: string; +} & Omit; + +const log = logger.getSubLogger({ prefix: ["[error]"] }); + +const CustomError: NextPage = (props) => { + const { error } = props; + let errorObject: CustomErrorProps = { + message: error.message, + err: error, + }; + + if (error instanceof HttpError) { + const redactedError = redactError(error); + errorObject = { + statusCode: error.statusCode, + title: redactedError.name, + message: redactedError.message, + err: { + ...redactedError, + ...error, + }, + }; + } + + // `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs + log.debug(`${error?.toString() ?? JSON.stringify(error)}`); + log.info("errorObject: ", errorObject); + + return ( + + ); +}; + +export default CustomError; diff --git a/apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx b/apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx new file mode 100644 index 0000000000..246bcc5c90 --- /dev/null +++ b/apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx @@ -0,0 +1,10 @@ +import EventTypes from "@pages/event-types"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("event_types_page_title"), + (t) => t("event_types_page_subtitle") + ); + +export default EventTypes; diff --git a/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx b/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx new file mode 100644 index 0000000000..7d78c3b422 --- /dev/null +++ b/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx @@ -0,0 +1,22 @@ +// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder +import { headers } from "next/headers"; +import { type ReactElement } from "react"; + +import { getLayout } from "@calcom/features/MainLayoutAppDir"; + +import PageWrapper from "@components/PageWrapperAppDir"; + +type WrapperWithLayoutProps = { + children: ReactElement; +}; + +export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) { + const h = headers(); + const nonce = h.get("x-nonce") ?? undefined; + + return ( + + {children} + + ); +} diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx new file mode 100644 index 0000000000..ecf7247dbb --- /dev/null +++ b/apps/web/app/global-error.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { type NextPage } from "next"; + +import CustomError, { type DefaultErrorProps } from "./error"; + +export const GlobalError: NextPage = (props) => { + return ( + + + + + + ); +}; + +export default GlobalError; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 23d224b6e1..f17543074f 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,84 +1,63 @@ -import type { Metadata } from "next"; -import { headers as nextHeaders, cookies as nextCookies } from "next/headers"; +import { dir } from "i18next"; +import { headers, cookies } from "next/headers"; import Script from "next/script"; import React from "react"; import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { prepareRootMetadata } from "@lib/metadata"; + import "../styles/globals.css"; -export const metadata: Metadata = { - icons: { - icon: [ - { - sizes: "32x32", - url: "/api/logo?type=favicon-32", - }, - { - sizes: "16x16", - url: "/api/logo?type=favicon-16", - }, - ], - apple: { - sizes: "180x180", - url: "/api/logo?type=apple-touch-icon", +export const generateMetadata = () => + prepareRootMetadata({ + twitterCreator: "@calcom", + twitterSite: "@calcom", + robots: { + index: false, + follow: false, }, - other: [ - { - url: "/safari-pinned-tab.svg", - rel: "mask-icon", - }, - ], - }, - manifest: "/site.webmanifest", - themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#f9fafb" }, - { media: "(prefers-color-scheme: dark)", color: "#1C1C1C" }, - ], - other: { - "msapplication-TileColor": "#000000", - }, -}; + }); -const getInitialProps = async ( - url: string, - headers: ReturnType, - cookies: ReturnType -) => { +const getInitialProps = async (url: string) => { const { pathname, searchParams } = new URL(url); const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null; const embedColorScheme = searchParams?.get("ui.color-scheme"); - // @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale - const newLocale = await getLocale({ headers, cookies }); - let direction = "ltr"; - - try { - const intlLocale = new Intl.Locale(newLocale); - // @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute - direction = intlLocale.textInfo?.direction; - } catch (e) { - console.error(e); - } + const req = { headers: headers(), cookies: cookies() }; + const newLocale = await getLocale(req); + const direction = dir(newLocale); return { isEmbed, embedColorScheme, locale: newLocale, direction }; }; +const getFallbackProps = () => ({ + locale: "en", + direction: "ltr", + isEmbed: false, + embedColorScheme: false, +}); + export default async function RootLayout({ children }: { children: React.ReactNode }) { - const headers = nextHeaders(); - const cookies = nextCookies(); + const h = headers(); - const fullUrl = headers.get("x-url") ?? ""; - const nonce = headers.get("x-csp") ?? ""; + const fullUrl = h.get("x-url") ?? ""; + const nonce = h.get("x-csp") ?? ""; + + const isSSG = !fullUrl; + + const { locale, direction, isEmbed, embedColorScheme } = isSSG + ? getFallbackProps() + : await getInitialProps(fullUrl); - const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies); return ( + style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined} + data-nextjs-router="app"> {!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && ( // eslint-disable-next-line @next/next/no-sync-scripts diff --git a/apps/web/components/PageWrapperAppDir.tsx b/apps/web/components/PageWrapperAppDir.tsx index e6541df55a..414e4009bc 100644 --- a/apps/web/components/PageWrapperAppDir.tsx +++ b/apps/web/components/PageWrapperAppDir.tsx @@ -1,5 +1,6 @@ "use client"; +import { type DehydratedState } from "@tanstack/react-query"; import type { SSRConfig } from "next-i18next"; import { Inter } from "next/font/google"; import localFont from "next/font/local"; @@ -10,7 +11,6 @@ import type { ReactNode } from "react"; import "@calcom/embed-core/src/embed-iframe"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; -import { trpc } from "@calcom/trpc/react"; import type { AppProps } from "@lib/app-providers-app-dir"; import AppProviders from "@lib/app-providers-app-dir"; @@ -29,13 +29,14 @@ const calFont = localFont({ }); export type PageWrapperProps = Readonly<{ - getLayout: (page: React.ReactElement) => ReactNode; + getLayout: ((page: React.ReactElement) => ReactNode) | null; children: React.ReactElement; requiresLicense: boolean; - isThemeSupported: boolean; - isBookingPage: boolean; nonce: string | undefined; themeBasis: string | null; + dehydratedState?: DehydratedState; + isThemeSupported?: boolean; + isBookingPage?: boolean; i18n?: SSRConfig; }>; @@ -85,4 +86,4 @@ function PageWrapper(props: PageWrapperProps) { ); } -export default trpc.withTRPC(PageWrapper); +export default PageWrapper; diff --git a/apps/web/lib/app-providers-app-dir.tsx b/apps/web/lib/app-providers-app-dir.tsx index 95dbfb077a..2b2d57d2ea 100644 --- a/apps/web/lib/app-providers-app-dir.tsx +++ b/apps/web/lib/app-providers-app-dir.tsx @@ -1,4 +1,5 @@ import { TooltipProvider } from "@radix-ui/react-tooltip"; +import { TrpcProvider } from "app/_trpc/trpc-provider"; import { dir } from "i18next"; import type { Session } from "next-auth"; import { SessionProvider, useSession } from "next-auth/react"; @@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => { const isBookingPage = useIsBookingPage(); const RemainingProviders = ( - - - - - {/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */} - - - - {props.children} - - - - - - - + + + + + + {/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */} + + + + {props.children} + + + + + + + + ); if (isBookingPage) { diff --git a/apps/web/lib/metadata.ts b/apps/web/lib/metadata.ts new file mode 100644 index 0000000000..db37af3443 --- /dev/null +++ b/apps/web/lib/metadata.ts @@ -0,0 +1,83 @@ +import type { Metadata } from "next"; + +import { truncateOnWord } from "@calcom/lib/text"; + +type RootMetadataRecipe = Readonly<{ + twitterCreator: string; + twitterSite: string; + robots: { + index: boolean; + follow: boolean; + }; +}>; + +export type PageMetadataRecipe = Readonly<{ + title: string; + canonical: string; + image: string; + description: string; + siteName: string; + metadataBase: URL; +}>; + +export const prepareRootMetadata = (recipe: RootMetadataRecipe): Metadata => ({ + icons: { + icon: "/favicon.icon", + apple: "/api/logo?type=apple-touch-icon", + other: [ + { + rel: "icon-mask", + url: "/safari-pinned-tab.svg", + // @ts-expect-error TODO available in the never Next.js version + color: "#000000", + }, + { + url: "/api/logo?type=favicon-16", + sizes: "16x16", + type: "image/png", + }, + { + url: "/api/logo?type=favicon-32", + sizes: "32x32", + type: "image/png", + }, + ], + }, + manifest: "/site.webmanifest", + viewport: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0", + robots: recipe.robots, + other: { + "application-TileColor": "#ff0000", + }, + themeColor: [ + { + media: "(prefers-color-scheme: light)", + color: "#f9fafb", + }, + { + media: "(prefers-color-scheme: dark)", + color: "#1C1C1C", + }, + ], + twitter: { + site: recipe.twitterSite, + creator: recipe.twitterCreator, + card: "summary_large_image", + }, +}); + +export const preparePageMetadata = (recipe: PageMetadataRecipe): Metadata => ({ + title: recipe.title, + alternates: { + canonical: recipe.canonical, + }, + openGraph: { + description: truncateOnWord(recipe.description, 158), + url: recipe.canonical, + type: "website", + siteName: recipe.siteName, + title: recipe.title, + images: [recipe.image], + }, + metadataBase: recipe.metadataBase, +}); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index b3fc0e2a83..d14b59cb45 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,13 +1,16 @@ import { get } from "@vercel/edge-config"; import { collectEvents } from "next-collect/server"; -import type { NextMiddleware } from "next/server"; +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry"; import { csp } from "@lib/csp"; -const middleware: NextMiddleware = async (req) => { +import { abTestMiddlewareFactory } from "./abTest/middlewareFactory"; + +const middleware = async (req: NextRequest): Promise> => { const url = req.nextUrl; const requestHeaders = new Headers(req.headers); @@ -61,6 +64,12 @@ const middleware: NextMiddleware = async (req) => { requestHeaders.set("x-csp-enforce", "true"); } + requestHeaders.set("x-pathname", url.pathname); + + const locale = await getLocale(req); + + requestHeaders.set("x-locale", locale); + return NextResponse.next({ request: { headers: requestHeaders, @@ -90,11 +99,13 @@ export const config = { * Paths required by routingForms.handle */ "/apps/routing_forms/:path*", + "/event-types", + "/future/event-types/", ], }; export default collectEvents({ - middleware, + middleware: abTestMiddlewareFactory(middleware), ...nextCollectBasicSettings, cookieName: "__clnds", extend: extendEventData, diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 7bde895793..3b6fcb599f 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useAutoAnimate } from "@formkit/auto-animate/react"; import type { User } from "@prisma/client"; import { Trans } from "next-i18next"; diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index 1bf95b69c3..0358f4ee9f 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { randomString } from "@calcom/lib/random"; import { test } from "./lib/fixtures"; +import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes"; import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; test.describe.configure({ mode: "parallel" }); +test.describe("Event Types A/B tests", () => { + test("should point to the /future/event-types page", async ({ page, users, context }) => { + await context.addCookies([ + { + name: "x-calcom-future-routes-override", + value: "1", + url: "http://localhost:3000", + }, + ]); + const user = await users.create(); + + await user.apiLogin(); + + await page.goto("/event-types"); + + await page.waitForLoadState(); + + const dataNextJsRouter = await page.evaluate(() => + window.document.documentElement.getAttribute("data-nextjs-router") + ); + + expect(dataNextJsRouter).toEqual("app"); + + const locator = page.getByRole("heading", { name: "Event Types" }); + + await expect(locator).toBeVisible(); + }); +}); + test.describe("Event Types tests", () => { - test.describe("user", () => { + testBothFutureAndLegacyRoutes.describe("user", () => { test.beforeEach(async ({ page, users }) => { const user = await users.create(); await user.apiLogin(); await page.goto("/event-types"); + // We wait until loading is finished await page.waitForSelector('[data-testid="event-types"]'); }); diff --git a/apps/web/playwright/lib/future-legacy-routes.ts b/apps/web/playwright/lib/future-legacy-routes.ts new file mode 100644 index 0000000000..d9a76b4eda --- /dev/null +++ b/apps/web/playwright/lib/future-legacy-routes.ts @@ -0,0 +1,33 @@ +import { test } from "./fixtures"; + +export type RouteVariant = "future" | "legacy"; + +const routeVariants = ["future", "legacy"]; + +/** + * Small wrapper around test.describe(). + * When using testbothFutureLegacyRoutes.describe() instead of test.describe(), this will run the specified + * tests twice. One with the pages route, and one with the new app dir "future" route. It will also add the route variant + * name to the test name for easier debugging. + * Finally it also adds a parameter routeVariant to your testBothFutureAndLegacyRoutes.describe() callback, which + * can be used to do any conditional rendering in the test for a specific route variant (should be as little + * as possible). + * + * See apps/web/playwright/event-types.e2e.ts for an example. + */ +export const testBothFutureAndLegacyRoutes = { + describe: (testName: string, testFn: (routeVariant: RouteVariant) => void) => { + routeVariants.forEach((routeVariant) => { + test.describe(`${testName} -- ${routeVariant}`, () => { + if (routeVariant === "future") { + test.beforeEach(({ context }) => { + context.addCookies([ + { name: "x-calcom-future-routes-override", value: "1", url: "http://localhost:3000" }, + ]); + }); + } + testFn(routeVariant as RouteVariant); + }); + }); + }, +}; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ca5be263cd..831bb9eb69 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -5,6 +5,7 @@ "paths": { "~/*": ["modules/*"], "@components/*": ["components/*"], + "@pages/*": ["pages/*"], "@lib/*": ["lib/*"], "@server/*": ["server/*"], "@prisma/client/*": ["@calcom/prisma/client/*"] diff --git a/packages/features/MainLayoutAppDir.tsx b/packages/features/MainLayoutAppDir.tsx new file mode 100644 index 0000000000..acf4a2b522 --- /dev/null +++ b/packages/features/MainLayoutAppDir.tsx @@ -0,0 +1,19 @@ +"use client"; + +import type { ComponentProps } from "react"; +import React from "react"; + +import Shell from "@calcom/features/shell/Shell"; + +export default function MainLayout({ + children, + ...rest +}: { children: React.ReactNode } & ComponentProps) { + return ( + + {children} + + ); +} + +export const getLayout = (page: React.ReactElement) => {page}; diff --git a/packages/features/auth/lib/getLocale.ts b/packages/features/auth/lib/getLocale.ts index 6e6c2795a6..1ea67ffafd 100644 --- a/packages/features/auth/lib/getLocale.ts +++ b/packages/features/auth/lib/getLocale.ts @@ -2,9 +2,11 @@ import { parse } from "accept-language-parser"; import { lookup } from "bcp-47-match"; import type { GetTokenParams } from "next-auth/jwt"; import { getToken } from "next-auth/jwt"; +import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; +import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; //@ts-expect-error no type definitions -import { i18n } from "@calcom/web/next-i18next.config"; +import { i18n } from "@calcom/config/next-i18next.config"; /** * This is a slimmed down version of the `getServerSession` function from @@ -17,9 +19,16 @@ import { i18n } from "@calcom/web/next-i18next.config"; * token has expired (30 days). This should be fine as we call `/auth/session` * frequently enough on the client-side to keep the session alive. */ -export const getLocale = async (req: GetTokenParams["req"]): Promise => { +export const getLocale = async ( + req: + | GetTokenParams["req"] + | { + cookies: ReadonlyRequestCookies; + headers: ReadonlyHeaders; + } +): Promise => { const token = await getToken({ - req, + req: req as GetTokenParams["req"], }); const tokenLocale = token?.["locale"]; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index cc5fb76143..1034db6d0d 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -106,4 +106,11 @@ export const APP_CREDENTIAL_SHARING_ENABLED = export const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; export const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; -export const AB_TEST_BUCKET_PROBABILITY = Number(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10"); + +const defaultOnNaN = (testedValue: number, defaultValue: number) => + !Number.isNaN(testedValue) ? testedValue : defaultValue; + +export const AB_TEST_BUCKET_PROBABILITY = defaultOnNaN( + parseInt(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10", 10), + 10 +); diff --git a/packages/lib/server/getFixedT.ts b/packages/lib/server/getFixedT.ts new file mode 100644 index 0000000000..692c1c8dcc --- /dev/null +++ b/packages/lib/server/getFixedT.ts @@ -0,0 +1,23 @@ +import i18next from "i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; + +//@ts-expect-error no type definitions +import config from "@calcom/web/next-i18next.config"; + +export const create = async (locale: string, ns: string) => { + const { _nextI18Next } = await serverSideTranslations(locale, [ns], config); + + const _i18n = i18next.createInstance(); + _i18n.init({ + lng: locale, + resources: _nextI18Next?.initialI18nStore, + fallbackLng: _nextI18Next?.userConfig?.i18n.defaultLocale, + }); + return _i18n; +}; + +export const getFixedT = async (locale: string, ns: string) => { + const i18n = await create(locale, ns); + + return i18n.getFixedT(locale, ns); +};