diff --git a/.env.example b/.env.example index c0ecaa4d653fc5..dd9aa60e05062d 100644 --- a/.env.example +++ b/.env.example @@ -297,3 +297,7 @@ APP_ROUTER_EVENT_TYPES_ENABLED=1 APP_ROUTER_SETTINGS_ADMIN_ENABLED=1 APP_ROUTER_APPS_SLUG_ENABLED=1 APP_ROUTER_APPS_SLUG_SETUP_ENABLED=1 +# whether we redirect to the future/apps/categories from /apps/categories or not +APP_ROUTER_APPS_CATEGORIES_ENABLED=1 +# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not +APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=1 diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts index 596a8b3035ae0a..76be7e14472e29 100644 --- a/apps/web/abTest/middlewareFactory.ts +++ b/apps/web/abTest/middlewareFactory.ts @@ -6,8 +6,10 @@ 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, + ["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const, + ["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const, + ["/apps/categories", process.env.APP_ROUTER_APPS_CATEGORIES_ENABLED === "1"] as const, + ["/apps/categories/:category", process.env.APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED === "1"] as const, ].map(([pathname, enabled]) => [ new URLPattern({ pathname, diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/layout.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/layout.tsx new file mode 100644 index 00000000000000..dfccdf2d777b16 --- /dev/null +++ b/apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/layout.tsx @@ -0,0 +1,19 @@ +import { headers } from "next/headers"; +import { type ReactElement } from "react"; + +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/future/(individual-page-wrapper)/apps/categories/[category]/page.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/page.tsx new file mode 100644 index 00000000000000..50f504cef3ac8a --- /dev/null +++ b/apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/page.tsx @@ -0,0 +1,73 @@ +import CategoryPage from "@pages/apps/categories/[category]"; +import { Prisma } from "@prisma/client"; +import { _generateMetadata } from "app/_utils"; +import { notFound } from "next/navigation"; +import z from "zod"; + +import { getAppRegistry } from "@calcom/app-store/_appRegistry"; +import { APP_NAME } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import { AppCategories } from "@calcom/prisma/enums"; + +export const generateMetadata = async () => { + return await _generateMetadata( + () => `${APP_NAME} | ${APP_NAME}`, + () => "" + ); +}; + +export const getStaticParams = async () => { + const paths = Object.keys(AppCategories); + + try { + await prisma.$queryRaw`SELECT 1`; + } catch (e: unknown) { + if (e instanceof Prisma.PrismaClientInitializationError) { + // Database is not available at build time. Make sure we fall back to building these pages on demand + return []; + } else { + throw e; + } + } + + return paths.map((category) => ({ category })); +}; + +const querySchema = z.object({ + category: z.nativeEnum(AppCategories), +}); + +const getPageProps = async ({ params }: { params: Record }) => { + const p = querySchema.safeParse(params); + + if (!p.success) { + return notFound(); + } + + const appQuery = await prisma.app.findMany({ + where: { + categories: { + has: p.data.category, + }, + }, + select: { + slug: true, + }, + }); + + const dbAppsSlugs = appQuery.map((category) => category.slug); + + const appStore = await getAppRegistry(); + + const apps = appStore.filter((app) => dbAppsSlugs.includes(app.slug)); + return { + apps, + }; +}; + +export default async function Page({ params }: { params: Record }) { + const { apps } = await getPageProps({ params }); + return ; +} + +export const dynamic = "force-static"; diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/categories/page.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/categories/page.tsx new file mode 100644 index 00000000000000..c0d6c3d15e714e --- /dev/null +++ b/apps/web/app/future/(individual-page-wrapper)/apps/categories/page.tsx @@ -0,0 +1,56 @@ +import LegacyPage from "@pages/apps/categories/index"; +import { ssrInit } from "app/_trpc/ssrInit"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { APP_NAME } from "@calcom/lib/constants"; + +import PageWrapper from "@components/PageWrapperAppDir"; + +export const generateMetadata = async () => { + return await _generateMetadata( + () => `Categories | ${APP_NAME}`, + () => "" + ); +}; + +async function getPageProps() { + const ssr = await ssrInit(); + const req = { headers: headers(), cookies: cookies() }; + + // @ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest | IncomingMessage + const session = await getServerSession({ req }); + + let appStore; + if (session?.user?.id) { + appStore = await getAppRegistryWithCredentials(session.user.id); + } else { + appStore = await getAppRegistry(); + } + + const categories = appStore.reduce((c, app) => { + for (const category of app.categories) { + c[category] = c[category] ? c[category] + 1 : 1; + } + return c; + }, {} as Record); + + return { + categories: Object.entries(categories).map(([name, count]) => ({ name, count })), + dehydratedState: await ssr.dehydrate(), + }; +} + +export default async function Page() { + const props = await getPageProps(); + const h = headers(); + const nonce = h.get("x-nonce") ?? undefined; + + return ( + + + + ); +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 40f17053153197..fa4da2d6807e23 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -103,12 +103,14 @@ 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/", + "/apps/categories/", + "/future/apps/categories/", + "/apps/categories/:category/", + "/future/apps/categories/:category/", ], }; diff --git a/apps/web/pages/apps/categories/[category].tsx b/apps/web/pages/apps/categories/[category].tsx index 81de5e70c86331..0cad97bfa1016d 100644 --- a/apps/web/pages/apps/categories/[category].tsx +++ b/apps/web/pages/apps/categories/[category].tsx @@ -1,3 +1,5 @@ +"use client"; + import { Prisma } from "@prisma/client"; import type { GetStaticPropsContext, InferGetStaticPropsType } from "next"; import Link from "next/link"; diff --git a/apps/web/pages/apps/categories/index.tsx b/apps/web/pages/apps/categories/index.tsx index 7b40ade47b0a2e..1012640fc8d71f 100644 --- a/apps/web/pages/apps/categories/index.tsx +++ b/apps/web/pages/apps/categories/index.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; @@ -13,7 +15,7 @@ import PageWrapper from "@components/PageWrapper"; import { ssrInit } from "@server/lib/ssr"; -export default function Apps({ categories }: inferSSRProps) { +export default function Apps({ categories }: Omit, "trpcState">) { const { t, isLocaleReady } = useLocale(); return ( diff --git a/apps/web/playwright/ab-tests-redirect.e2e.ts b/apps/web/playwright/ab-tests-redirect.e2e.ts index feb64ac1eb8db5..58c474228f17ab 100644 --- a/apps/web/playwright/ab-tests-redirect.e2e.ts +++ b/apps/web/playwright/ab-tests-redirect.e2e.ts @@ -58,4 +58,58 @@ test.describe("apps/ A/B tests", () => { await expect(locator).toBeVisible(); }); -}); + + test("should point to the /future/apps/categories", 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/categories"); + + await page.waitForLoadState(); + + const dataNextJsRouter = await page.evaluate(() => + window.document.documentElement.getAttribute("data-nextjs-router") + ); + + expect(dataNextJsRouter).toEqual("app"); + + const locator = page.getByTestId("app-store-category-messaging"); + + await expect(locator).toBeVisible(); + }); + + test("should point to the /future/apps/categories/[category]", 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/categories/messaging"); + + await page.waitForLoadState(); + + const dataNextJsRouter = await page.evaluate(() => + window.document.documentElement.getAttribute("data-nextjs-router") + ); + + expect(dataNextJsRouter).toEqual("app"); + + const locator = page.getByText(/messaging apps/i); + + await expect(locator).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/turbo.json b/turbo.json index 100cd41c08a091..f6a0f7145b0a5a 100644 --- a/turbo.json +++ b/turbo.json @@ -198,6 +198,8 @@ "ALLOWED_HOSTNAMES", "ANALYZE", "API_KEY_PREFIX", + "APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED", + "APP_ROUTER_APPS_CATEGORIES_ENABLED", "APP_ROUTER_APPS_SLUG_ENABLED", "APP_ROUTER_APPS_SLUG_SETUP_ENABLED", "APP_ROUTER_EVENT_TYPES_ENABLED",