diff --git a/.env.example b/.env.example index f68708004d4a3d..8d501dd1263720 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,8 @@ NEXT_PUBLIC_POSTHOG_HOST= PLAIN_API_KEY= PLAIN_API_URL=https://api.plain.com/v1 PLAIN_HMAC_SECRET_KEY= +PLAIN_CHAT_ID= +PLAIN_CHAT_HMAC_SECRET_KEY= # Zendesk Config NEXT_PUBLIC_ZENDESK_KEY= diff --git a/.yarn/versions/aecb4352.yml b/.yarn/versions/aecb4352.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/aecb4352.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/c92a78d0.yml b/.yarn/versions/c92a78d0.yml new file mode 100644 index 00000000000000..ccddf60ece96de --- /dev/null +++ b/.yarn/versions/c92a78d0.yml @@ -0,0 +1,2 @@ +undecided: + - calcom-monorepo diff --git a/apps/web/app/WithEmbedSSR.test.ts b/apps/web/app/WithEmbedSSR.test.ts new file mode 100644 index 00000000000000..b760097fd325b9 --- /dev/null +++ b/apps/web/app/WithEmbedSSR.test.ts @@ -0,0 +1,247 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse, Redirect } from "next"; +import { redirect, notFound } from "next/navigation"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, it, vi } from "vitest"; + +import withEmbedSsrAppDir from "./WithEmbedSSR"; + +export type CustomNextApiRequest = NextApiRequest & Request; +export type CustomNextApiResponse = NextApiResponse & Response; + +export function createMockNextJsRequest(...args: Parameters) { + return createMocks(...args); +} + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), + notFound: vi.fn(), +})); + +function getServerSidePropsFnGenerator( + config: + | { redirectUrl: string } + | { props: Record } + | { + notFound: true; + } +) { + if ("redirectUrl" in config) + return async () => { + return { + redirect: { + permanent: false, + destination: config.redirectUrl, + } satisfies Redirect, + }; + }; + + if ("props" in config) + return async () => { + return { + props: config.props, + }; + }; + + if ("notFound" in config) + return async () => { + return { + notFound: true as const, + }; + }; + + throw new Error("Invalid config"); +} + +interface ServerSidePropsContext { + embedRelatedParams?: Record; +} + +function getServerSidePropsContextArg({ embedRelatedParams = {} }: ServerSidePropsContext) { + const { req, res } = createMockNextJsRequest(); + return { + req, + res, + query: { + ...embedRelatedParams, + }, + resolvedUrl: "/MOCKED_RESOLVED_URL", + }; +} + +describe("withEmbedSsrAppDir", () => { + describe("when gSSP returns redirect", () => { + describe("when redirect destination is relative", () => { + it("should redirect with layout and embed params from the current query", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + redirectUrl: "/reschedule", + }) + ); + + await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "namespace1", + }, + }) + ).catch(() => {}); + + expect(redirect).toHaveBeenCalledWith("/reschedule/embed?layout=week_view&embed=namespace1"); + }); + + it("should preserve existing query params in redirect URL", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + redirectUrl: "/reschedule?redirectParam=1", + }) + ); + + await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "namespace1", + }, + }) + ).catch(() => {}); + + expect(redirect).toHaveBeenCalledWith( + "/reschedule/embed?redirectParam=1&layout=week_view&embed=namespace1" + ); + }); + + it("should handle empty embed namespace", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + redirectUrl: "/reschedule?redirectParam=1", + }) + ); + + await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + }, + }) + ).catch(() => {}); + + expect(redirect).toHaveBeenCalledWith("/reschedule/embed?redirectParam=1&layout=week_view&embed="); + }); + }); + + describe("when redirect destination is absolute", () => { + it("should handle HTTPS URLs", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + redirectUrl: "https://calcom.cal.local/owner", + }) + ); + + await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "namespace1", + }, + }) + ).catch(() => {}); + + expect(redirect).toHaveBeenCalledWith( + "https://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1" + ); + }); + + it("should handle HTTP URLs", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + redirectUrl: "http://calcom.cal.local/owner", + }) + ); + + await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "namespace1", + }, + }) + ).catch(() => {}); + + expect(redirect).toHaveBeenCalledWith( + "http://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1" + ); + }); + + it("should treat URLs without protocol as relative", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + redirectUrl: "calcom.cal.local/owner", + }) + ); + + await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "namespace1", + }, + }) + ).catch(() => {}); + + expect(redirect).toHaveBeenCalledWith( + "/calcom.cal.local/owner/embed?layout=week_view&embed=namespace1" + ); + }); + }); + }); + + describe("when gSSP returns props", () => { + it("should add isEmbed=true prop", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + props: { + prop1: "value1", + }, + }) + ); + + const ret = await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + }, + }) + ); + + expect(ret).toEqual({ + prop1: "value1", + isEmbed: true, + }); + }); + }); + + describe("when gSSP returns notFound", () => { + it("should throw notFound", async () => { + const withEmbedGetSsr = withEmbedSsrAppDir( + getServerSidePropsFnGenerator({ + notFound: true, + }) + ); + + await withEmbedGetSsr( + getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + }, + }) + ).catch(() => {}); + + expect(notFound).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/WithEmbedSSR.tsx b/apps/web/app/WithEmbedSSR.tsx index ddfe5bd9efe9e2..80e41e1fd82a0a 100644 --- a/apps/web/app/WithEmbedSSR.tsx +++ b/apps/web/app/WithEmbedSSR.tsx @@ -1,6 +1,4 @@ -import type { GetServerSidePropsContext } from "next"; -import { isNotFoundError } from "next/dist/client/components/not-found"; -import { getURLFromRedirectError, isRedirectError } from "next/dist/client/components/redirect"; +import type { GetServerSideProps, GetServerSidePropsContext } from "next"; import { notFound, redirect } from "next/navigation"; import { WebAppURL } from "@calcom/lib/WebAppURL"; @@ -9,48 +7,46 @@ export type EmbedProps = { isEmbed?: boolean; }; -export default function withEmbedSsrAppDir>( - getData: (context: GetServerSidePropsContext) => Promise -) { - return async (context: GetServerSidePropsContext): Promise => { +const withEmbedSsrAppDir = + >(getServerSideProps: GetServerSideProps) => + async (context: GetServerSidePropsContext): Promise => { const { embed, layout } = context.query; - try { - const props = await getData(context); - - return { - ...props, - isEmbed: true, - }; - } catch (e) { - if (isRedirectError(e)) { - const destinationUrl = getURLFromRedirectError(e); - let urlPrefix = ""; - - // Get the URL parsed from URL so that we can reliably read pathname and searchParams from it. - const destinationUrlObj = new WebAppURL(destinationUrl); - - // If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain. - if (destinationUrl.search(/^(http:|https:).*/) !== -1) { - urlPrefix = destinationUrlObj.origin; - } else { - // Don't use any prefix for relative URLs to ensure we stay on the same domain - urlPrefix = ""; - } - - const destinationQueryStr = destinationUrlObj.searchParams.toString(); - // Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace - const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${ - destinationQueryStr ? `${destinationQueryStr}&` : "" - }layout=${layout}&embed=${embed}`; - - redirect(newDestinationUrl); - } + const ssrResponse = await getServerSideProps(context); + + if ("redirect" in ssrResponse) { + const destinationUrl = ssrResponse.redirect.destination; + let urlPrefix = ""; - if (isNotFoundError(e)) { - notFound(); + // Get the URL parsed from URL so that we can reliably read pathname and searchParams from it. + const destinationUrlObj = new WebAppURL(ssrResponse.redirect.destination); + + // If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain. + if (destinationUrl.search(/^(http:|https:).*/) !== -1) { + urlPrefix = destinationUrlObj.origin; + } else { + // Don't use any prefix for relative URLs to ensure we stay on the same domain + urlPrefix = ""; } - throw e; + const destinationQueryStr = destinationUrlObj.searchParams.toString(); + // Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace + const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${ + destinationQueryStr ? `${destinationQueryStr}&` : "" + }layout=${layout}&embed=${embed}`; + redirect(newDestinationUrl); } + + if ("notFound" in ssrResponse) { + notFound(); + } + + return { + ...ssrResponse.props, + ...("trpcState" in ssrResponse.props && { + dehydratedState: ssrResponse.props.trpcState, + }), + isEmbed: true, + }; }; -} + +export default withEmbedSsrAppDir; diff --git a/apps/web/app/api/customer-card/route.ts b/apps/web/app/api/customer-card/route.ts index 189114585107cb..1ddce87154da9d 100644 --- a/apps/web/app/api/customer-card/route.ts +++ b/apps/web/app/api/customer-card/route.ts @@ -26,6 +26,7 @@ export async function handler(request: Request) { // HMAC verification const incomingSignature = headersList.get("plain-request-signature"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!) .update(JSON.stringify(requestBody)) .digest("hex"); @@ -88,18 +89,8 @@ export async function handler(request: Request) { rowAsideContent: [ { componentBadge: { - badgeLabel: - customer.emailVerified === undefined - ? "Unknown" - : customer.emailVerified - ? "Yes" - : "No", - badgeColor: - customer.emailVerified === undefined - ? "YELLOW" - : customer.emailVerified - ? "GREEN" - : "RED", + badgeLabel: customer.emailVerified ? "Yes" : "No", + badgeColor: customer.emailVerified ? "GREEN" : "RED", }, }, ], @@ -195,18 +186,8 @@ export async function handler(request: Request) { rowAsideContent: [ { componentBadge: { - badgeLabel: - customer.twoFactorEnabled === undefined - ? "Unknown" - : customer.twoFactorEnabled - ? "Yes" - : "No", - badgeColor: - customer.twoFactorEnabled === undefined - ? "YELLOW" - : customer.twoFactorEnabled - ? "GREEN" - : "RED", + badgeLabel: customer.twoFactorEnabled ? "Yes" : "No", + badgeColor: customer.twoFactorEnabled ? "GREEN" : "RED", }, }, ], diff --git a/apps/web/app/api/plain-hash/route.ts b/apps/web/app/api/plain-hash/route.ts new file mode 100644 index 00000000000000..b6fac246ca5c85 --- /dev/null +++ b/apps/web/app/api/plain-hash/route.ts @@ -0,0 +1,49 @@ +import { createHmac } from "crypto"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { apiRouteMiddleware } from "@calcom/lib/server/apiRouteMiddleware"; + +const responseSchema = z.object({ + hash: z.string(), + email: z.string().email(), + shortName: z.string(), + appId: z.string(), + fullName: z.string(), + chatAvatarUrl: z.string(), +}); + +async function handler(request: Request) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = await getServerSession({ req: request as any }); + if (!session?.user?.email) { + return new Response("Unauthorized - No session email found", { status: 401 }); + } + + const secret = process.env.PLAIN_CHAT_HMAC_SECRET_KEY; + if (!secret) { + return new Response("Missing Plain Chat secret", { status: 500 }); + } + + const hmac = createHmac("sha256", secret); + hmac.update(session.user.email.toLowerCase().trim()); + const hash = hmac.digest("hex"); + + const shortName = + (session.user.name?.split(" ")[0] || session.user.email).charAt(0).toUpperCase() + + (session.user.name?.split(" ")[0] || session.user.email).slice(1) || "User"; + + const response = responseSchema.parse({ + hash, + email: session.user.email || "user@example.com", + shortName, + appId: process.env.PLAIN_CHAT_ID, + fullName: session.user.name || "User", + chatAvatarUrl: session.user.avatarUrl || "", + }); + + return NextResponse.json(response); +} + +export const POST = apiRouteMiddleware(handler); diff --git a/apps/web/app/booking/[uid]/embed/page.tsx b/apps/web/app/booking/[uid]/embed/page.tsx index 7a314fc547408f..28f96ba531c386 100644 --- a/apps/web/app/booking/[uid]/embed/page.tsx +++ b/apps/web/app/booking/[uid]/embed/page.tsx @@ -1,12 +1,9 @@ -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import { WithLayout } from "app/layoutHOC"; import OldPage from "~/bookings/views/bookings-single-view"; import { getServerSideProps, type PageProps } from "~/bookings/views/bookings-single-view.getServerSideProps"; -const getData = withAppDirSsr(getServerSideProps); - -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ getLayout: null, getData: getEmbedData, Page: OldPage }); diff --git a/apps/web/app/booking/dry-run-successful/page.tsx b/apps/web/app/booking/dry-run-successful/page.tsx new file mode 100644 index 00000000000000..0749c15a924d64 --- /dev/null +++ b/apps/web/app/booking/dry-run-successful/page.tsx @@ -0,0 +1,17 @@ +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +import BookingDryRunSuccessView from "~/bookings/views/booking-dry-run-success-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("booking_dry_run_successful"), + (t) => t("booking_dry_run_successful_description") + ); +}; + +const Page = async () => { + return ; +}; + +export default WithLayout({ ServerPage: Page }); diff --git a/apps/web/app/future/[user]/[type]/embed/page.tsx b/apps/web/app/future/[user]/[type]/embed/page.tsx index 3986b0b9ae544e..78fe30896dc3c8 100644 --- a/apps/web/app/future/[user]/[type]/embed/page.tsx +++ b/apps/web/app/future/[user]/[type]/embed/page.tsx @@ -1,4 +1,3 @@ -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import { WithLayout } from "app/layoutHOC"; @@ -6,10 +5,6 @@ import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps import LegacyPage, { type PageProps } from "~/users/views/users-type-public-view"; -export { generateMetadata } from "../page"; - -const getData = withAppDirSsr(getServerSideProps); - -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">; diff --git a/apps/web/app/future/[user]/embed/page.tsx b/apps/web/app/future/[user]/embed/page.tsx index 5aae7be4346f59..09070a22f9d935 100644 --- a/apps/web/app/future/[user]/embed/page.tsx +++ b/apps/web/app/future/[user]/embed/page.tsx @@ -1,4 +1,3 @@ -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import { WithLayout } from "app/layoutHOC"; @@ -7,10 +6,6 @@ import { getServerSideProps } from "@server/lib/[user]/getServerSideProps"; import type { PageProps as UserPageProps } from "~/users/views/users-public-view"; import LegacyPage from "~/users/views/users-public-view"; -export { generateMetadata } from "../page"; - -const getData = withAppDirSsr(getServerSideProps); - -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">; diff --git a/apps/web/app/future/org/[orgSlug]/[user]/[type]/embed/page.tsx b/apps/web/app/future/org/[orgSlug]/[user]/[type]/embed/page.tsx index 6762375103a456..4cbd35891fd0f8 100644 --- a/apps/web/app/future/org/[orgSlug]/[user]/[type]/embed/page.tsx +++ b/apps/web/app/future/org/[orgSlug]/[user]/[type]/embed/page.tsx @@ -1,12 +1,10 @@ import { type PageProps } from "@pages/org/[orgSlug]/[user]/[type]"; import Page from "@pages/org/[orgSlug]/[user]/[type]/embed"; -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import { WithLayout } from "app/layoutHOC"; import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps"; -const getData = withAppDirSsr(getServerSideProps); -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page }); diff --git a/apps/web/app/future/org/[orgSlug]/[user]/embed/page.tsx b/apps/web/app/future/org/[orgSlug]/[user]/embed/page.tsx index adf85d2655c65e..02c31c9929e1e9 100644 --- a/apps/web/app/future/org/[orgSlug]/[user]/embed/page.tsx +++ b/apps/web/app/future/org/[orgSlug]/[user]/embed/page.tsx @@ -1,11 +1,8 @@ import { getServerSideProps, type PageProps } from "@pages/org/[orgSlug]/[user]"; import Page from "@pages/org/[orgSlug]/[user]/embed"; -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import { WithLayout } from "app/layoutHOC"; -const getData = withAppDirSsr(getServerSideProps); - -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page }); diff --git a/apps/web/app/future/org/[orgSlug]/embed/page.tsx b/apps/web/app/future/org/[orgSlug]/embed/page.tsx index cc874931185a9c..0f378c48cb0003 100644 --- a/apps/web/app/future/org/[orgSlug]/embed/page.tsx +++ b/apps/web/app/future/org/[orgSlug]/embed/page.tsx @@ -1,28 +1,14 @@ -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps as _PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import { cookies, headers } from "next/headers"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps"; import type { PageProps } from "~/team/team-view"; import TeamPage from "~/team/team-view"; -const getData = withAppDirSsr(getServerSideProps); - -export const generateMetadata = async ({ params, searchParams }: _PageProps) => { - const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); - - return await _generateMetadata( - (t) => props.team.name || t("nameless_team"), - (t) => props.team.name || t("nameless_team") - ); -}; - -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ Page: TeamPage, diff --git a/apps/web/app/future/team/[slug]/[type]/embed/page.tsx b/apps/web/app/future/team/[slug]/[type]/embed/page.tsx index 378a528ff517dc..4c15acd8776f72 100644 --- a/apps/web/app/future/team/[slug]/[type]/embed/page.tsx +++ b/apps/web/app/future/team/[slug]/[type]/embed/page.tsx @@ -1,43 +1,13 @@ -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps as _PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import { cookies, headers } from "next/headers"; -import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { EventRepository } from "@calcom/lib/server/repository/event"; - -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps"; import type { PageProps } from "~/team/type-view"; import LegacyPage from "~/team/type-view"; -export const generateMetadata = async ({ params, searchParams }: _PageProps) => { - const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams); - const props = await getData(legacyCtx); - const { user: username, slug: eventSlug, booking } = props; - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug); - - const event = await EventRepository.getPublicEvent({ - username, - eventSlug, - isTeamEvent: true, - org: isValidOrgDomain ? currentOrgDomain : null, - fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true", - }); - - const profileName = event?.profile?.name ?? ""; - const title = event?.title ?? ""; - - return await _generateMetadata( - (t) => `${booking?.uid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`, - (t) => `${booking?.uid ? t("reschedule") : ""} ${title}` - ); -}; - -const getData = withAppDirSsr(getServerSideProps); -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">; diff --git a/apps/web/app/future/team/[slug]/embed/page.tsx b/apps/web/app/future/team/[slug]/embed/page.tsx index 889732c6ca3fe4..9978957cb9a677 100644 --- a/apps/web/app/future/team/[slug]/embed/page.tsx +++ b/apps/web/app/future/team/[slug]/embed/page.tsx @@ -3,24 +3,12 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps as _PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; -import { cookies, headers } from "next/headers"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps"; import type { PageProps } from "~/team/team-view"; import LegacyPage from "~/team/team-view"; -export const generateMetadata = async ({ params, searchParams }: _PageProps) => { - const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); - - return await _generateMetadata( - (t) => props.team.name || t("nameless_team"), - (t) => props.team.name || t("nameless_team") - ); -}; - -const getData = withAppDirSsr(getServerSideProps); -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">; diff --git a/apps/web/app/reschedule/[uid]/embed/page.tsx b/apps/web/app/reschedule/[uid]/embed/page.tsx index 0ec1cff1f69a12..efc98cafb0dc5e 100644 --- a/apps/web/app/reschedule/[uid]/embed/page.tsx +++ b/apps/web/app/reschedule/[uid]/embed/page.tsx @@ -1,4 +1,3 @@ -import { withAppDirSsr } from "app/WithAppDirSsr"; import withEmbedSsrAppDir from "app/WithEmbedSSR"; import type { PageProps } from "app/_types"; import { cookies, headers } from "next/headers"; @@ -6,8 +5,7 @@ import { cookies, headers } from "next/headers"; import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/reschedule/[uid]/getServerSideProps"; -const getData = withAppDirSsr(getServerSideProps); -const getEmbedData = withEmbedSsrAppDir(getData); +const getEmbedData = withEmbedSsrAppDir(getServerSideProps); const Page = async ({ params, searchParams }: PageProps) => { const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams); diff --git a/apps/web/lib/app-providers-app-dir.tsx b/apps/web/lib/app-providers-app-dir.tsx index 58abc62da01d30..3d2b60318e3556 100644 --- a/apps/web/lib/app-providers-app-dir.tsx +++ b/apps/web/lib/app-providers-app-dir.tsx @@ -17,12 +17,12 @@ import CacheProvider from "react-inlinesvg/provider"; import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic"; import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider"; import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic"; -import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic"; import { FeatureProvider } from "@calcom/features/flags/context/provider"; import { useFlags } from "@calcom/features/flags/hooks"; import { MetaProvider } from "@calcom/ui"; import useIsBookingPage from "@lib/hooks/useIsBookingPage"; +import PlainChat from "@lib/plain/plainChat"; import type { WithLocaleProps } from "@lib/withLocale"; import type { WithNonceProps } from "@lib/withNonce"; @@ -265,9 +265,11 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) { const AppProviders = (props: PageWrapperProps) => { // No need to have intercom on public pages - Good for Page Performance 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 */} @@ -301,11 +303,11 @@ const AppProviders = (props: PageWrapperProps) => { } return ( - - + <> + {Hydrated} - - + + ); }; diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index 3b11aee2b280da..c7450d45151aee 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -16,12 +16,12 @@ import CacheProvider from "react-inlinesvg/provider"; import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic"; import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider"; import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic"; -import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic"; import { FeatureProvider } from "@calcom/features/flags/context/provider"; import { useFlags } from "@calcom/features/flags/hooks"; import { MetaProvider } from "@calcom/ui"; import useIsBookingPage from "@lib/hooks/useIsBookingPage"; +import PlainChat from "@lib/plain/plainChat"; import type { WithLocaleProps } from "@lib/withLocale"; import type { WithNonceProps } from "@lib/withNonce"; @@ -282,7 +282,6 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) { } const AppProviders = (props: AppPropsWithChildren) => { - // No need to have intercom on public pages - Good for Page Performance const isBookingPage = useIsBookingPage(); const { pageProps, ...rest } = props; @@ -298,9 +297,9 @@ const AppProviders = (props: AppPropsWithChildren) => { 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 */} { } return ( - - + <> + {RemainingProviders} - - + + ); }; diff --git a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx index 210836b8cc334b..ecc97927b66ef2 100644 --- a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx @@ -12,7 +12,7 @@ import { RedirectType } from "@calcom/prisma/enums"; import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; -import type { EmbedProps } from "@lib/withEmbedSsr"; +import type { EmbedProps } from "app/WithEmbedSSR"; export type PageProps = inferSSRProps & EmbedProps; diff --git a/apps/web/lib/plain/plainChat.tsx b/apps/web/lib/plain/plainChat.tsx new file mode 100644 index 00000000000000..f63b93f0d8e4f1 --- /dev/null +++ b/apps/web/lib/plain/plainChat.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { usePathname, useSearchParams } from "next/navigation"; +import Script from "next/script"; +import { useEffect, useState } from "react"; + +declare global { + interface Window { + Plain?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + init: (config: any) => void; + open: () => void; + }; + plainScriptLoaded?: () => void; + __PLAIN_CONFIG__?: PlainChatConfig; + } +} + +interface PlainChatConfig { + appId: string; + customerDetails: { + email: string; + emailHash: string; + fullName: string; + shortName: string; + chatAvatarUrl: string; + }; + links: Array<{ + icon: string; + text: string; + url: string; + }>; + chatButtons: Array<{ + icon: string; + text: string; + type: string; + form?: { + fields: Array<{ + type: string; + placeholder: string; + options: Array<{ + icon: string; + text: string; + threadDetails: { + severity: string; + labelTypeIds: Array; + issueType: string; + priority: string; + }; + }>; + }>; + }; + }>; + entryPoint: { + type: string; + }; + hideBranding: boolean; + theme: string; + style: { + brandColor: string; + launcherBackgroundColor: string; + launcherIconColor: string; + }; + position: { + bottom: string; + right: string; + }; +} + +const PlainChat = () => { + const [config, setConfig] = useState(null); + const [isSmallScreen, setIsSmallScreen] = useState(false); + const { data: session } = useSession(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const isAppDomain = typeof window !== "undefined" && window.location.hostname === "app.cal.com"; + + useEffect(() => { + if (!isAppDomain) return; + + const checkScreenSize = () => { + setIsSmallScreen(window.innerWidth < 768); + }; + + checkScreenSize(); + window.addEventListener("resize", checkScreenSize); + + const initConfig = async () => { + if (!session?.user?.email) return; + + try { + const response = await fetch("/api/plain-hash", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to generate hash: ${errorText}`); + } + + const data = await response.json(); + + if (!data.hash || !data.email || !data.appId) { + throw new Error("Missing required fields in API response"); + } + + const plainChatConfig: PlainChatConfig = { + appId: data.appId, + customerDetails: { + email: data.email, + shortName: data.shortName, + fullName: data.fullName, + emailHash: data.hash, + chatAvatarUrl: data.chatAvatarUrl, + }, + links: [ + { + icon: "book", + text: "Documentation", + url: "https://cal.com/docs", + }, + { + icon: "chat", + text: "Ask the community", + url: "https://github.com/calcom/cal.com/discussions", + }, + ], + chatButtons: [ + { + icon: "chat", + text: "Ask a question", + type: "primary", + }, + { + icon: "bulb", + text: "Send feedback", + type: "default", + }, + { + icon: "error", + text: "Report an issue", + type: "default", + form: { + fields: [ + { + type: "dropdown", + placeholder: "Select severity...", + options: [ + { + icon: "support", + text: "I'm unable to use the app", + threadDetails: { + severity: "critical", + issueType: "critical", + labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"], + priority: "u", + }, + }, + { + icon: "error", + text: "Major functionality degraded", + threadDetails: { + severity: "major", + issueType: "major", + labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"], + priority: "h", + }, + }, + { + icon: "bug", + text: "Minor annoyance", + threadDetails: { + severity: "minor", + issueType: "minor", + labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"], + priority: "l", + }, + }, + ], + }, + ], + }, + }, + ], + entryPoint: { + type: "chat", + }, + hideBranding: true, + theme: "auto", + style: { + brandColor: "#FFFFFF", + launcherBackgroundColor: "#262626", + launcherIconColor: "#FFFFFF", + }, + position: { + bottom: "20px", + right: "20px", + }, + }; + + if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") { + window.__PLAIN_CONFIG__ = plainChatConfig; + } + + setConfig(plainChatConfig); + + if (pathname === "/event-types" && searchParams?.has("openPlain")) { + const timer = setTimeout(() => { + if (window.Plain) { + window.Plain.open(); + } + }, 100); + return () => clearTimeout(timer); + } + } catch (error) { + console.error("Failed to initialize Plain Chat:", error); + } + }; + + initConfig(); + + return () => window.removeEventListener("resize", checkScreenSize); + }, [session, pathname, searchParams, isAppDomain]); + + const plainChatScript = ` + window.plainScriptLoaded = function() { + if (window.Plain && ${Boolean(config)}) { + try { + Plain.init(${config ? JSON.stringify(config) : null}); + } catch (error) { + console.error("Failed to initialize Plain:", error); + } + } + } + `; + + if (!isAppDomain || isSmallScreen || !config) return null; + + return ( + <> +