diff --git a/.env.example b/.env.example index 1cc4f37308..6d71f204ea 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ CALCOM_LICENSE_KEY= DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +INSIGHTS_DATABASE_URL= # Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy # Cold boots will be faster and you'll be able to scale your DB independently of your app. @@ -106,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= NEXT_PUBLIC_FRESHCHAT_HOST= +# Google OAuth credentials +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` above +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance +# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications +GOOGLE_LOGIN_ENABLED=false + +# - GOOGLE CALENDAR/MEET/LOGIN +# Needed to enable Google Calendar integration and Login with Google +# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS= + # Inbox to send user feedback SEND_FEEDBACK_EMAIL= @@ -236,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL= E2E_TEST_APPLE_CALENDAR_EMAIL="" E2E_TEST_APPLE_CALENDAR_PASSWORD="" +# - CALCOM QA ACCOUNT +# Used for E2E tests on Cal.com that require 3rd party integrations +E2E_TEST_CALCOM_QA_EMAIL="qa@example.com" +# Replace with your own password +E2E_TEST_CALCOM_QA_PASSWORD="password" + # - APP CREDENTIAL SYNC *********************************************************************************** # Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations # Under settings/admin/apps ensure that all app secrets are set the same as the parent application @@ -263,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/.github/workflows/pr-assign-team-label.yml b/.github/workflows/pr-assign-team-label.yml index f6c02d1fd5..ecb601f75c 100644 --- a/.github/workflows/pr-assign-team-label.yml +++ b/.github/workflows/pr-assign-team-label.yml @@ -13,4 +13,4 @@ jobs: with: repo-token: ${{ secrets.GH_ACCESS_TOKEN }} organization-name: calcom - ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" + ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" diff --git a/apps/api/lib/helpers/customPrisma.ts b/apps/api/lib/helpers/customPrisma.ts index db3ffe0c2f..926c7979cf 100644 --- a/apps/api/lib/helpers/customPrisma.ts +++ b/apps/api/lib/helpers/customPrisma.ts @@ -1,7 +1,7 @@ import type { NextMiddleware } from "next-api-middleware"; import { CONSOLE_URL } from "@calcom/lib/constants"; -import prisma, { customPrisma } from "@calcom/prisma"; +import { customPrisma } from "@calcom/prisma"; const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL; @@ -12,41 +12,32 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => { } = req; // If no custom api Id is provided, attach to request the regular cal.com prisma client. if (!key) { - req.prisma = prisma; + req.prisma = customPrisma(); await next(); return; } - try { - // If we have a key, we check if the deployment matching the key, has a databaseUrl value set. - const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`) - .then((res) => res.json()) - .then((res) => res.databaseUrl); + // If we have a key, we check if the deployment matching the key, has a databaseUrl value set. + const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`) + .then((res) => res.json()) + .then((res) => res.databaseUrl); - if (!databaseUrl) { - res.status(400).json({ error: "no databaseUrl set up at your instance yet" }); - return; - } - req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } }); - /* @note: + if (!databaseUrl) { + res.status(400).json({ error: "no databaseUrl set up at your instance yet" }); + return; + } + req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } }); + /* @note: In order to skip verifyApiKey for customPrisma requests, we pass isAdmin true, and userId 0, if we detect them later, we skip verifyApiKey logic and pass onto next middleware instead. */ - req.isAdmin = true; - req.isCustomPrisma = true; - // We don't need the key from here and on. Prevents unrecognized key errors. - delete req.query.key; - await next(); - await req.prisma.$disconnect(); - // @ts-expect-error testing - delete req.prisma; - } catch (err) { - if (req.prisma) { - await req.prisma.$disconnect(); - // @ts-expect-error testing - delete req.prisma; - } - throw err; - } + req.isAdmin = true; + req.isCustomPrisma = true; + // We don't need the key from here and on. Prevents unrecognized key errors. + delete req.query.key; + await next(); + await req.prisma.$disconnect(); + // @ts-expect-error testing + delete req.prisma; }; diff --git a/apps/api/lib/validations/availability.ts b/apps/api/lib/validations/availability.ts index 9d6fd20d1f..5d62a9fe34 100644 --- a/apps/api/lib/validations/availability.ts +++ b/apps/api/lib/validations/availability.ts @@ -26,6 +26,7 @@ const schemaAvailabilityCreateParams = z startTime: z.date().or(z.string()), endTime: z.date().or(z.string()), days: z.array(z.number()).optional(), + date: z.date().or(z.string()).optional(), }) .strict(); @@ -34,6 +35,7 @@ const schemaAvailabilityEditParams = z startTime: z.date().or(z.string()).optional(), endTime: z.date().or(z.string()).optional(), days: z.array(z.number()).optional(), + date: z.date().or(z.string()).optional(), }) .strict(); diff --git a/apps/api/lib/validations/schedule.ts b/apps/api/lib/validations/schedule.ts index 51a3fbe1af..ed70775600 100644 --- a/apps/api/lib/validations/schedule.ts +++ b/apps/api/lib/validations/schedule.ts @@ -21,7 +21,16 @@ export const schemaSchedulePublic = z .merge( z.object({ availability: z - .array(Availability.pick({ id: true, eventTypeId: true, days: true, startTime: true, endTime: true })) + .array( + Availability.pick({ + id: true, + eventTypeId: true, + date: true, + days: true, + startTime: true, + endTime: true, + }) + ) .transform((v) => v.map((item) => ({ ...item, diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/pages/api/event-types/_post.ts index 1531485e7b..6eeb59f5c9 100644 --- a/apps/api/pages/api/event-types/_post.ts +++ b/apps/api/pages/api/event-types/_post.ts @@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) { statusCode: 401, message: "ADMIN required for `userId`", }); + if (!isAdmin && body.teamId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `teamId`", + }); /* Admin users are required to pass in a userId or teamId */ - if (isAdmin && (!body.userId || !body.teamId)) + if (isAdmin && !body.userId && !body.teamId) throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" }); } diff --git a/apps/api/pages/api/index.ts b/apps/api/pages/api/index.ts index 85f1d6be3b..9f0dfa4829 100644 --- a/apps/api/pages/api/index.ts +++ b/apps/api/pages/api/index.ts @@ -1,5 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) { - res.status(201).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" }); + res.status(200).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" }); } diff --git a/apps/api/pages/api/users/[userId]/_delete.ts b/apps/api/pages/api/users/[userId]/_delete.ts index 44e8f0df0f..6b54fd3bee 100644 --- a/apps/api/pages/api/users/[userId]/_delete.ts +++ b/apps/api/pages/api/users/[userId]/_delete.ts @@ -1,5 +1,6 @@ import type { NextApiRequest } from "next"; +import { deleteUser } from "@calcom/features/users/lib/userDeletionService"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; @@ -41,10 +42,18 @@ export async function deleteHandler(req: NextApiRequest) { // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - const user = await prisma.user.findUnique({ where: { id: query.userId } }); + const user = await prisma.user.findUnique({ + where: { id: query.userId }, + select: { + id: true, + email: true, + metadata: true, + }, + }); if (!user) throw new HttpError({ statusCode: 404, message: "User not found" }); - await prisma.user.delete({ where: { id: user.id } }); + await deleteUser(user); + return { message: `User with id: ${user.id} deleted successfully` }; } diff --git a/apps/api/test/lib/bookings/_post.test.ts b/apps/api/test/lib/bookings/_post.test.ts index a6a308c6f8..64abddcfe3 100644 --- a/apps/api/test/lib/bookings/_post.test.ts +++ b/apps/api/test/lib/bookings/_post.test.ts @@ -8,6 +8,7 @@ import { describe, expect, test, vi } from "vitest"; import dayjs from "@calcom/dayjs"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; +import { ErrorCode } from "@calcom/lib/errorCodes"; import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder"; import prisma from "@calcom/prisma"; @@ -148,7 +149,7 @@ describe.skipIf(true)("POST /api/bookings", () => { expect(res._getStatusCode()).toBe(500); expect(JSON.parse(res._getData())).toEqual( expect.objectContaining({ - message: "No available users found.", + message: ErrorCode.NoAvailableUsersFound, }) ); }); diff --git a/apps/api/vercel.json b/apps/api/vercel.json new file mode 100644 index 0000000000..09d28d9434 --- /dev/null +++ b/apps/api/vercel.json @@ -0,0 +1,7 @@ +{ + "functions": { + "pages/api/slots/*.ts": { + "memory": 512 + } + } +} diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 5cc269a431..3aae79cb0a 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -10,7 +10,7 @@ "@calcom/config": "*", "@calcom/dayjs": "*", "@calcom/ui": "*", - "@radix-ui/react-avatar": "^1.0.0", + "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.0", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", @@ -49,7 +49,7 @@ "storybook-addon-designs": "^6.3.1", "storybook-addon-next": "^1.6.9", "storybook-react-i18next": "^1.1.2", - "tailwindcss": "^3.3.1", + "tailwindcss": "^3.3.3", "typescript": "^4.9.4", "vite": "^4.1.2" } diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts new file mode 100644 index 0000000000..5e300cf0d6 --- /dev/null +++ b/apps/web/abTest/middlewareFactory.ts @@ -0,0 +1,68 @@ +import { getBucket } from "abTest/utils"; +import type { NextMiddleware, NextRequest } from "next/server"; +import { NextResponse, URLPattern } from "next/server"; +import z from "zod"; + +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")]); + +export const abTestMiddlewareFactory = + (next: (req: NextRequest) => Promise>): NextMiddleware => + async (req: NextRequest) => { + const response = await next(req); + + const { pathname } = req.nextUrl; + + const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME); + + const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null; + + const enabled = route !== null ? route[1] || override : false; + + if (pathname.includes("future") || !enabled) { + return response; + } + + const safeParsedBucket = override + ? { success: true as const, data: "future" as const } + : bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value); + + if (!safeParsedBucket.success) { + // cookie does not exist or it has incorrect value + const bucket = getBucket(); + + 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); + } + + if (safeParsedBucket.data === "legacy") { + return response; + } + + const url = req.nextUrl.clone(); + url.pathname = `future${pathname}/`; + + return NextResponse.rewrite(url, response); + }; diff --git a/apps/web/abTest/utils.ts b/apps/web/abTest/utils.ts new file mode 100644 index 0000000000..ed40c9fca9 --- /dev/null +++ b/apps/web/abTest/utils.ts @@ -0,0 +1,9 @@ +import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants"; + +const cryptoRandom = () => { + return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff; +}; + +export const getBucket = () => { + return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy"; +}; 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/AppListCard.tsx b/apps/web/components/AppListCard.tsx index 2f9547fbb7..e9deaae260 100644 --- a/apps/web/components/AppListCard.tsx +++ b/apps/web/components/AppListCard.tsx @@ -1,4 +1,4 @@ -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import type { ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; import { z } from "zod"; @@ -6,6 +6,7 @@ import { z } from "zod"; import type { CredentialOwner } from "@calcom/app-store/types"; import classNames from "@calcom/lib/classNames"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { Badge, ListItemText, Avatar } from "@calcom/ui"; @@ -56,7 +57,7 @@ export default function AppListCard(props: AppListCardProps) { const router = useRouter(); const [highlight, setHighlight] = useState(shouldHighlight && hl === slug); const timeoutRef = useRef(null); - const searchParams = useSearchParams(); + const searchParams = useCompatSearchParams(); const pathname = usePathname(); useEffect(() => { 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/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 05c095caa1..c6a35e9749 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -42,6 +42,7 @@ export type AppPageProps = { disableInstall?: boolean; dependencies?: string[]; concurrentMeetings: AppType["concurrentMeetings"]; + paid?: AppType["paid"]; }; export const AppPage = ({ @@ -67,6 +68,7 @@ export const AppPage = ({ isTemplate, dependencies, concurrentMeetings, + paid, }: AppPageProps) => { const { t, i18n } = useLocale(); const hasDescriptionItems = descriptionItems && descriptionItems.length > 0; @@ -163,6 +165,19 @@ export const AppPage = ({ className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize"> {categories[0]} {" "} + {paid && ( + <> + + {Intl.NumberFormat(i18n.language, { + style: "currency", + currency: "USD", + useGrouping: false, + maximumFractionDigits: 0, + }).format(paid.priceInUsd)} + /{t("month")} + + + )} •{" "} {t("published_by", { author })} @@ -206,6 +221,7 @@ export const AppPage = ({ addAppMutationInput={{ type, variant, slug }} multiInstall concurrentMeetings={concurrentMeetings} + paid={paid} {...props} /> ); @@ -244,6 +260,7 @@ export const AppPage = ({ addAppMutationInput={{ type, variant, slug }} credentials={appDbQuery.data?.credentials} concurrentMeetings={concurrentMeetings} + paid={paid} {...props} /> ); @@ -263,7 +280,7 @@ export const AppPage = ({ ))} - {price !== 0 && ( + {price !== 0 && !paid && ( {feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar} {feeType === "monthly" && `/${t("month")}`} @@ -273,23 +290,27 @@ export const AppPage = ({
{body}
-

{t("pricing")}

- - {teamsPlanRequired ? ( - t("teams_plan_required") - ) : price === 0 ? ( - t("free_to_use_apps") - ) : ( - <> - {Intl.NumberFormat(i18n.language, { - style: "currency", - currency: "USD", - useGrouping: false, - }).format(price)} - {feeType === "monthly" && `/${t("month")}`} - - )} - + {!paid && ( + <> +

{t("pricing")}

+ + {teamsPlanRequired ? ( + t("teams_plan_required") + ) : price === 0 ? ( + t("free_to_use_apps") + ) : ( + <> + {Intl.NumberFormat(i18n.language, { + style: "currency", + currency: "USD", + useGrouping: false, + }).format(price)} + {feeType === "monthly" && `/${t("month")}`} + + )} + + + )}

{t("contact")}