Fix/Keep themes in peace across embed and booking pages and App (#8108)

This commit is contained in:
Hariom Balhara 2023-04-17 17:46:54 +05:30 committed by GitHub
parent 39c48e7f79
commit 2c96444058
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 86 additions and 49 deletions

View File

@ -2,7 +2,6 @@ import { useRouter } from "next/router";
import { useCallback, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Button, TextArea } from "@calcom/ui";
@ -34,7 +33,6 @@ export default function CancelBooking(props: Props) {
const [loading, setLoading] = useState(false);
const telemetry = useTelemetry();
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
useTheme(props.theme);
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {

View File

@ -27,7 +27,8 @@ const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children
export type AppProps = Omit<NextAppProps<WithNonceProps & Record<string, unknown>>, "Component"> & {
Component: NextAppProps["Component"] & {
requiresLicense?: boolean;
isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean);
isThemeSupported?: boolean;
isBookingPage?: boolean | ((arg: { router: NextRouter }) => boolean);
getLayout?: (page: React.ReactElement, router: NextRouter) => ReactNode;
};
@ -61,45 +62,66 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
return <I18nextAdapter {...passedProps} />;
};
const enum ThemeSupport {
// e.g. Login Page
None = "none",
// Entire App except Booking Pages
App = "systemOnly",
// Booking Pages(including Routing Forms)
Booking = "userConfigured",
}
const CalcomThemeProvider = (
props: PropsWithChildren<
WithNonceProps & { isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean) }
WithNonceProps & {
isBookingPage?: boolean | ((arg: { router: NextRouter }) => boolean);
isThemeSupported?: boolean;
}
>
) => {
// We now support the inverse of how we handled it in the past. Setting this to false will disable theme.
// undefined or true means we use system theme
const router = useRouter();
const isThemeSupported = (() => {
if (typeof props.isThemeSupported === "function") {
return props.isThemeSupported({ router: router });
const isBookingPage = (() => {
if (typeof props.isBookingPage === "function") {
return props.isBookingPage({ router: router });
}
if (typeof props.isThemeSupported === "undefined") {
return true;
}
return props.isThemeSupported;
return props.isBookingPage;
})();
const forcedTheme = !isThemeSupported ? "light" : undefined;
const themeSupport = isBookingPage
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
// One such example is our Embeds Demo and Testing page at http://localhost:3100
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
const embedNamespace = typeof window !== "undefined" ? window.getEmbedNamespace() : null;
const isEmbedMode = typeof embedNamespace === "string";
// If embedNamespace is not defined, we use the default storageKey -> The default storage key changs based on if we force light mode or not
// This is done to ensure that the default theme is light when we force light mode and as soon as you navigate to a page that is dark we dont need a hard refresh to change
const storageKey = isEmbedMode
? `embed-theme-${embedNamespace}`
: !isThemeSupported
? "cal-light"
: "theme";
: themeSupport === ThemeSupport.App
? "app-theme"
: themeSupport === ThemeSupport.Booking
? "booking-theme"
: undefined;
return (
<ThemeProvider
nonce={props.nonce}
enableColorScheme={false}
enableSystem={isThemeSupported}
enableSystem={themeSupport !== ThemeSupport.None}
forcedTheme={forcedTheme}
storageKey={storageKey}
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
// This is how login to dashboard soft navigation changes theme from light to dark
key={storageKey}
attribute="class">
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
@ -134,7 +156,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
<TooltipProvider>
<CalcomThemeProvider
nonce={props.pageProps.nonce}
isThemeSupported={props.Component.isThemeSupported}>
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage}>
<FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider>
</FeatureFlagsProvider>

View File

@ -195,6 +195,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
);
}
User.isBookingPage = true;
const getEventTypesWithHiddenFromDB = async (userId: number) => {
return (
await prisma.eventType.findMany({

View File

@ -54,6 +54,8 @@ export default function Type(props: AvailabilityPageProps) {
);
}
Type.isBookingPage = true;
const paramsSchema = z.object({ type: z.string(), user: z.string() });
async function getUserPageProps(context: GetStaticPropsContext) {
// load server side dependencies

View File

@ -69,6 +69,8 @@ export default function Book(props: BookPageProps) {
);
}
Book.isBookingPage = true;
const querySchema = z.object({
bookingUid: z.string().optional(),
count: z.coerce.number().optional(),

View File

@ -15,7 +15,7 @@ type AppPageType = {
getServerSideProps: AppGetServerSideProps;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: ((props: any) => JSX.Element) & Pick<AppProps["Component"], "isThemeSupported" | "getLayout">;
default: ((props: any) => JSX.Element) & Pick<AppProps["Component"], "isBookingPage" | "getLayout">;
};
type Found = {
@ -70,17 +70,17 @@ const AppPage: AppPageType["default"] = function AppPage(props) {
return <route.Component {...componentProps} />;
};
AppPage.isThemeSupported = ({ router }) => {
AppPage.isBookingPage = ({ router }) => {
const route = getRoute(router.query.slug as string, router.query.pages as string[]);
if (route.notFound) {
return false;
}
const isThemeSupported = route.Component.isThemeSupported;
if (typeof isThemeSupported === "function") {
return isThemeSupported({ router });
const isBookingPage = route.Component.isBookingPage;
if (typeof isBookingPage === "function") {
return isBookingPage({ router });
}
return !!isThemeSupported;
return !!isBookingPage;
};
AppPage.getLayout = (page, router) => {

View File

@ -268,7 +268,9 @@ export default function Success(props: SuccessProps) {
return t("emailed_you_and_attendees" + titleSuffix);
}
useTheme(isSuccessBookingPage ? props.profile.theme : "light");
// This is a weird case where the same route can be opened in booking flow as a success page or as a booking detail page from the app
// As Booking Page it has to support configured theme, but as booking detail page it should not do any change. Let Shell.tsx handle it.
useTheme(isSuccessBookingPage ? props.profile.theme : undefined);
useBrandColors({
brandColor: props.profile.brandColor,
darkBrandColor: props.profile.darkBrandColor,
@ -705,6 +707,8 @@ export default function Success(props: SuccessProps) {
);
}
Success.isBookingPage = true;
type RecurringBookingsProps = {
eventType: SuccessProps["eventType"];
recurringBookings: SuccessProps["recurringBookings"];

View File

@ -24,6 +24,8 @@ export default function Type(props: DynamicAvailabilityPageProps) {
return <AvailabilityPage {...props} />;
}
Type.isBookingPage = true;
const querySchema = z.object({
link: z.string().optional().default(""),
slug: z.string().optional().default(""),

View File

@ -19,6 +19,8 @@ export default function Book(props: HashLinkPageProps) {
return <BookingPage {...props} />;
}
Book.isBookingPage = true;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const link = asStringOrThrow(context.query.link as string);

View File

@ -203,4 +203,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} as const;
};
TeamPage.isBookingPage = true;
export default TeamPage;

View File

@ -24,6 +24,8 @@ export default function TeamType(props: AvailabilityTeamPageProps) {
return <AvailabilityPage {...props} />;
}
TeamType.isBookingPage = true;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type);

View File

@ -23,6 +23,7 @@ export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
export default function TeamBookingPage(props: TeamBookingPageProps) {
return <BookingPage {...props} />;
}
TeamBookingPage.isBookingPage = true;
const querySchema = z.object({
rescheduleUid: z.string().optional(),

View File

@ -332,7 +332,6 @@ export default function FormEditPage({
/>
);
}
FormEditPage.isThemeSupported = true;
FormEditPage.getLayout = (page: React.ReactElement) => {
return (

View File

@ -252,8 +252,6 @@ export default function RoutingForms({
);
}
RoutingForms.isThemeSupported = true;
RoutingForms.getLayout = (page: React.ReactElement) => {
return <Shell withoutMain={true}>{page}</Shell>;
};

View File

@ -197,8 +197,6 @@ export default function ReporterWrapper({
);
}
ReporterWrapper.isThemeSupported = true;
ReporterWrapper.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>

View File

@ -523,7 +523,6 @@ export default function RouteBuilder({
/>
);
}
RouteBuilder.isThemeSupported = true;
RouteBuilder.getLayout = (page: React.ReactElement) => {
return (

View File

@ -26,8 +26,6 @@ export default function Router({ form, message }: inferSSRProps<typeof getServer
);
}
Router.isThemeSupported = true;
const querySchema = z
.object({
form: z.string(),

View File

@ -165,7 +165,7 @@ export default function RoutingLink(props: inferSSRProps<typeof getServerSidePro
return <RoutingForm {...props} />;
}
RoutingLink.isThemeSupported = true;
RoutingLink.isBookingPage = true;
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,

View File

@ -22,7 +22,6 @@ import classNames from "@calcom/lib/classNames";
import { APP_NAME, DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
import getBrandColours from "@calcom/lib/getBrandColours";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
@ -231,7 +230,8 @@ export default function Shell(props: LayoutProps) {
// if a page is unauthed and isPublic is true, the redirect does not happen.
useRedirectToLoginIfUnauthenticated(props.isPublic);
useRedirectToOnboardingIfNeeded();
useTheme();
// 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 ? (

View File

@ -4,23 +4,28 @@ import { useEffect } from "react";
import { useEmbedTheme } from "@calcom/embed-core/embed-iframe";
import type { Maybe } from "@calcom/trpc/server";
// makes sure the ui doesn't flash
export default function useTheme(theme?: Maybe<string>) {
/**
* It should be called once per route and only if you want to use app configured theme. System only theme works automatically by using ThemeProvider
* Calling it without a theme will just returns the current theme.
* It handles embed configured theme as well.
*/
export default function useTheme(themeToSet?: Maybe<string>) {
const { resolvedTheme, setTheme, forcedTheme, theme: activeTheme } = useNextTheme();
const embedTheme = useEmbedTheme();
// Embed UI configuration takes more precedence over App Configuration
const currentTheme = embedTheme || theme || "system";
useEffect(() => {
if (currentTheme !== activeTheme && typeof currentTheme === "string") {
setTheme(currentTheme);
}
// If themeToSet is not provided the purpose is to just return the current the current values
if (themeToSet === undefined) return;
// Embed theme takes precedence over theme configured in app. This allows embeds to be themed differently
const finalThemeToSet = embedTheme || themeToSet || "system";
if (!finalThemeToSet || finalThemeToSet === activeTheme) return;
console.log("Setting theme", { resolvedTheme, finalThemeToSet, activeTheme, forcedTheme });
setTheme(finalThemeToSet);
// eslint-disable-next-line react-hooks/exhaustive-deps -- we do not want activeTheme to re-render this effect
}, [currentTheme, setTheme]);
useEffect(() => {
if (forcedTheme) setTheme(forcedTheme);
}, [forcedTheme, setTheme]);
}, [themeToSet, setTheme]);
return {
resolvedTheme,