From 90a6fc3f264c63f721b23f14c9d236fa1c9190c1 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 8 Dec 2023 04:02:47 +0530 Subject: [PATCH] refactor: Top Banner and add google calendar credential banner (#12532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: https://github.com/calcom/cal.com/issues/12473 TODO: - [x] Fix Type error Screenshot 2023-12-02 at 12 47 19 AM Screenshot 2023-12-02 at 12 47 39 AM --- apps/web/public/static/locales/en/common.json | 1 + .../components/ImpersonatingBanner.tsx | 7 +- .../components/OrgUpgradeBanner.tsx | 8 +- .../teams/components/TeamsUpgradeBanner.tsx | 9 +- .../features/ee/teams/components/index.ts | 2 +- .../settings/layouts/SettingsLayout.tsx | 4 +- packages/features/shell/Shell.tsx | 135 +++++++++++++----- .../users/components/AdminPasswordBanner.tsx | 7 +- .../components/CalendarCredentialBanner.tsx | 31 ++++ .../users/components/VerifyEmailBanner.tsx | 14 +- packages/lib/constants.ts | 2 + .../server/routers/loggedInViewer/_router.tsx | 16 +++ .../getUserTopBanners.handler.ts | 57 ++++++++ .../ui/components/top-banner/TopBanner.tsx | 4 +- 14 files changed, 238 insertions(+), 59 deletions(-) create mode 100644 packages/features/users/components/CalendarCredentialBanner.tsx create mode 100644 packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 39e5906ea2..3b97b5db3c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -880,6 +880,7 @@ "toggle_calendars_conflict": "Toggle the calendars you want to check for conflicts to prevent double bookings.", "connect_additional_calendar": "Connect additional calendar", "calendar_updated_successfully": "Calendar updated successfully", + "check_here":"Check here", "conferencing": "Conferencing", "calendar": "Calendar", "payments": "Payments", diff --git a/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx b/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx index 7a032900ff..f4ea44eeac 100644 --- a/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx +++ b/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx @@ -1,11 +1,12 @@ -import { useSession } from "next-auth/react"; +import type { SessionContextValue } from "next-auth/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { TopBanner } from "@calcom/ui"; -function ImpersonatingBanner() { +export type ImpersonatingBannerProps = { data: SessionContextValue["data"] }; + +function ImpersonatingBanner({ data }: ImpersonatingBannerProps) { const { t } = useLocale(); - const { data } = useSession(); if (!data?.user.impersonatedByUID) return null; diff --git a/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx index b3c2dd2dc6..073ffe9c35 100644 --- a/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx +++ b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx @@ -1,13 +1,17 @@ import { useRouter } from "next/navigation"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { showToast, TopBanner } from "@calcom/ui"; -export function OrgUpgradeBanner() { +export type OrgUpgradeBannerProps = { + data: RouterOutputs["viewer"]["getUserTopBanners"]["orgUpgradeBanner"]; +}; + +export function OrgUpgradeBanner({ data }: OrgUpgradeBannerProps) { const { t } = useLocale(); const router = useRouter(); - const { data } = trpc.viewer.organizations.checkIfOrgNeedsUpgrade.useQuery(); const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({ onSuccess(data) { router.push(data.url); diff --git a/packages/features/ee/teams/components/TeamsUpgradeBanner.tsx b/packages/features/ee/teams/components/TeamsUpgradeBanner.tsx index 58441a6337..2804d59a2f 100644 --- a/packages/features/ee/teams/components/TeamsUpgradeBanner.tsx +++ b/packages/features/ee/teams/components/TeamsUpgradeBanner.tsx @@ -2,12 +2,17 @@ import { useRouter } from "next/navigation"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { showToast, TopBanner } from "@calcom/ui"; -export function TeamsUpgradeBanner() { +export type TeamsUpgradeBannerProps = { + data: RouterOutputs["viewer"]["getUserTopBanners"]["teamUpgradeBanner"]; +}; + +export function TeamsUpgradeBanner({ data }: TeamsUpgradeBannerProps) { const { t } = useLocale(); const router = useRouter(); - const { data } = trpc.viewer.teams.getUpgradeable.useQuery(); + const publishTeamMutation = trpc.viewer.teams.publish.useMutation({ onSuccess(data) { router.push(data.url); diff --git a/packages/features/ee/teams/components/index.ts b/packages/features/ee/teams/components/index.ts index 9a2c857f44..4ba43769b0 100644 --- a/packages/features/ee/teams/components/index.ts +++ b/packages/features/ee/teams/components/index.ts @@ -1,3 +1,3 @@ export { CreateANewTeamForm } from "./CreateANewTeamForm"; export { TeamsListing } from "./TeamsListing"; -export { TeamsUpgradeBanner } from "./TeamsUpgradeBanner"; +export { TeamsUpgradeBanner, type TeamsUpgradeBannerProps } from "./TeamsUpgradeBanner"; diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 677c43f5e4..70721e3cfb 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -213,7 +213,9 @@ const SettingsSidebarContainer = ({ enabled: !!session.data?.user?.org, }); - const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(); + const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, { + enabled: !!session.data?.user?.org, + }); useEffect(() => { if (teams) { diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index dc49b862ae..4df2c81993 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -4,27 +4,39 @@ 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 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 from "@calcom/features/ee/impersonation/components/ImpersonatingBanner"; -import { OrgUpgradeBanner } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner"; +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 { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components"; +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 from "@calcom/features/users/components/AdminPasswordBanner"; -import VerifyEmailBanner from "@calcom/features/users/components/VerifyEmailBanner"; +import AdminPasswordBanner, { + type AdminPasswordBannerProps, +} from "@calcom/features/users/components/AdminPasswordBanner"; +import CalendarCredentialBanner, { + type CalendarCredentialBannerProps, +} from "@calcom/features/users/components/CalendarCredentialBanner"; +import VerifyEmailBanner, { + type VerifyEmailBannerProps, +} from "@calcom/features/users/components/VerifyEmailBanner"; import classNames from "@calcom/lib/classNames"; +import { TOP_BANNER_HEIGHT } from "@calcom/lib/constants"; 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"; @@ -131,38 +143,29 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) { }; } -function AppTop({ setBannersHeight }: { setBannersHeight: Dispatch> }) { - const bannerRef = useRef(null); +type BannerTypeProps = { + teamUpgradeBanner: TeamsUpgradeBannerProps; + orgUpgradeBanner: OrgUpgradeBannerProps; + verifyEmailBanner: VerifyEmailBannerProps; + adminPasswordBanner: AdminPasswordBannerProps; + impersonationBanner: ImpersonatingBannerProps; + calendarCredentialBanner: CalendarCredentialBannerProps; +}; - useIsomorphicLayoutEffect(() => { - const resizeObserver = new ResizeObserver((entries) => { - const { offsetHeight } = entries[0].target as HTMLElement; - setBannersHeight(offsetHeight); - }); +type BannerType = keyof BannerTypeProps; - const currentBannerRef = bannerRef.current; +type BannerComponent = { + [Key in BannerType]: (props: BannerTypeProps[Key]) => JSX.Element; +}; - if (currentBannerRef) { - resizeObserver.observe(currentBannerRef); - } - - return () => { - if (currentBannerRef) { - resizeObserver.unobserve(currentBannerRef); - } - }; - }, [bannerRef]); - - return ( -
- - - - - -
- ); -} +const BannerComponent: BannerComponent = { + teamUpgradeBanner: (props: TeamsUpgradeBannerProps) => , + orgUpgradeBanner: (props: OrgUpgradeBannerProps) => , + verifyEmailBanner: (props: VerifyEmailBannerProps) => , + adminPasswordBanner: (props: AdminPasswordBannerProps) => , + impersonationBanner: (props: ImpersonatingBannerProps) => , + calendarCredentialBanner: (props: CalendarCredentialBannerProps) => , +}; function useRedirectToOnboardingIfNeeded() { const router = useRouter(); @@ -188,10 +191,41 @@ function useRedirectToOnboardingIfNeeded() { }; } +type allBannerProps = { [Key in BannerType]: BannerTypeProps[Key]["data"] }; + +const useBanners = () => { + const { data: getUserTopBanners, isLoading } = trpc.viewer.getUserTopBanners.useQuery(); + const { data: userSession } = useSession(); + + if (isLoading || !userSession) return null; + + const isUserInactiveAdmin = userSession?.user.role === "INACTIVE_ADMIN"; + const userImpersonatedByUID = userSession?.user.impersonatedByUID; + + const userSessionBanners = { + adminPasswordBanner: isUserInactiveAdmin ? userSession : null, + impersonationBanner: userImpersonatedByUID ? userSession : null, + }; + + const allBanners: allBannerProps = Object.assign({}, getUserTopBanners, userSessionBanners); + + return allBanners; +}; + const Layout = (props: LayoutProps) => { - const [bannersHeight, setBannersHeight] = useState(0); + const banners = useBanners(); + const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title; + 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 ( <> {!props.withoutSeo && ( @@ -207,7 +241,32 @@ const Layout = (props: LayoutProps) => { {/* todo: only run this if timezone is different */}
- + {banners && ( +
+ {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 ; + } + })} +
+ )} +
{props.SidebarContainer ? ( cloneElement(props.SidebarContainer, { bannersHeight }) diff --git a/packages/features/users/components/AdminPasswordBanner.tsx b/packages/features/users/components/AdminPasswordBanner.tsx index 3ac73bd15d..7c01f1d9f4 100644 --- a/packages/features/users/components/AdminPasswordBanner.tsx +++ b/packages/features/users/components/AdminPasswordBanner.tsx @@ -1,12 +1,13 @@ -import { useSession } from "next-auth/react"; +import type { SessionContextValue } from "next-auth/react"; import Link from "next/link"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { TopBanner } from "@calcom/ui"; -function AdminPasswordBanner() { +export type AdminPasswordBannerProps = { data: SessionContextValue["data"] }; + +function AdminPasswordBanner({ data }: AdminPasswordBannerProps) { const { t } = useLocale(); - const { data } = useSession(); if (data?.user.role !== "INACTIVE_ADMIN") return null; diff --git a/packages/features/users/components/CalendarCredentialBanner.tsx b/packages/features/users/components/CalendarCredentialBanner.tsx new file mode 100644 index 0000000000..c20448d78b --- /dev/null +++ b/packages/features/users/components/CalendarCredentialBanner.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { type RouterOutputs } from "@calcom/trpc"; +import { TopBanner } from "@calcom/ui"; + +export type CalendarCredentialBannerProps = { + data: RouterOutputs["viewer"]["getUserTopBanners"]["calendarCredentialBanner"]; +}; + +function CalendarCredentialBanner({ data }: CalendarCredentialBannerProps) { + const { t } = useLocale(); + + if (!data) return null; + + return ( + <> + + {t("check_here")} + + } + /> + + ); +} + +export default CalendarCredentialBanner; diff --git a/packages/features/users/components/VerifyEmailBanner.tsx b/packages/features/users/components/VerifyEmailBanner.tsx index cc8aebbff1..cdbf9125ff 100644 --- a/packages/features/users/components/VerifyEmailBanner.tsx +++ b/packages/features/users/components/VerifyEmailBanner.tsx @@ -1,23 +1,21 @@ -import { useSession } from "next-auth/react"; - import { APP_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck"; import { TopBanner, showToast } from "@calcom/ui"; import { Mail } from "@calcom/ui/components/icon"; import { useFlagMap } from "../../flags/context/provider"; -function VerifyEmailBanner() { +export type VerifyEmailBannerProps = { + data: boolean; +}; + +function VerifyEmailBanner({ data }: VerifyEmailBannerProps) { const flags = useFlagMap(); const { t } = useLocale(); - const { data, isLoading } = useEmailVerifyCheck(); const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation(); - const session = useSession(); - const isLoggedIn = session?.data?.user; - if (!isLoggedIn || isLoading || data?.isVerified || !flags["email-verification"]) return null; + if (!data || !flags["email-verification"]) return null; return ( <> diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 25cd09580c..c1fa5a6381 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -108,6 +108,8 @@ export const APP_CREDENTIAL_SHARING_ENABLED = export const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; export const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; +export const TOP_BANNER_HEIGHT = 40; + const defaultOnNaN = (testedValue: number, defaultValue: number) => !Number.isNaN(testedValue) ? testedValue : defaultValue; diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx index 1c96204997..5dc20024bb 100644 --- a/packages/trpc/server/routers/loggedInViewer/_router.tsx +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -44,6 +44,7 @@ type AppsRouterHandlerCache = { getUsersDefaultConferencingApp?: typeof import("./getUsersDefaultConferencingApp.handler").getUsersDefaultConferencingAppHandler; updateUserDefaultConferencingApp?: typeof import("./updateUserDefaultConferencingApp.handler").updateUserDefaultConferencingAppHandler; teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery; + getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler; }; const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; @@ -340,6 +341,21 @@ export const loggedInViewerRouter = router({ return UNSTABLE_HANDLER_CACHE.getCalVideoRecordings({ ctx, input }); }), + getUserTopBanners: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getUserTopBanners) { + UNSTABLE_HANDLER_CACHE.getUserTopBanners = ( + await import("./getUserTopBanners.handler") + ).getUserTopBannersHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getUserTopBanners) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getUserTopBanners({ ctx }); + }), + getDownloadLinkOfCalVideoRecordings: authedProcedure .input(ZGetDownloadLinkOfCalVideoRecordingsInputSchema) .query(async ({ ctx, input }) => { diff --git a/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts b/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts new file mode 100644 index 0000000000..c81a00452f --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts @@ -0,0 +1,57 @@ +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { prisma } from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { checkIfOrgNeedsUpgradeHandler } from "../viewer/organizations/checkIfOrgNeedsUpgrade.handler"; +import { getUpgradeableHandler } from "../viewer/teams/getUpgradeable.handler"; +import { shouldVerifyEmailHandler } from "./shouldVerifyEmail.handler"; + +type Props = { + ctx: { + user: NonNullable; + }; +}; + +const checkInvalidGoogleCalendarCredentials = async ({ ctx }: Props) => { + const userCredentials = await prisma.credential.findMany({ + where: { + userId: ctx.user.id, + type: "google_calendar", + }, + select: credentialForCalendarServiceSelect, + }); + + const calendarCredentials = getCalendarCredentials(userCredentials); + + const { connectedCalendars } = await getConnectedCalendars( + calendarCredentials, + ctx.user.selectedCalendars, + ctx.user.destinationCalendar?.externalId + ); + + return connectedCalendars.some((calendar) => !!calendar.error); +}; + +export const getUserTopBannersHandler = async ({ ctx }: Props) => { + const upgradeableTeamMememberships = getUpgradeableHandler({ ctx }); + const upgradeableOrgMememberships = checkIfOrgNeedsUpgradeHandler({ ctx }); + const shouldEmailVerify = shouldVerifyEmailHandler({ ctx }); + const isInvalidCalendarCredential = checkInvalidGoogleCalendarCredentials({ ctx }); + + const [teamUpgradeBanner, orgUpgradeBanner, verifyEmailBanner, calendarCredentialBanner] = + await Promise.allSettled([ + upgradeableTeamMememberships, + upgradeableOrgMememberships, + shouldEmailVerify, + isInvalidCalendarCredential, + ]); + + return { + teamUpgradeBanner: teamUpgradeBanner.status === "fulfilled" ? teamUpgradeBanner.value : [], + orgUpgradeBanner: orgUpgradeBanner.status === "fulfilled" ? orgUpgradeBanner.value : [], + verifyEmailBanner: verifyEmailBanner.status === "fulfilled" ? !verifyEmailBanner.value.isVerified : false, + calendarCredentialBanner: + calendarCredentialBanner.status === "fulfilled" ? calendarCredentialBanner.value : false, + }; +}; diff --git a/packages/ui/components/top-banner/TopBanner.tsx b/packages/ui/components/top-banner/TopBanner.tsx index 2ab3155936..69646b1441 100644 --- a/packages/ui/components/top-banner/TopBanner.tsx +++ b/packages/ui/components/top-banner/TopBanner.tsx @@ -1,6 +1,7 @@ import classNames from "classnames"; import type { ComponentType, ReactNode } from "react"; +import { TOP_BANNER_HEIGHT } from "@calcom/lib/constants"; import type { LucideIcon, LucideProps } from "@calcom/ui/components/icon"; import { AlertTriangle, Info } from "@calcom/ui/components/icon"; @@ -40,8 +41,9 @@ export function TopBanner(props: TopBannerProps) { return (