From 225313e391133f16c79e26fb6add805cfc160dc4 Mon Sep 17 00:00:00 2001 From: Nizzy <140507264+nizzyabi@users.noreply.github.com> Date: Thu, 26 Dec 2024 14:07:08 -0500 Subject: [PATCH] feat: Plain chat (#18284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * plain custom desin * No display on card * dynamic plain chat component and hmac hash * re-route users to plain.com chat instead of intercom chat when going to /support * provider errors * yarn lock fix * plain chat removed unneeded hmac * fix ts error * error handling improved * remove intercome provider from app-dir * Create getting-started.mdx (#18342) * Delete help directory (#18343) * chore: moved docs/help to /help (#18345) * chore: Delete unused guides directory (#18346) * feat: booking filters (#18303) Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> * chore: Remove `HeadSeo` components where no longer needed + improve app router metadata logic (#18348) * remove HeadSeo for already migrated pages and refactor prepareMetadata * create _generateMetadataWithoutImage and refactor _generateMetadata * chore: app router - /bookings status page (#18183) * chore: app router - /bookings page * remove env vars * fix * Update middleware.ts * revert unneeded change * refactor for the better * fix * cache i18n instances (#18309) * feat: virtual queues tab in insights (#18260) Co-authored-by: CarinaWolli Co-authored-by: sean-brydon Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Peer Richelsen Co-authored-by: Omar López * feat: update translations via @replexica (#18361) Co-authored-by: Replexica * update OOO e2e tests to remove flakiness (#18367) * fix: metadata is overwritten for child managed eventType when updating parent (#18059) * fix: metadata is overwirten * Update * type error * Update * fix test * fix type error * chore: added routing support link (#18369) * Added routing form support link * small change * Type fix --------- Co-authored-by: Joe Au-Yeung * chore: refactor handling logic for embeds in app router (#18362) * refactor handling logic for embeds in app router * fix type checks * add test for withEmbedSsrAppDir * fix * review changes * yarn lock --------- Co-authored-by: Peer Richelsen Co-authored-by: Calcom Bot <109866826+calcom-bot@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: Benny Joo Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli Co-authored-by: sean-brydon Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Omar López Co-authored-by: Replexica Co-authored-by: Vijay Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Joe Au-Yeung Co-authored-by: Alex van Andel --- .env.example | 2 + apps/web/app/api/customer-card/route.ts | 29 +- apps/web/app/api/plain-hash/route.ts | 49 + apps/web/lib/app-providers-app-dir.tsx | 12 +- apps/web/lib/app-providers.tsx | 13 +- apps/web/lib/plain/plainChat.tsx | 247 + apps/web/next.config.js | 2 +- .../pages/api/plain/customer-card-display.ts | 8 +- apps/web/public/static/locales/en/common.json | 4 +- .../shell/user-dropdown/UserDropdown.tsx | 184 +- turbo.json | 4 +- yarn.lock | 7423 ++++++++++++++++- 12 files changed, 7767 insertions(+), 210 deletions(-) create mode 100644 apps/web/app/api/plain-hash/route.ts create mode 100644 apps/web/lib/plain/plainChat.tsx diff --git a/.env.example b/.env.example index f68708004d..8d501dd126 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/apps/web/app/api/customer-card/route.ts b/apps/web/app/api/customer-card/route.ts index 1891145851..1ddce87154 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 0000000000..b6fac246ca --- /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/lib/app-providers-app-dir.tsx b/apps/web/lib/app-providers-app-dir.tsx index 58abc62da0..3d2b60318e 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 3b11aee2b2..c7450d4515 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/plain/plainChat.tsx b/apps/web/lib/plain/plainChat.tsx new file mode 100644 index 0000000000..bb28a5508a --- /dev/null +++ b/apps/web/lib/plain/plainChat.tsx @@ -0,0 +1,247 @@ +"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 { data: session } = useSession(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + 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(); + }, [session, pathname, searchParams]); + + 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 (!config) return null; + + return ( + <> +