import type { User as UserAuth } from "next-auth"; import { signOut, useSession } from "next-auth/react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import type { Dispatch, ReactElement, ReactNode, SetStateAction } from "react"; import React, { cloneElement, Fragment, useEffect, useMemo, useState } from "react"; import { Toaster } from "react-hot-toast"; import dayjs from "@calcom/dayjs"; import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge"; import ImpersonatingBanner, { type ImpersonatingBannerProps, } from "@calcom/features/ee/impersonation/components/ImpersonatingBanner"; import { OrgUpgradeBanner, type OrgUpgradeBannerProps, } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem"; import useIntercom, { isInterComEnabled } from "@calcom/features/ee/support/lib/intercom/useIntercom"; import { TeamsUpgradeBanner, type TeamsUpgradeBannerProps } from "@calcom/features/ee/teams/components"; import { useFlagMap } from "@calcom/features/flags/context/provider"; import { KBarContent, KBarRoot, KBarTrigger } from "@calcom/features/kbar/Kbar"; import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog"; import AdminPasswordBanner, { type AdminPasswordBannerProps, } from "@calcom/features/users/components/AdminPasswordBanner"; import CalendarCredentialBanner, { type CalendarCredentialBannerProps, } from "@calcom/features/users/components/CalendarCredentialBanner"; import { InvalidAppCredentialBanners, type InvalidAppCredentialBannersProps, } from "@calcom/features/users/components/InvalidAppCredentialsBanner"; import VerifyEmailBanner, { type VerifyEmailBannerProps, } from "@calcom/features/users/components/VerifyEmailBanner"; import classNames from "@calcom/lib/classNames"; import { APP_NAME, DESKTOP_APP_LINK, ENABLE_PROFILE_SWITCHER, IS_VISUAL_REGRESSION_TESTING, JOIN_DISCORD, ROADMAP, TOP_BANNER_HEIGHT, WEBAPP_URL, } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useFormbricks } from "@calcom/lib/formbricks-client"; import getBrandColours from "@calcom/lib/getBrandColours"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import { isKeyInObject } from "@calcom/lib/isKeyInObject"; import { localStorage } from "@calcom/lib/webstorage"; import type { User } from "@calcom/prisma/client"; import { trpc } from "@calcom/trpc/react"; import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { Avatar, Button, ButtonOrLink, Credits, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger, ErrorBoundary, HeadSeo, Icon, Logo, showToast, SkeletonText, Tooltip, useCalcomTheme, type IconName, } from "@calcom/ui"; import { Discord } from "@calcom/ui/components/icon/Discord"; import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes"; import { useOrgBranding } from "../ee/organizations/context/provider"; import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider"; import { TeamInviteBadge } from "./TeamInviteBadge"; // need to import without ssr to prevent hydration errors const Tips = dynamic(() => import("@calcom/features/tips").then((mod) => mod.Tips), { ssr: false, }); /* TODO: Migate this */ export const ONBOARDING_INTRODUCED_AT = dayjs("September 1 2021").toISOString(); export const ONBOARDING_NEXT_REDIRECT = { redirect: { permanent: false, destination: "/getting-started", }, } as const; export const shouldShowOnboarding = ( user: Pick & { organizationId: number | null; } ) => { return ( !user.completedOnboarding && !user.organizationId && dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT) ); }; function useRedirectToLoginIfUnauthenticated(isPublic = false) { const { data: session, status } = useSession(); const loading = status === "loading"; const router = useRouter(); useEffect(() => { if (isPublic) { return; } if (!loading && !session) { const urlSearchParams = new URLSearchParams(); urlSearchParams.set("callbackUrl", `${WEBAPP_URL}${location.pathname}${location.search}`); router.replace(`/auth/login?${urlSearchParams.toString()}`); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, session, isPublic]); return { loading: loading && !session, session, }; } type BannerTypeProps = { teamUpgradeBanner: TeamsUpgradeBannerProps; orgUpgradeBanner: OrgUpgradeBannerProps; verifyEmailBanner: VerifyEmailBannerProps; adminPasswordBanner: AdminPasswordBannerProps; impersonationBanner: ImpersonatingBannerProps; calendarCredentialBanner: CalendarCredentialBannerProps; invalidAppCredentialBanners: InvalidAppCredentialBannersProps; }; type BannerType = keyof BannerTypeProps; type BannerComponent = { [Key in BannerType]: (props: BannerTypeProps[Key]) => JSX.Element; }; const BannerComponent: BannerComponent = { teamUpgradeBanner: (props: TeamsUpgradeBannerProps) => , orgUpgradeBanner: (props: OrgUpgradeBannerProps) => , verifyEmailBanner: (props: VerifyEmailBannerProps) => , adminPasswordBanner: (props: AdminPasswordBannerProps) => , impersonationBanner: (props: ImpersonatingBannerProps) => , calendarCredentialBanner: (props: CalendarCredentialBannerProps) => , invalidAppCredentialBanners: (props: InvalidAppCredentialBannersProps) => ( ), }; function useRedirectToOnboardingIfNeeded() { const router = useRouter(); const query = useMeQuery(); const user = query.data; const flags = useFlagMap(); const { data: email } = useEmailVerifyCheck(); const needsEmailVerification = !email?.isVerified && flags["email-verification"]; const isRedirectingToOnboarding = user && shouldShowOnboarding(user); useEffect(() => { if (isRedirectingToOnboarding && !needsEmailVerification) { router.replace("/getting-started"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRedirectingToOnboarding, needsEmailVerification]); return { isRedirectingToOnboarding, }; } type allBannerProps = { [Key in BannerType]: BannerTypeProps[Key]["data"] }; const useBanners = () => { const { data: getUserTopBanners, isPending } = trpc.viewer.getUserTopBanners.useQuery(); const { data: userSession } = useSession(); if (isPending || !userSession) return null; const isUserInactiveAdmin = userSession?.user.role === "INACTIVE_ADMIN"; const userImpersonatedByUID = userSession?.user.impersonatedBy?.id; const userSessionBanners = { adminPasswordBanner: isUserInactiveAdmin ? userSession : null, impersonationBanner: userImpersonatedByUID ? userSession : null, }; const allBanners: allBannerProps = Object.assign({}, getUserTopBanners, userSessionBanners); return allBanners; }; const Layout = (props: LayoutProps) => { const banners = useBanners(); const { data: user } = trpc.viewer.me.useQuery(); const { boot } = useIntercom(); const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title; useEffect(() => { // not using useMediaQuery as it toggles between true and false const showIntercom = localStorage.getItem("showIntercom"); if (!isInterComEnabled || showIntercom === "false" || window.innerWidth <= 768 || !user) return; boot(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); const bannersHeight = useMemo(() => { const activeBanners = banners && Object.entries(banners).filter(([_, value]) => { return value && (!Array.isArray(value) || value.length > 0); }); return (activeBanners?.length ?? 0) * TOP_BANNER_HEIGHT; }, [banners]); useFormbricks(); return ( <> {!props.withoutSeo && ( )}
{banners && !props.isPlatformUser && (
{Object.keys(banners).map((key) => { if (key === "teamUpgradeBanner") { const Banner = BannerComponent[key]; return ; } else if (key === "orgUpgradeBanner") { const Banner = BannerComponent[key]; return ; } else if (key === "verifyEmailBanner") { const Banner = BannerComponent[key]; return ; } else if (key === "adminPasswordBanner") { const Banner = BannerComponent[key]; return ; } else if (key === "impersonationBanner") { const Banner = BannerComponent[key]; return ; } else if (key === "calendarCredentialBanner") { const Banner = BannerComponent[key]; return ; } else if (key === "invalidAppCredentialBanners") { const Banner = BannerComponent[key]; return ; } })}
)}
{props.SidebarContainer ? ( cloneElement(props.SidebarContainer, { bannersHeight }) ) : ( )}
); }; type DrawerState = [isOpen: boolean, setDrawerOpen: Dispatch>]; export type LayoutProps = { centered?: boolean; title?: string; description?: string; heading?: ReactNode; subtitle?: ReactNode; headerClassName?: string; children: ReactNode; CTA?: ReactNode; large?: boolean; MobileNavigationContainer?: ReactNode; SidebarContainer?: ReactElement; TopNavContainer?: ReactNode; drawerState?: DrawerState; HeadingLeftIcon?: ReactNode; backPath?: string | boolean; // renders back button to specified path // use when content needs to expand with flex flexChildrenContainer?: boolean; isPublic?: boolean; withoutMain?: boolean; // Gives you the option to skip HeadSEO and render your own. withoutSeo?: boolean; // Gives the ability to include actions to the right of the heading actions?: JSX.Element; beforeCTAactions?: JSX.Element; afterHeading?: ReactNode; smallHeading?: boolean; hideHeadingOnMobile?: boolean; isPlatformUser?: boolean; }; const useAppTheme = () => { const { data: user } = useMeQuery(); const brandTheme = getBrandColours({ lightVal: user?.brandColor, darkVal: user?.darkBrandColor, }); useCalcomTheme(brandTheme); useTheme(user?.appTheme); }; const KBarWrapper = ({ children, withKBar = false }: { withKBar: boolean; children: React.ReactNode }) => withKBar ? ( {children} ) : ( <>{children} ); const PublicShell = (props: LayoutProps) => { const { status } = useSession(); return ( ); }; export default function Shell(props: LayoutProps) { // if a page is unauthed and isPublic is true, the redirect does not happen. useRedirectToLoginIfUnauthenticated(props.isPublic); useRedirectToOnboardingIfNeeded(); useAppTheme(); return !props.isPublic ? ( ) : ( ); } interface UserDropdownProps { small?: boolean; } function UserDropdown({ small }: UserDropdownProps) { const { isPlatformUser } = useGetUserAttributes(); const { t } = useLocale(); const { data: user } = useMeQuery(); const utils = trpc.useUtils(); const bookerUrl = useBookerUrl(); useEffect(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore const Beacon = window.Beacon; // window.Beacon is defined when user actually opens up HelpScout and username is available here. On every re-render update session info, so that it is always latest. Beacon && Beacon("session-data", { username: user?.username || "Unknown", screenResolution: `${screen.width}x${screen.height}`, }); }); const [helpOpen, setHelpOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const onHelpItemSelect = () => { setHelpOpen(false); setMenuOpen(false); }; // Prevent rendering dropdown if user isn't available. // We don't want to show nameless user. if (!user) { return null; } return ( setMenuOpen((menuOpen) => !menuOpen)}> { setMenuOpen(false); setHelpOpen(false); }} className="group overflow-hidden rounded-md"> {helpOpen ? ( onHelpItemSelect()} /> ) : ( <> {!isPlatformUser && ( <> )} {t("help")} )} ); } export type NavigationItemType = { name: string; href: string; onClick?: React.MouseEventHandler; target?: HTMLAnchorElement["target"]; badge?: React.ReactNode; icon?: IconName; child?: NavigationItemType[]; pro?: true; onlyMobile?: boolean; onlyDesktop?: boolean; isCurrent?: ({ item, isChild, pathname, }: { item: Pick; isChild?: boolean; pathname: string | null; }) => boolean; }; const requiredCredentialNavigationItems = ["Routing Forms"]; const MORE_SEPARATOR_NAME = "more"; const navigation: NavigationItemType[] = [ { name: "event_types_page_title", href: "/event-types", icon: "link", }, { name: "bookings", href: "/bookings/upcoming", icon: "calendar", badge: , isCurrent: ({ pathname }) => pathname?.startsWith("/bookings") ?? false, }, { name: "availability", href: "/availability", icon: "clock", }, { name: "teams", href: "/teams", icon: "users", onlyDesktop: true, badge: , }, { name: "apps", href: "/apps", icon: "grid-3x3", isCurrent: ({ pathname: path, item }) => { // During Server rendering path is /v2/apps but on client it becomes /apps(weird..) return (path?.startsWith(item.href) ?? false) && !(path?.includes("routing-forms/") ?? false); }, child: [ { name: "app_store", href: "/apps", isCurrent: ({ pathname: path, item }) => { // During Server rendering path is /v2/apps but on client it becomes /apps(weird..) return ( (path?.startsWith(item.href) ?? false) && !(path?.includes("routing-forms/") ?? false) && !(path?.includes("/installed") ?? false) ); }, }, { name: "installed_apps", href: "/apps/installed/calendar", isCurrent: ({ pathname: path }) => (path?.startsWith("/apps/installed/") ?? false) || (path?.startsWith("/v2/apps/installed/") ?? false), }, ], }, { name: MORE_SEPARATOR_NAME, href: "/more", icon: "ellipsis", }, //{ //Auskommentiert, da Enterprice feature // name: "Routing Forms", // href: "/apps/routing-forms/forms", // icon: "file-text", // isCurrent: ({ pathname }) => pathname?.startsWith("/apps/routing-forms/") ?? false, //}, { name: "workflows", href: "/workflows", icon: "zap", }, { name: "insights", href: "/insights", icon: "bar-chart", }, ]; const platformNavigation: NavigationItemType[] = [ { name: "Dashboard", href: "/settings/platform/", icon: "layout-dashboard", }, { name: "Documentation", href: "https://docs.cal.com/docs/platform", icon: "bar-chart", target: "_blank", }, { name: "API reference", href: "https://api.cal.com/v2/docs#/", icon: "terminal", target: "_blank", }, { name: "Atoms", href: "https://docs.cal.com/docs/platform#atoms", icon: "atom", target: "_blank", }, { name: MORE_SEPARATOR_NAME, href: "https://docs.cal.com/docs/platform/faq", icon: "ellipsis", target: "_blank", }, ]; const getDesktopNavigationItems = (isPlatformNavigation = false) => { const navigationType = !isPlatformNavigation ? navigation : platformNavigation; const moreSeparatorIndex = navigationType.findIndex((item) => item.name === MORE_SEPARATOR_NAME); const { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems } = ( !isPlatformNavigation ? navigation : platformNavigation ).reduce>( (items, item, index) => { // We filter out the "more" separator in` desktop navigation if (item.name !== MORE_SEPARATOR_NAME) items.desktopNavigationItems.push(item); // Items for mobile bottom navigation if (index < moreSeparatorIndex + 1 && !item.onlyDesktop) { items.mobileNavigationBottomItems.push(item); } // Items for the "more" menu in mobile navigation else { items.mobileNavigationMoreItems.push(item); } return items; }, { desktopNavigationItems: [], mobileNavigationBottomItems: [], mobileNavigationMoreItems: [] } ); return { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems }; }; const Navigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => { const { desktopNavigationItems } = getDesktopNavigationItems(isPlatformNavigation); return ( ); }; function useShouldDisplayNavigationItem(item: NavigationItemType) { const flags = useFlagMap(); if (isKeyInObject(item.name, flags)) return flags[item.name]; return true; } const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => { return isChild ? item.href === pathname : item.href ? pathname?.startsWith(item.href) ?? false : false; }; const NavigationItem: React.FC<{ index?: number; item: NavigationItemType; isChild?: boolean; }> = (props) => { const { item, isChild } = props; const { t, isLocaleReady } = useLocale(); const pathname = usePathname(); const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent; const current = isCurrent({ isChild: !!isChild, item, pathname }); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); if (!shouldDisplayNavigationItem) return null; return ( {item.icon && ( {item.child && isCurrent({ pathname, isChild, item }) && item.child.map((item, index) => )} ); }; function MobileNavigationContainer({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) { const { status } = useSession(); if (status !== "authenticated") return null; return ; } const MobileNavigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => { const isEmbed = useIsEmbed(); const { mobileNavigationBottomItems } = getDesktopNavigationItems(isPlatformNavigation); return ( <> {/* add padding to content for mobile navigation*/}
); }; const MobileNavigationItem: React.FC<{ item: NavigationItemType; isChild?: boolean; }> = (props) => { const { item, isChild } = props; const pathname = usePathname(); const { t, isLocaleReady } = useLocale(); const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent; const current = isCurrent({ isChild: !!isChild, item, pathname }); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); if (!shouldDisplayNavigationItem) return null; return ( {item.badge &&
{item.badge}
} {item.icon && (