diff --git a/.env.example b/.env.example index 95eddc71ac..c0ecaa4d65 100644 --- a/.env.example +++ b/.env.example @@ -294,4 +294,6 @@ E2E_TEST_OIDC_USER_PASSWORD= AB_TEST_BUCKET_PROBABILITY=50 # whether we redirect to the future/event-types from event-types or not APP_ROUTER_EVENT_TYPES_ENABLED=1 -APP_ROUTER_SETTINGS_ADMIN_ENABLED=1 \ No newline at end of file +APP_ROUTER_SETTINGS_ADMIN_ENABLED=1 +APP_ROUTER_APPS_SLUG_ENABLED=1 +APP_ROUTER_APPS_SLUG_SETUP_ENABLED=1 diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts index 704e5decfb..596a8b3035 100644 --- a/apps/web/abTest/middlewareFactory.ts +++ b/apps/web/abTest/middlewareFactory.ts @@ -6,6 +6,8 @@ import z from "zod"; const ROUTES: [URLPattern, boolean][] = [ ["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const, ["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const, + ["/apps/:slug", Boolean(process.env.APP_ROUTER_APPS_SLUG_ENABLED)] as const, + ["/apps/:slug/setup", Boolean(process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED)] as const, ].map(([pathname, enabled]) => [ new URLPattern({ pathname, diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx new file mode 100644 index 0000000000..918ae3fa16 --- /dev/null +++ b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx @@ -0,0 +1,15 @@ +import { type ReactElement } from "react"; + +import PageWrapper from "@components/PageWrapperAppDir"; + +type EventTypesLayoutProps = { + children: ReactElement; +}; + +export default function Layout({ children }: EventTypesLayoutProps) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx new file mode 100644 index 0000000000..ec1121c949 --- /dev/null +++ b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx @@ -0,0 +1,126 @@ +import AppPage from "@pages/apps/[slug]/index"; +import { Prisma } from "@prisma/client"; +import { _generateMetadata } from "app/_utils"; +import fs from "fs"; +import matter from "gray-matter"; +import { notFound } from "next/navigation"; +import path from "path"; +import { z } from "zod"; + +import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; +import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath"; +import { APP_NAME, IS_PRODUCTION } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +const sourceSchema = z.object({ + content: z.string(), + data: z.object({ + description: z.string().optional(), + items: z + .array( + z.union([ + z.string(), + z.object({ + iframe: z.object({ src: z.string() }), + }), + ]) + ) + .optional(), + }), +}); + +export const generateMetadata = async ({ params }: { params: Record }) => { + const { data } = await getPageProps({ params }); + + return await _generateMetadata( + () => `${data.name} | ${APP_NAME}`, + () => data.description + ); +}; + +export const generateStaticParams = async () => { + try { + const appStore = await prisma.app.findMany({ select: { slug: true } }); + return appStore.map(({ slug }) => ({ slug })); + } catch (e: unknown) { + if (e instanceof Prisma.PrismaClientInitializationError) { + // Database is not available at build time, but that's ok – we fall back to resolving paths on demand + } else { + throw e; + } + } + + return []; +}; + +const getPageProps = async ({ params }: { params: Record }) => { + if (typeof params?.slug !== "string") { + notFound(); + } + + const appMeta = await getAppWithMetadata({ + slug: params?.slug, + }); + + const appFromDb = await prisma.app.findUnique({ + where: { slug: params.slug.toLowerCase() }, + }); + + const isAppAvailableInFileSystem = appMeta; + const isAppDisabled = isAppAvailableInFileSystem && (!appFromDb || !appFromDb.enabled); + + if (!IS_PRODUCTION && isAppDisabled) { + return { + isAppDisabled: true as const, + data: { + ...appMeta, + }, + }; + } + + if (!appFromDb || !appMeta || isAppDisabled) { + notFound(); + } + + const isTemplate = appMeta.isTemplate; + const appDirname = path.join(isTemplate ? "templates" : "", appFromDb.dirName); + const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/DESCRIPTION.md`); + const postFilePath = path.join(README_PATH); + let source = ""; + + try { + source = fs.readFileSync(postFilePath).toString(); + source = source.replace(/{DESCRIPTION}/g, appMeta.description); + } catch (error) { + /* If the app doesn't have a README we fallback to the package description */ + console.log(`No DESCRIPTION.md provided for: ${appDirname}`); + source = appMeta.description; + } + + const result = matter(source); + const { content, data } = sourceSchema.parse({ content: result.content, data: result.data }); + if (data.items) { + data.items = data.items.map((item) => { + if (typeof item === "string") { + return getAppAssetFullPath(item, { + dirName: appMeta.dirName, + isTemplate: appMeta.isTemplate, + }); + } + return item; + }); + } + return { + isAppDisabled: false as const, + source: { content, data }, + data: appMeta, + }; +}; + +export default async function Page({ params }: { params: Record }) { + const pageProps = await getPageProps({ params }); + + return ; +} + +export const dynamic = "force-static"; diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx new file mode 100644 index 0000000000..ce6abc75ba --- /dev/null +++ b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx @@ -0,0 +1,36 @@ +import SetupPage from "@pages/apps/[slug]/setup"; +import { _generateMetadata } from "app/_utils"; +import type { GetServerSidePropsContext } from "next"; +import { cookies, headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; + +import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps"; +import { APP_NAME } from "@calcom/lib/constants"; + +export const generateMetadata = async ({ params }: { params: Record }) => { + return await _generateMetadata( + () => `${params.slug} | ${APP_NAME}`, + () => "" + ); +}; + +const getPageProps = async ({ params }: { params: Record }) => { + const req = { headers: headers(), cookies: cookies() }; + + const result = await getServerSideProps({ params, req } as unknown as GetServerSidePropsContext); + + if (!result || "notFound" in result) { + notFound(); + } + + if ("redirect" in result) { + redirect(result.redirect.destination); + } + + return result.props; +}; + +export default async function Page({ params }: { params: Record }) { + const pageProps = await getPageProps({ params }); + return ; +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 6640a2b634..40f1705315 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -103,6 +103,12 @@ export const config = { "/future/event-types/", "/settings/admin/:path*", "/future/settings/admin/:path*", + + "/apps/:slug/", + "/future/apps/:slug/", + + "/apps/:slug/setup/", + "/future/apps/:slug/setup/", ], }; diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx index da41a33402..27bd8dbdfb 100644 --- a/apps/web/pages/apps/[slug]/index.tsx +++ b/apps/web/pages/apps/[slug]/index.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Prisma } from "@prisma/client"; import fs from "fs"; import matter from "gray-matter"; diff --git a/apps/web/pages/apps/[slug]/setup.tsx b/apps/web/pages/apps/[slug]/setup.tsx index 31d8c78a11..9dd65d9faf 100644 --- a/apps/web/pages/apps/[slug]/setup.tsx +++ b/apps/web/pages/apps/[slug]/setup.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { InferGetServerSidePropsType } from "next"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; diff --git a/apps/web/playwright/ab-tests-redirect.e2e.ts b/apps/web/playwright/ab-tests-redirect.e2e.ts new file mode 100644 index 0000000000..feb64ac1eb --- /dev/null +++ b/apps/web/playwright/ab-tests-redirect.e2e.ts @@ -0,0 +1,61 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("apps/ A/B tests", () => { + test("should point to the /future/apps/[slug]", 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("/apps/telegram"); + + 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: "Telegram" }); + + await expect(locator).toBeVisible(); + }); + + test("should point to the /future/apps/[slug]/setup", 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("/apps/apple-calendar/setup"); + + 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: "Connect to Apple Server" }); + + await expect(locator).toBeVisible(); + }); +}); diff --git a/turbo.json b/turbo.json index a91fc56c0d..100cd41c08 100644 --- a/turbo.json +++ b/turbo.json @@ -198,6 +198,8 @@ "ALLOWED_HOSTNAMES", "ANALYZE", "API_KEY_PREFIX", + "APP_ROUTER_APPS_SLUG_ENABLED", + "APP_ROUTER_APPS_SLUG_SETUP_ENABLED", "APP_ROUTER_EVENT_TYPES_ENABLED", "APP_ROUTER_SETTINGS_ADMIN_ENABLED", "APP_USER_NAME",