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, useRef, 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 { OrgUpgradeBanner } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner"; import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem"; import { TeamsUpgradeBanner } 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 from "@calcom/features/users/components/AdminPasswordBanner"; import VerifyEmailBanner from "@calcom/features/users/components/VerifyEmailBanner"; import classNames from "@calcom/lib/classNames"; import { APP_NAME, DESKTOP_APP_LINK, JOIN_DISCORD, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants"; import getBrandColours from "@calcom/lib/getBrandColours"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useIsomorphicLayoutEffect } from "@calcom/lib/hooks/useIsomorphicLayoutEffect"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { isKeyInObject } from "@calcom/lib/isKeyInObject"; 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 type { SVGComponent } from "@calcom/types/SVGComponent"; import { Avatar, Button, ButtonOrLink, Credits, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger, ErrorBoundary, HeadSeo, Logo, showToast, SkeletonText, Tooltip, useCalcomTheme, } from "@calcom/ui"; import { ArrowLeft, ArrowRight, BarChart, Calendar, ChevronDown, Clock, Copy, Download, ExternalLink, FileText, Grid, HelpCircle, Link as LinkIcon, LogOut, Map, Moon, MoreHorizontal, Settings, User as UserIcon, Users, Zap, } from "@calcom/ui/components/icon"; import { Discord } from "@calcom/ui/components/icon/Discord"; import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants"; 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 ) => { 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, }; } function AppTop({ setBannersHeight }: { setBannersHeight: Dispatch> }) { const bannerRef = useRef(null); useIsomorphicLayoutEffect(() => { const resizeObserver = new ResizeObserver((entries) => { const { offsetHeight } = entries[0].target as HTMLElement; setBannersHeight(offsetHeight); }); const currentBannerRef = bannerRef.current; if (currentBannerRef) { resizeObserver.observe(currentBannerRef); } return () => { if (currentBannerRef) { resizeObserver.unobserve(currentBannerRef); } }; }, [bannerRef]); return (
); } 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, }; } const Layout = (props: LayoutProps) => { const [bannersHeight, setBannersHeight] = useState(0); 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 ? ( cloneElement(props.SidebarContainer, { bannersHeight }) ) : ( )}
); }; type DrawerState = [isOpen: boolean, setDrawerOpen: Dispatch>]; type LayoutProps = { centered?: boolean; title?: 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; }; const useBrandColors = () => { const { data: user } = useMeQuery(); const brandTheme = getBrandColours({ lightVal: user?.brandColor, darkVal: user?.darkBrandColor, }); useCalcomTheme(brandTheme); }; 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(); // System Theme is automatically supported using ThemeProvider. If we intend to use user theme throughout the app we need to uncomment this. // useTheme(profile.theme); useBrandColors(); return !props.isPublic ? ( ) : ( ); } interface UserDropdownProps { small?: boolean; } function UserDropdown({ small }: UserDropdownProps) { const { t } = useLocale(); const { data: user } = useMeQuery(); const utils = trpc.useContext(); 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 mutation = trpc.viewer.away.useMutation({ onMutate: async ({ away }) => { await utils.viewer.me.cancel(); const previousValue = utils.viewer.me.getData(); if (previousValue) { utils.viewer.me.setData(undefined, { ...previousValue, away }); } return { previousValue }; }, onError: (_, __, context) => { if (context?.previousValue) { utils.viewer.me.setData(undefined, context.previousValue); } showToast(t("toggle_away_error"), "error"); }, onSettled() { utils.viewer.me.invalidate(); }, }); 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()} /> ) : ( <> ( ( ( } target="_blank" rel="noreferrer" href={JOIN_DISCORD}> {t("join_our_discord")} {t("visit_roadmap")} {t("download_desktop_app")} )} ); } export type NavigationItemType = { name: string; href: string; onClick?: React.MouseEventHandler; target?: HTMLAnchorElement["target"]; badge?: React.ReactNode; icon?: SVGComponent; child?: NavigationItemType[]; pro?: true; onlyMobile?: boolean; onlyDesktop?: boolean; isCurrent?: ({ item, isChild, pathname, }: { item: Pick; isChild?: boolean; pathname: string; }) => boolean; }; const requiredCredentialNavigationItems = ["Routing Forms"]; const MORE_SEPARATOR_NAME = "more"; const navigation: NavigationItemType[] = [ { name: "event_types_page_title", href: "/event-types", icon: LinkIcon, }, { name: "bookings", href: "/bookings/upcoming", icon: Calendar, badge: , isCurrent: ({ pathname }) => pathname?.startsWith("/bookings"), }, { name: "availability", href: "/availability", icon: Clock, }, { name: "teams", href: "/teams", icon: Users, onlyDesktop: true, badge: , }, { name: "apps", href: "/apps", icon: Grid, isCurrent: ({ pathname: path, item }) => { // During Server rendering path is /v2/apps but on client it becomes /apps(weird..) return path?.startsWith(item.href) && !path?.includes("routing-forms/"); }, 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) && !path?.includes("routing-forms/") && !path?.includes("/installed") ); }, }, { name: "installed_apps", href: "/apps/installed/calendar", isCurrent: ({ pathname: path }) => path?.startsWith("/apps/installed/") || path?.startsWith("/v2/apps/installed/"), }, ], }, { name: MORE_SEPARATOR_NAME, href: "/more", icon: MoreHorizontal, }, { name: "Routing Forms", href: "/apps/routing-forms/forms", icon: FileText, isCurrent: ({ pathname }) => pathname?.startsWith("/apps/routing-forms/"), }, { name: "workflows", href: "/workflows", icon: Zap, }, { name: "insights", href: "/insights", icon: BarChart, }, ]; 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 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; }; 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() { 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 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 && (