refactor: Top Banner and add google calendar credential banner (#12532)

Fixes: https://github.com/calcom/cal.com/issues/12473

TODO:
- [x] Fix Type error

<img width="1512" alt="Screenshot 2023-12-02 at 12 47 19 AM" src="https://github.com/calcom/cal.com/assets/53316345/8a5c6dd0-6095-482b-b4d0-81653607a270">


<img width="1512" alt="Screenshot 2023-12-02 at 12 47 39 AM" src="https://github.com/calcom/cal.com/assets/53316345/fc64edb9-27b3-438f-b42d-75b200ac96e9">
This commit is contained in:
Udit Takkar 2023-12-08 04:02:47 +05:30 committed by GitHub
parent 969411041b
commit 90a6fc3f26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 238 additions and 59 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -1,3 +1,3 @@
export { CreateANewTeamForm } from "./CreateANewTeamForm";
export { TeamsListing } from "./TeamsListing";
export { TeamsUpgradeBanner } from "./TeamsUpgradeBanner";
export { TeamsUpgradeBanner, type TeamsUpgradeBannerProps } from "./TeamsUpgradeBanner";

View File

@ -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) {

View File

@ -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<SetStateAction<number>> }) {
const bannerRef = useRef<HTMLDivElement | null>(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 (
<div ref={bannerRef} className="sticky top-0 z-10 w-full divide-y divide-black">
<TeamsUpgradeBanner />
<OrgUpgradeBanner />
<ImpersonatingBanner />
<AdminPasswordBanner />
<VerifyEmailBanner />
</div>
);
}
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} />,
};
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<number>(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 */}
<TimezoneChangeDialog />
<div className="flex min-h-screen flex-col">
<AppTop setBannersHeight={setBannersHeight} />
{banners && (
<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} />;
}
})}
</div>
)}
<div className="flex flex-1" data-testid="dashboard-shell">
{props.SidebarContainer ? (
cloneElement(props.SidebarContainer, { bannersHeight })

View File

@ -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;

View File

@ -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 (
<>
<TopBanner
text={`${t("something_went_wrong")} ${t("calendar_error")}`}
variant="error"
actions={
<Link href="/apps/installed/calendar" className="border-b border-b-black">
{t("check_here")}
</Link>
}
/>
</>
);
}
export default CalendarCredentialBanner;

View File

@ -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 (
<>

View File

@ -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;

View File

@ -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 }) => {

View File

@ -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<TrpcSessionUser>;
};
};
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,
};
};

View File

@ -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 (
<div
data-testid="banner"
style={{ minHeight: TOP_BANNER_HEIGHT }}
className={classNames(
"flex min-h-[40px] w-full items-start justify-between gap-8 px-4 py-2 text-center lg:items-center",
"flex w-full items-start justify-between gap-8 px-4 py-2 text-center lg:items-center",
variantClassName[variant]
)}>
<div className="flex flex-1 flex-col items-start justify-center gap-2 p-1 lg:flex-row lg:items-center">