chore: refactor shell to individual files (#16893)
* banners * useAuthHooks * fixes to redirect and banner * extract useIntercom to custom hook * use app theme * extract user-dropdown to new component * Navigation Item * navigation and profile dropdown * Fix import * navigation and sidebar * fix type errors * fix banners being an async call * fix types * fix banner prop type * fix mobile nav item import * fix banner types * (revert) layout banner render method to fix type error --------- Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
16 files changed
+1353
-1152
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Shell, { MobileNavigationMoreItems } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { MobileNavigationMoreItems } from "@calcom/features/shell/navigation/Navigation";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function MorePage() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, Page } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { User } from "@prisma/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { useEmailVerifyCheck } from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
const shouldShowOnboarding = (
|
||||
user: Pick<User, "createdDate" | "completedOnboarding"> & {
|
||||
organizationId: number | null;
|
||||
}
|
||||
) => {
|
||||
return (
|
||||
!user.completedOnboarding &&
|
||||
!user.organizationId &&
|
||||
dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT)
|
||||
);
|
||||
};
|
||||
|
||||
export const ONBOARDING_INTRODUCED_AT = dayjs("September 1 2021").toISOString();
|
||||
|
||||
export const ONBOARDING_NEXT_REDIRECT = {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/getting-started",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useEffect } from "react";
|
||||
import { useIntercom as useIntercomLib } from "react-use-intercom";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useHasTeamPlan, useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
@@ -117,4 +119,16 @@ export const useIntercom = () => {
|
||||
return { ...hookData, open, boot };
|
||||
};
|
||||
|
||||
export const useBootIntercom = () => {
|
||||
const { boot } = useIntercom();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
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]);
|
||||
};
|
||||
|
||||
export default useIntercom;
|
||||
+18
-1150
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,342 @@
|
||||
import type { User as UserAuth } from "next-auth";
|
||||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { IS_CALCOM, IS_VISUAL_REGRESSION_TESTING, ENABLE_PROFILE_SWITCHER } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonOrLink,
|
||||
Credits,
|
||||
Icon,
|
||||
SkeletonText,
|
||||
Tooltip,
|
||||
Logo,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { KBarTrigger } from "../kbar/Kbar";
|
||||
import type { LayoutProps } from "./Shell";
|
||||
import { Navigation } from "./navigation/Navigation";
|
||||
import { type NavigationItemType } from "./navigation/NavigationItem";
|
||||
import { ProfileDropdown } from "./user-dropdown/ProfileDropdown";
|
||||
import { UserDropdown } from "./user-dropdown/UserDropdown";
|
||||
|
||||
// need to import without ssr to prevent hydration errors
|
||||
const Tips = dynamic(() => import("@calcom/features/tips").then((mod) => mod.Tips), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export type SideBarContainerProps = {
|
||||
bannersHeight: number;
|
||||
isPlatformUser?: boolean;
|
||||
};
|
||||
|
||||
export type SideBarProps = {
|
||||
bannersHeight: number;
|
||||
user?: UserAuth | null;
|
||||
isPlatformUser?: boolean;
|
||||
};
|
||||
|
||||
export function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarContainerProps) {
|
||||
const { status, data } = useSession();
|
||||
|
||||
// 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;
|
||||
return <SideBar isPlatformUser={isPlatformUser} bannersHeight={bannersHeight} user={data?.user} />;
|
||||
}
|
||||
|
||||
export function SideBar({ bannersHeight, user }: SideBarProps) {
|
||||
const { fetchAndCopyToClipboard } = useCopy();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const orgBranding = useOrgBranding();
|
||||
const pathname = usePathname();
|
||||
const isPlatformPages = pathname?.startsWith("/settings/platform");
|
||||
const [isReferalLoading, setIsReferalLoading] = useState(false);
|
||||
|
||||
const publicPageUrl = useMemo(() => {
|
||||
if (!user?.org?.id) return `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user?.username}`;
|
||||
const publicPageUrl = orgBranding?.slug ? getOrgFullOrigin(orgBranding.slug) : "";
|
||||
return publicPageUrl;
|
||||
}, [orgBranding?.slug, user?.username, user?.org?.id]);
|
||||
|
||||
const sidebarStylingAttributes = {
|
||||
maxHeight: `calc(100vh - ${bannersHeight}px)`,
|
||||
top: `${bannersHeight}px`,
|
||||
};
|
||||
|
||||
// Todo: extract this to a hook
|
||||
const bottomNavItems: NavigationItemType[] = [
|
||||
{
|
||||
name: "view_public_page",
|
||||
href: publicPageUrl,
|
||||
icon: "external-link",
|
||||
target: "__blank",
|
||||
},
|
||||
{
|
||||
name: "copy_public_page_link",
|
||||
href: "",
|
||||
onClick: (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(publicPageUrl);
|
||||
showToast(t("link_copied"), "success");
|
||||
},
|
||||
icon: "copy",
|
||||
},
|
||||
IS_CALCOM
|
||||
? {
|
||||
name: "copy_referral_link",
|
||||
href: "",
|
||||
onClick: (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
setIsReferalLoading(true);
|
||||
// Create an artificial delay to show the loading state so it doesnt flicker if this request is fast
|
||||
setTimeout(() => {
|
||||
fetchAndCopyToClipboard(
|
||||
fetch("/api/generate-referral-link", {
|
||||
method: "POST",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => res.shortLink),
|
||||
{
|
||||
onSuccess: () => showToast(t("link_copied"), "success"),
|
||||
onFailure: () => showToast("Copy to clipboard failed", "error"),
|
||||
}
|
||||
);
|
||||
setIsReferalLoading(false);
|
||||
}, 1000);
|
||||
},
|
||||
icon: "gift",
|
||||
isLoading: isReferalLoading,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
name: "settings",
|
||||
href: user?.org ? `/settings/organizations/profile` : "/settings/my-account/profile",
|
||||
icon: "settings",
|
||||
},
|
||||
].filter(Boolean) as NavigationItemType[];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<aside
|
||||
style={!isPlatformPages ? sidebarStylingAttributes : {}}
|
||||
className={classNames(
|
||||
"bg-muted border-muted fixed left-0 hidden h-full w-14 flex-col overflow-y-auto overflow-x-hidden border-r md:sticky md:flex lg:w-56 lg:px-3",
|
||||
!isPlatformPages && "max-h-screen"
|
||||
)}>
|
||||
<div className="flex h-full flex-col justify-between py-3 lg:pt-4">
|
||||
<header className="todesktop:-mt-3 todesktop:flex-col-reverse todesktop:[-webkit-app-region:drag] items-center justify-between md:hidden lg:flex">
|
||||
{orgBranding ? (
|
||||
!ENABLE_PROFILE_SWITCHER ? (
|
||||
<Link href="/settings/organizations/profile" className="w-full px-1.5">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Avatar
|
||||
alt={`${orgBranding.name} logo`}
|
||||
imageSrc={getPlaceholderAvatar(orgBranding.logoUrl, orgBranding.name)}
|
||||
size="xsm"
|
||||
/>
|
||||
<p className="text line-clamp-1 text-sm">
|
||||
<span>{orgBranding.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<ProfileDropdown />
|
||||
)
|
||||
) : (
|
||||
<div data-testid="user-dropdown-trigger" className="todesktop:mt-4 w-full">
|
||||
<span className="hidden lg:inline">
|
||||
<UserDropdown />
|
||||
</span>
|
||||
<span className="hidden md:inline lg:hidden">
|
||||
<UserDropdown small />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full justify-end rtl:space-x-reverse">
|
||||
<button
|
||||
color="minimal"
|
||||
onClick={() => window.history.back()}
|
||||
className="todesktop:block hover:text-emphasis text-subtle group hidden text-sm font-medium">
|
||||
<Icon
|
||||
name="arrow-left"
|
||||
className="group-hover:text-emphasis text-subtle h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
color="minimal"
|
||||
onClick={() => window.history.forward()}
|
||||
className="todesktop:block hover:text-emphasis text-subtle group hidden text-sm font-medium">
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
className="group-hover:text-emphasis text-subtle h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
{!!orgBranding && (
|
||||
<div data-testid="user-dropdown-trigger" className="flex items-center">
|
||||
<UserDropdown small />
|
||||
</div>
|
||||
)}
|
||||
<KBarTrigger />
|
||||
</div>
|
||||
</header>
|
||||
{/* logo icon for tablet */}
|
||||
<Link href="/event-types" className="text-center md:inline lg:hidden">
|
||||
<Logo small icon />
|
||||
</Link>
|
||||
<Navigation isPlatformNavigation={isPlatformPages} />
|
||||
</div>
|
||||
|
||||
{!isPlatformPages && (
|
||||
<div>
|
||||
<Tips />
|
||||
{bottomNavItems.map((item, index) => (
|
||||
<Tooltip side="right" content={t(item.name)} className="lg:hidden" key={item.name}>
|
||||
<ButtonOrLink
|
||||
id={item.name}
|
||||
href={item.href || undefined}
|
||||
aria-label={t(item.name)}
|
||||
target={item.target}
|
||||
className={classNames(
|
||||
"text-left",
|
||||
"[&[aria-current='page']]:bg-emphasis text-default justify-right group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
|
||||
"[&[aria-current='page']]:text-emphasis mt-0.5 w-full text-sm",
|
||||
isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : "",
|
||||
index === 0 && "mt-3"
|
||||
)}
|
||||
onClick={item.onClick}>
|
||||
{!!item.icon && (
|
||||
<Icon
|
||||
name={item.isLoading ? "rotate-cw" : item.icon}
|
||||
className={classNames(
|
||||
"h-4 w-4 flex-shrink-0 [&[aria-current='page']]:text-inherit",
|
||||
"me-3 md:mx-auto lg:ltr:mr-2 lg:rtl:ml-2",
|
||||
item.isLoading && "animate-spin"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{isLocaleReady ? (
|
||||
<span className="hidden w-full justify-between lg:flex">
|
||||
<div className="flex">{t(item.name)}</div>
|
||||
</span>
|
||||
) : (
|
||||
<SkeletonText className="h-[20px] w-full" />
|
||||
)}
|
||||
</ButtonOrLink>
|
||||
</Tooltip>
|
||||
))}
|
||||
{!IS_VISUAL_REGRESSION_TESTING && <Credits />}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShellMain(props: LayoutProps) {
|
||||
const router = useRouter();
|
||||
const { isLocaleReady, t } = useLocale();
|
||||
|
||||
const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications();
|
||||
|
||||
return (
|
||||
<>
|
||||
{(props.heading || !!props.backPath) && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center md:mb-6 md:mt-0",
|
||||
props.smallHeading ? "lg:mb-7" : "lg:mb-8",
|
||||
props.hideHeadingOnMobile ? "mb-0" : "mb-6"
|
||||
)}>
|
||||
{!!props.backPath && (
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="minimal"
|
||||
onClick={() =>
|
||||
typeof props.backPath === "string" ? router.push(props.backPath as string) : router.back()
|
||||
}
|
||||
StartIcon="arrow-left"
|
||||
aria-label="Go Back"
|
||||
className="rounded-md ltr:mr-2 rtl:ml-2"
|
||||
data-testid="go-back-button"
|
||||
/>
|
||||
)}
|
||||
{props.heading && (
|
||||
<header
|
||||
className={classNames(props.large && "py-8", "flex w-full max-w-full items-center truncate")}>
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div
|
||||
className={classNames("w-full truncate ltr:mr-4 rtl:ml-4 md:block", props.headerClassName)}>
|
||||
{props.heading && (
|
||||
<h3
|
||||
className={classNames(
|
||||
"font-cal max-w-28 sm:max-w-72 md:max-w-80 text-emphasis inline truncate text-lg font-semibold tracking-wide sm:text-xl md:block xl:max-w-full",
|
||||
props.smallHeading ? "text-base" : "text-xl",
|
||||
props.hideHeadingOnMobile && "hidden"
|
||||
)}>
|
||||
{!isLocaleReady ? <SkeletonText invisible /> : props.heading}
|
||||
</h3>
|
||||
)}
|
||||
{props.subtitle && (
|
||||
<p className="text-default hidden text-sm md:block" data-testid="subtitle">
|
||||
{!isLocaleReady ? <SkeletonText invisible /> : props.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{props.beforeCTAactions}
|
||||
{props.CTA && (
|
||||
<div
|
||||
className={classNames(
|
||||
props.backPath
|
||||
? "relative"
|
||||
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
|
||||
"flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
|
||||
)}>
|
||||
{isLocaleReady && props.CTA}
|
||||
</div>
|
||||
)}
|
||||
{props.actions && props.actions}
|
||||
{props.heading === "Bookings" && buttonToShow && (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={buttonToShow === ButtonState.ALLOW ? enableNotifications : disableNotifications}
|
||||
loading={isLoading}
|
||||
disabled={buttonToShow === ButtonState.DENIED}
|
||||
tooltipSide="bottom"
|
||||
tooltip={
|
||||
buttonToShow === ButtonState.DENIED ? t("you_have_denied_notifications") : undefined
|
||||
}>
|
||||
{t(
|
||||
buttonToShow === ButtonState.DISABLE
|
||||
? "disable_browser_notifications"
|
||||
: "allow_browser_notifications"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.afterHeading && <>{props.afterHeading}</>}
|
||||
<div className={classNames(props.flexChildrenContainer && "flex flex-1 flex-col")}>
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { KBarTrigger } from "@calcom/features/kbar/Kbar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Logo } from "@calcom/ui";
|
||||
|
||||
import { UserDropdown } from "./user-dropdown/UserDropdown";
|
||||
|
||||
export function TopNavContainer() {
|
||||
const { status } = useSession();
|
||||
if (status !== "authenticated") return null;
|
||||
return <TopNav />;
|
||||
}
|
||||
|
||||
function TopNav() {
|
||||
const isEmbed = useIsEmbed();
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
style={isEmbed ? { display: "none" } : {}}
|
||||
className="bg-muted border-subtle sticky top-0 z-40 flex w-full items-center justify-between border-b bg-opacity-50 px-4 py-1.5 backdrop-blur-lg sm:p-4 md:hidden">
|
||||
<Link href="/event-types">
|
||||
<Logo />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 self-center">
|
||||
<span className="hover:bg-muted hover:text-emphasis text-default group flex items-center rounded-full text-sm font-medium transition lg:hidden">
|
||||
<KBarTrigger />
|
||||
</span>
|
||||
<button className="hover:bg-muted hover:text-subtle text-muted rounded-full p-1 transition focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
|
||||
<span className="sr-only">{t("settings")}</span>
|
||||
<Link href="/settings/my-account/profile">
|
||||
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</button>
|
||||
<UserDropdown small />
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import ImpersonatingBanner, {
|
||||
type ImpersonatingBannerProps,
|
||||
} from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
|
||||
import {
|
||||
OrgUpgradeBanner,
|
||||
type OrgUpgradeBannerProps,
|
||||
} from "@calcom/features/ee/organizations/components/OrgUpgradeBanner";
|
||||
import { TeamsUpgradeBanner, type TeamsUpgradeBannerProps } from "@calcom/features/ee/teams/components";
|
||||
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";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type AllBannerProps = { [Key in BannerType]: BannerTypeProps[Key]["data"] };
|
||||
|
||||
export const BannerComponent: BannerComponent = {
|
||||
teamUpgradeBanner: (props: TeamsUpgradeBannerProps) => <TeamsUpgradeBanner {...props} />,
|
||||
orgUpgradeBanner: (props: OrgUpgradeBannerProps) => <OrgUpgradeBanner {...props} />,
|
||||
verifyEmailBanner: (props: VerifyEmailBannerProps) => <VerifyEmailBanner {...props} />,
|
||||
adminPasswordBanner: (props: AdminPasswordBannerProps) => <AdminPasswordBanner {...props} />,
|
||||
impersonationBanner: (props: ImpersonatingBannerProps) => <ImpersonatingBanner {...props} />,
|
||||
calendarCredentialBanner: (props: CalendarCredentialBannerProps) => <CalendarCredentialBanner {...props} />,
|
||||
invalidAppCredentialBanners: (props: InvalidAppCredentialBannersProps) => (
|
||||
<InvalidAppCredentialBanners {...props} />
|
||||
),
|
||||
};
|
||||
|
||||
interface BannerContainerProps {
|
||||
banners: AllBannerProps;
|
||||
}
|
||||
|
||||
export const BannerContainer: React.FC<BannerContainerProps> = ({ banners }) => {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 w-full divide-y divide-black">
|
||||
{Object.keys(banners).map((key) => {
|
||||
if (key === "teamUpgradeBanner") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
} else if (key === "orgUpgradeBanner") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
} else if (key === "verifyEmailBanner") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
} else if (key === "adminPasswordBanner") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
} else if (key === "impersonationBanner") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
} else if (key === "calendarCredentialBanner") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
} else if (key === "invalidAppCredentialBanners") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { TOP_BANNER_HEIGHT } from "@calcom/lib/constants";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { type AllBannerProps } from "./LayoutBanner";
|
||||
|
||||
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 useBannersHeight = (banners: AllBannerProps | null) => {
|
||||
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]);
|
||||
|
||||
return bannersHeight;
|
||||
};
|
||||
|
||||
const useBanners = () => {
|
||||
const banners = _useBanners();
|
||||
const bannersHeight = useBannersHeight(banners);
|
||||
return { banners, bannersHeight };
|
||||
};
|
||||
|
||||
export { useBanners, useBannersHeight };
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge";
|
||||
import { KBarTrigger } from "@calcom/features/kbar/Kbar";
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import { TeamInviteBadge } from "../TeamInviteBadge";
|
||||
import type { NavigationItemType } from "./NavigationItem";
|
||||
import { NavigationItem, MobileNavigationItem, MobileNavigationMoreItem } from "./NavigationItem";
|
||||
|
||||
export 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: <UnconfirmedBookingBadge />,
|
||||
isCurrent: ({ pathname }) => pathname?.startsWith("/bookings") ?? false,
|
||||
},
|
||||
{
|
||||
name: "availability",
|
||||
href: "/availability",
|
||||
icon: "clock",
|
||||
},
|
||||
{
|
||||
name: "teams",
|
||||
href: "/teams",
|
||||
icon: "users",
|
||||
onlyDesktop: true,
|
||||
badge: <TeamInviteBadge />,
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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: "chart-bar",
|
||||
},
|
||||
];
|
||||
|
||||
const platformNavigation: NavigationItemType[] = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
href: "/settings/platform/",
|
||||
icon: "layout-dashboard",
|
||||
},
|
||||
{
|
||||
name: "Documentation",
|
||||
href: "https://docs.cal.com/docs/platform",
|
||||
icon: "chart-bar",
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
export 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<Record<string, NavigationItemType[]>>(
|
||||
(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 };
|
||||
};
|
||||
|
||||
export const Navigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => {
|
||||
const { desktopNavigationItems } = getDesktopNavigationItems(isPlatformNavigation);
|
||||
|
||||
return (
|
||||
<nav className="mt-2 flex-1 md:px-2 lg:mt-4 lg:px-0">
|
||||
{desktopNavigationItems.map((item) => (
|
||||
<NavigationItem key={item.name} item={item} />
|
||||
))}
|
||||
<div className="text-subtle mt-0.5 lg:hidden">
|
||||
<KBarTrigger />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export function MobileNavigationContainer({
|
||||
isPlatformNavigation = false,
|
||||
}: {
|
||||
isPlatformNavigation?: boolean;
|
||||
}) {
|
||||
const { status } = useSession();
|
||||
if (status !== "authenticated") return null;
|
||||
return <MobileNavigation isPlatformNavigation={isPlatformNavigation} />;
|
||||
}
|
||||
|
||||
const MobileNavigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => {
|
||||
const isEmbed = useIsEmbed();
|
||||
const { mobileNavigationBottomItems } = getDesktopNavigationItems(isPlatformNavigation);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={classNames(
|
||||
"pwa:pb-[max(0.625rem,env(safe-area-inset-bottom))] pwa:-mx-2 bg-muted border-subtle fixed bottom-0 left-0 z-30 flex w-full border-t bg-opacity-40 px-1 shadow backdrop-blur-md md:hidden",
|
||||
isEmbed && "hidden"
|
||||
)}>
|
||||
{mobileNavigationBottomItems.map((item) => (
|
||||
<MobileNavigationItem key={item.name} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
{/* add padding to content for mobile navigation*/}
|
||||
<div className="block pt-12 md:hidden" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileNavigationMoreItems = () => {
|
||||
const { mobileNavigationMoreItems } = getDesktopNavigationItems();
|
||||
|
||||
return (
|
||||
<ul className="border-subtle mt-2 rounded-md border">
|
||||
{mobileNavigationMoreItems.map((item) => (
|
||||
<MobileNavigationMoreItem key={item.name} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Tooltip, Icon, SkeletonText } from "@calcom/ui";
|
||||
import type { IconName } from "@calcom/ui";
|
||||
|
||||
import { useShouldDisplayNavigationItem } from "./useShouldDisplayNavigationItem";
|
||||
|
||||
export type NavigationItemType = {
|
||||
name: string;
|
||||
href: string;
|
||||
isLoading?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>;
|
||||
target?: HTMLAnchorElement["target"];
|
||||
badge?: React.ReactNode;
|
||||
icon?: IconName;
|
||||
child?: NavigationItemType[];
|
||||
pro?: true;
|
||||
onlyMobile?: boolean;
|
||||
onlyDesktop?: boolean;
|
||||
isCurrent?: ({
|
||||
item,
|
||||
isChild,
|
||||
pathname,
|
||||
}: {
|
||||
item: Pick<NavigationItemType, "href">;
|
||||
isChild?: boolean;
|
||||
pathname: string | null;
|
||||
}) => boolean;
|
||||
};
|
||||
|
||||
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => {
|
||||
return isChild ? item.href === pathname : item.href ? pathname?.startsWith(item.href) ?? false : false;
|
||||
};
|
||||
|
||||
export 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 (
|
||||
<Fragment>
|
||||
<Tooltip side="right" content={t(item.name)} className="lg:hidden">
|
||||
<Link
|
||||
data-test-id={item.name}
|
||||
href={item.href}
|
||||
aria-label={t(item.name)}
|
||||
target={item.target}
|
||||
className={classNames(
|
||||
"todesktop:py-[7px] text-default group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
|
||||
item.child ? `[&[aria-current='page']]:!bg-transparent` : `[&[aria-current='page']]:bg-emphasis`,
|
||||
isChild
|
||||
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${
|
||||
props.index === 0 ? "mt-0" : "mt-px"
|
||||
}`
|
||||
: "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
|
||||
isLocaleReady
|
||||
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
|
||||
: ""
|
||||
)}
|
||||
aria-current={current ? "page" : undefined}>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
name={item.isLoading ? "rotate-cw" : item.icon}
|
||||
className={classNames(
|
||||
"todesktop:!text-blue-500 mr-2 h-4 w-4 flex-shrink-0 rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2 [&[aria-current='page']]:text-inherit",
|
||||
item.isLoading && "animate-spin"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
aria-current={current ? "page" : undefined}
|
||||
/>
|
||||
)}
|
||||
{isLocaleReady ? (
|
||||
<span
|
||||
className="hidden w-full justify-between truncate text-ellipsis lg:flex"
|
||||
data-testid={`${item.name}-test`}>
|
||||
{t(item.name)}
|
||||
{item.badge && item.badge}
|
||||
</span>
|
||||
) : (
|
||||
<SkeletonText className="h-[20px] w-full" />
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
{item.child &&
|
||||
isCurrent({ pathname, isChild, item }) &&
|
||||
item.child.map((item, index) => <NavigationItem index={index} key={item.name} item={item} isChild />)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export 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 (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={item.target}
|
||||
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted relative my-2 min-w-0 flex-1 overflow-hidden rounded-md !bg-transparent p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
|
||||
aria-current={current ? "page" : undefined}>
|
||||
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
|
||||
{item.icon && (
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className="[&[aria-current='page']]:text-emphasis mx-auto mb-1 block h-5 w-5 flex-shrink-0 text-center text-inherit"
|
||||
aria-hidden="true"
|
||||
aria-current={current ? "page" : undefined}
|
||||
/>
|
||||
)}
|
||||
{isLocaleReady ? <span className="block truncate">{t(item.name)}</span> : <SkeletonText />}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export 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 (
|
||||
<li className="border-subtle border-b last:border-b-0" key={item.name}>
|
||||
<Link href={item.href} className="hover:bg-subtle flex items-center justify-between p-5 transition">
|
||||
<span className="text-default flex items-center font-semibold ">
|
||||
{item.icon && (
|
||||
<Icon name={item.icon} className="h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
|
||||
)}
|
||||
{isLocaleReady ? t(item.name) : <SkeletonText />}
|
||||
</span>
|
||||
<Icon name="arrow-right" className="text-subtle h-5 w-5" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
|
||||
|
||||
import type { NavigationItemType } from "./NavigationItem";
|
||||
|
||||
export function useShouldDisplayNavigationItem(item: NavigationItemType) {
|
||||
const flags = useFlagMap();
|
||||
if (isKeyInObject(item.name, flags)) return flags[item.name];
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import getBrandColours from "@calcom/lib/getBrandColours";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { useCalcomTheme } from "@calcom/ui";
|
||||
|
||||
export const useAppTheme = () => {
|
||||
const { data: user } = useMeQuery();
|
||||
const brandTheme = getBrandColours({
|
||||
lightVal: user?.brandColor,
|
||||
darkVal: user?.darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
useTheme(user?.appTheme);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { ENABLE_PROFILE_SWITCHER } from "@calcom/lib/constants";
|
||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import {
|
||||
Avatar,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
export function ProfileDropdown() {
|
||||
const { update, data: sessionData } = useSession();
|
||||
const { data } = trpc.viewer.me.useQuery();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const refreshData = useRefreshData();
|
||||
|
||||
if (!data || !ENABLE_PROFILE_SWITCHER || !sessionData) {
|
||||
return null;
|
||||
}
|
||||
const options = data.profiles.map((profile) => {
|
||||
let label;
|
||||
if (profile.organization) {
|
||||
label = profile.organization.name;
|
||||
} else {
|
||||
label = sessionData.user.name;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
value: profile.upId,
|
||||
};
|
||||
});
|
||||
|
||||
const currentOption = options.find((option) => option.value === sessionData.upId) || options[0];
|
||||
|
||||
return (
|
||||
<Dropdown open={menuOpen}>
|
||||
<DropdownMenuTrigger asChild onClick={() => setMenuOpen((menuOpen) => !menuOpen)}>
|
||||
<button
|
||||
data-testid="user-dropdown-trigger-button"
|
||||
className={classNames(
|
||||
"hover:bg-emphasis todesktop:!bg-transparent group mx-0 flex w-full cursor-pointer appearance-none items-center rounded-full px-2 py-1.5 text-left outline-none transition focus:outline-none focus:ring-0 md:rounded-none lg:rounded"
|
||||
)}>
|
||||
<span className="flex w-full flex-grow items-center justify-around gap-2 text-sm font-medium leading-none">
|
||||
<Avatar alt={currentOption.label || ""} size="xsm" />
|
||||
<span className="block w-20 overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
{currentOption.label}
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
className="group-hover:text-subtle text-muted h-4 w-4 flex-shrink-0 transition rtl:mr-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
onInteractOutside={() => {
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="min-w-56 hariom group overflow-hidden rounded-md">
|
||||
<DropdownMenuItem className="p-3 uppercase">
|
||||
<span>Switch to</span>
|
||||
</DropdownMenuItem>
|
||||
{options.map((option) => {
|
||||
const isSelected = currentOption.value === option.value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
if (isSelected) return;
|
||||
update({
|
||||
upId: option.value,
|
||||
}).then(() => {
|
||||
refreshData();
|
||||
});
|
||||
}}
|
||||
className={classNames("flex w-full", isSelected ? "bg-subtle text-emphasis" : "")}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
childrenClassName={classNames("flex w-full justify-between items-center")}>
|
||||
<span>
|
||||
<Avatar alt={option.label || ""} size="xsm" />
|
||||
<span className="ml-2">{option.label}</span>
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<Icon name="check" className="ml-2 inline h-4 w-4" aria-hidden="true" />
|
||||
) : null}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* <DropdownMenuSeparator /> */}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { signOut } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { JOIN_COMMUNITY, ROADMAP, DESKTOP_APP_LINK } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import {
|
||||
Avatar,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
// TODO (Platform): we shouldnt be importing from web here
|
||||
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
|
||||
|
||||
import FreshChatProvider from "../../ee/support/lib/freshchat/FreshChatProvider";
|
||||
|
||||
interface UserDropdownProps {
|
||||
small?: boolean;
|
||||
}
|
||||
export function UserDropdown({ small }: UserDropdownProps) {
|
||||
const { isPlatformUser } = useGetUserAttributes();
|
||||
const { t } = useLocale();
|
||||
const { data: user } = useMeQuery();
|
||||
const pathname = usePathname();
|
||||
const isPlatformPages = pathname?.startsWith("/settings/platform");
|
||||
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 (
|
||||
<Dropdown open={menuOpen}>
|
||||
<DropdownMenuTrigger asChild onClick={() => setMenuOpen((menuOpen) => !menuOpen)}>
|
||||
<button
|
||||
data-testid="user-dropdown-trigger-button"
|
||||
className={classNames(
|
||||
"hover:bg-emphasis todesktop:!bg-transparent group mx-0 flex w-full cursor-pointer appearance-none items-center rounded-full text-left outline-none transition focus:outline-none focus:ring-0 md:rounded-none lg:rounded",
|
||||
small ? "p-2" : "px-2 py-1.5"
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
small ? "h-4 w-4" : "h-5 w-5 ltr:mr-2 rtl:ml-2",
|
||||
"relative flex-shrink-0 rounded-full "
|
||||
)}>
|
||||
<Avatar
|
||||
size={small ? "xs" : "xsm"}
|
||||
imageSrc={`${user.avatarUrl || user.avatar}`}
|
||||
alt={user.username || "Nameless User"}
|
||||
className="overflow-hidden"
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
"border-muted absolute -bottom-1 -right-1 rounded-full border bg-green-500",
|
||||
small ? "-bottom-0.5 -right-0.5 h-2.5 w-2.5" : "-bottom-0.5 -right-0 h-2 w-2"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{!small && (
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<span className="w-24 flex-shrink-0 text-sm leading-none">
|
||||
<span className="text-emphasis block truncate font-medium">
|
||||
{user.name || "Nameless User"}
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
className="group-hover:text-subtle text-muted h-4 w-4 flex-shrink-0 transition rtl:mr-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<FreshChatProvider>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
onInteractOutside={() => {
|
||||
setMenuOpen(false);
|
||||
setHelpOpen(false);
|
||||
}}
|
||||
className="group overflow-hidden rounded-md">
|
||||
{helpOpen ? (
|
||||
<HelpMenuItem onHelpItemSelect={() => onHelpItemSelect()} />
|
||||
) : (
|
||||
<>
|
||||
{!isPlatformPages && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
CustomStartIcon={
|
||||
<Icon name="user" className="text-default h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
href="/settings/my-account/profile">
|
||||
{t("my_profile")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
CustomStartIcon={
|
||||
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
href="/settings/my-account/general">
|
||||
{t("my_settings")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
CustomStartIcon={
|
||||
<Icon name="moon" className="text-default h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
href="/settings/my-account/out-of-office">
|
||||
{t("out_of_office")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
StartIcon="messages-square"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={JOIN_COMMUNITY}>
|
||||
{t("join_our_community")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem StartIcon="map" target="_blank" href={ROADMAP}>
|
||||
{t("visit_roadmap")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="circle-help"
|
||||
aria-hidden="true"
|
||||
onClick={() => setHelpOpen(true)}>
|
||||
{t("help")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{!isPlatformPages && (
|
||||
<DropdownMenuItem className="todesktop:hidden hidden lg:flex">
|
||||
<DropdownItem
|
||||
StartIcon="download"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={DESKTOP_APP_LINK}>
|
||||
{t("download_desktop_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{!isPlatformPages && isPlatformUser && (
|
||||
<DropdownMenuItem className="todesktop:hidden hidden lg:flex">
|
||||
<DropdownItem
|
||||
StartIcon="blocks"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="/settings/platform">
|
||||
Platform
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="log-out"
|
||||
aria-hidden="true"
|
||||
onClick={() => signOut({ callbackUrl: "/auth/logout" })}>
|
||||
{t("sign_out")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</FreshChatProvider>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user