import type { User } from "@prisma/client"; import noop from "lodash/noop"; import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; import { NextRouter, useRouter } from "next/router"; import React, { Dispatch, Fragment, ReactNode, SetStateAction, useEffect, 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 from "@calcom/features/ee/impersonation/components/ImpersonatingBanner"; import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem"; import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; import { JOIN_SLACK, ROADMAP, DESKTOP_APP_LINK, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import isCalcom from "@calcom/lib/isCalcom"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { SVGComponent } from "@calcom/types/SVGComponent"; import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuPortal, } from "@calcom/ui/Dropdown"; import { Icon } from "@calcom/ui/Icon"; import TimezoneChangeDialog from "@calcom/ui/TimezoneChangeDialog"; import Button from "@calcom/ui/v2/core/Button"; /* TODO: Get this from endpoint */ import pkg from "../../../../apps/web/package.json"; import ErrorBoundary from "../../ErrorBoundary"; import { KBarContent, KBarRoot, KBarTrigger } from "../../Kbar"; import Logo from "../../Logo"; import Tips from "../modules/tips/Tips"; import HeadSeo from "./head-seo"; import { SkeletonText } from "./skeleton"; /* 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) => { return !user.completedOnboarding && 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) { router.replace({ pathname: "/auth/login", query: { callbackUrl: `${WEBAPP_URL}${location.pathname}${location.search}`, }, }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, session, isPublic]); return { loading: loading && !session, session, }; } function useRedirectToOnboardingIfNeeded() { const router = useRouter(); const query = useMeQuery(); const user = query.data; const isRedirectingToOnboarding = user && shouldShowOnboarding(user); useEffect(() => { if (isRedirectingToOnboarding) { router.replace({ pathname: "/getting-started", }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRedirectingToOnboarding]); return { isRedirectingToOnboarding, }; } export function ShellSubHeading(props: { title: ReactNode; subtitle?: ReactNode; actions?: ReactNode; className?: string; }) { return (

{props.title}

{props.subtitle &&

{props.subtitle}

}
{props.actions &&
{props.actions}
}
); } const Layout = (props: LayoutProps) => { const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title; return ( <> {!props.withoutSeo && ( )}
{/* todo: only run this if timezone is different */}
{props.SidebarContainer || }
); }; type DrawerState = [isOpen: boolean, setDrawerOpen: Dispatch>]; type LayoutProps = { centered?: boolean; title?: string; heading?: ReactNode; subtitle?: ReactNode; children: ReactNode; CTA?: ReactNode; large?: boolean; SettingsSidebarContainer?: ReactNode; MobileNavigationContainer?: ReactNode; SidebarContainer?: ReactNode; TopNavContainer?: ReactNode; drawerState?: DrawerState; HeadingLeftIcon?: ReactNode; backPath?: string; // 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; }; const CustomBrandingContainer = () => { const { data: user } = useMeQuery(); return ; }; export default function Shell(props: LayoutProps) { useRedirectToLoginIfUnauthenticated(props.isPublic); useRedirectToOnboardingIfNeeded(); useTheme("light"); const { session } = useRedirectToLoginIfUnauthenticated(props.isPublic); if (!session && !props.isPublic) return null; return ( ); } function UserDropdown({ small }: { small?: boolean }) { const { t } = useLocale(); const query = useMeQuery(); const user = query.data; 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 mutation = trpc.useMutation("viewer.away", { onSettled() { utils.invalidateQueries("viewer.me"); }, }); const utils = trpc.useContext(); const [helpOpen, setHelpOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); if (!user) { return null; } 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(true)}> { setMenuOpen(false); setHelpOpen(false); }} className="overflow-hidden rounded-md"> {helpOpen ? ( onHelpItemSelect()} /> ) : ( <> {user.username && ( {" "} {t("view_public_page")} )} {" "} {t("join_our_slack")} {t("visit_roadmap")} {" "} {t("download_desktop_app")} signOut({ callbackUrl: "/auth/logout" })} className="flex cursor-pointer items-center px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"> )} ); } export type NavigationItemType = { name: string; href: string; badge?: React.ReactNode; icon?: SVGComponent; child?: NavigationItemType[]; pro?: true; onlyMobile?: boolean; onlyDesktop?: boolean; isCurrent?: ({ item, isChild, router, }: { item: NavigationItemType; isChild?: boolean; router: NextRouter; }) => boolean; }; const requiredCredentialNavigationItems = ["Routing Forms"]; const MORE_SEPARATOR_NAME = "more"; const navigation: NavigationItemType[] = [ { name: "event_types_page_title", href: "/event-types", icon: Icon.FiLink, }, { name: "bookings", href: "/bookings/upcoming", icon: Icon.FiCalendar, badge: , }, { name: "availability", href: "/availability", icon: Icon.FiClock, }, { name: "teams", href: "/teams", icon: Icon.FiUsers, onlyDesktop: true, }, { name: "apps", href: "/apps", icon: Icon.FiGrid, isCurrent: ({ router, item }) => { const path = router.asPath.split("?")[0]; // During Server rendering path is /v2/apps but on client it becomes /apps(weird..) return ( (path.startsWith(item.href) || path.startsWith("/v2" + item.href)) && !path.includes("routing-forms/") ); }, child: [ { name: "app_store", href: "/apps", isCurrent: ({ router, item }) => { const path = router.asPath.split("?")[0]; // During Server rendering path is /v2/apps but on client it becomes /apps(weird..) return ( (path.startsWith(item.href) || path.startsWith("/v2" + item.href)) && !path.includes("routing-forms/") && !path.includes("/installed") ); }, }, { name: "installed_apps", href: "/apps/installed/calendar", isCurrent: ({ router }) => { const path = router.asPath; return path.startsWith("/apps/installed/") || path.startsWith("/v2/apps/installed/"); }, }, ], }, { name: MORE_SEPARATOR_NAME, href: "/more", icon: Icon.FiMoreHorizontal, }, { name: "Routing Forms", href: "/apps/routing-forms/forms", icon: Icon.FiFileText, isCurrent: ({ router }) => { return router.asPath.startsWith("/apps/routing-forms/"); }, }, { name: "workflows", href: "/workflows", icon: Icon.FiZap, }, { name: "settings", href: "/settings", icon: Icon.FiSettings, }, ]; const moreSeparatorIndex = navigation.findIndex((item) => item.name === MORE_SEPARATOR_NAME); // We create all needed navigation items for the different use cases const { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems } = navigation.reduce< Record >( (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: [] } ); const Navigation = () => { return ( ); }; function useShouldDisplayNavigationItem(item: NavigationItemType) { const { status } = useSession(); const { data: routingForms } = trpc.useQuery(["viewer.appById", { appId: "routing-forms" }], { enabled: status === "authenticated" && requiredCredentialNavigationItems.includes(item.name), }); return !requiredCredentialNavigationItems.includes(item.name) || routingForms?.isInstalled; } const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, router }) => { return isChild ? item.href === router.asPath : router.asPath.startsWith(item.href); }; const NavigationItem: React.FC<{ item: NavigationItemType; isChild?: boolean; }> = (props) => { const { item, isChild } = props; const { t, isLocaleReady } = useLocale(); const router = useRouter(); const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent; const current = isCurrent({ isChild: !!isChild, item, router }); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); if (!shouldDisplayNavigationItem) return null; return ( {item.child && isCurrent({ router, isChild, item }) && item.child.map((item) => )} ); }; function MobileNavigationContainer() { const { status } = useSession(); if (status !== "authenticated") return null; return ; } const MobileNavigation = () => { const isEmbed = useIsEmbed(); return ( <> {/* add padding to content for mobile navigation*/}
); }; const MobileNavigationItem: React.FC<{ item: NavigationItemType; isChild?: boolean; }> = (props) => { const { item, isChild } = props; const router = useRouter(); const { t, isLocaleReady } = useLocale(); const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent; const current = isCurrent({ isChild: !!isChild, item, router }); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); if (!shouldDisplayNavigationItem) return null; return ( {item.badge &&
{item.badge}
} {item.icon && (
); }; const MobileNavigationMoreItem: React.FC<{ item: NavigationItemType; isChild?: boolean; }> = (props) => { const { item } = props; const { t, isLocaleReady } = useLocale(); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); if (!shouldDisplayNavigationItem) return null; return (
  • {item.icon && (
  • ); }; function DeploymentInfo() { const query = useMeQuery(); const user = query.data; return ( © {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"} {process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? "h" : "sh"} -{user?.plan} ); } function SideBarContainer() { const { status } = useSession(); const router = useRouter(); // Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning. // This improves the experience of refresh on app store pages(when logged in) which are SSG. // Though when logged out, app store pages would temporarily show SideBar until session status is confirmed. if (status !== "loading" && status !== "authenticated") return null; if (router.route.startsWith("/v2/settings/")) return null; return ; } function SideBar() { return ( ); } export function ShellMain(props: LayoutProps) { const router = useRouter(); const { isLocaleReady } = useLocale(); return ( <>
    {!!props.backPath && (
    {props.children}
    ); } const SettingsSidebarContainerDefault = () => null; function MainContainer({ SettingsSidebarContainer: SettingsSidebarContainerProp = , MobileNavigationContainer: MobileNavigationContainerProp = , TopNavContainer: TopNavContainerProp = , ...props }: LayoutProps) { const [sideContainerOpen, setSideContainerOpen] = props.drawerState || [false, noop]; return (
    {/* show top navigation for md and smaller (tablet and phones) */} {TopNavContainerProp} {/* The following is used for settings navigation on medium and smaller screens */}
    { setSideContainerOpen(false); }} /> {SettingsSidebarContainerProp}
    {/* add padding to top for mobile when App Bar is fixed */}
    {!props.withoutMain ? {props.children} : props.children} {/* show bottom navigation for md and smaller (tablet and phones) on pages where back button doesn't exist */} {!props.backPath ? MobileNavigationContainerProp : null}
    ); } function TopNavContainer() { const { status } = useSession(); if (status !== "authenticated") return null; return ; } function TopNav() { const isEmbed = useIsEmbed(); const { t } = useLocale(); return ( <> ); } export const MobileNavigationMoreItems = () => (
      {mobileNavigationMoreItems.map((item) => ( ))}
    );