feat: Settings for dashboard theme that is separate from system (#13779)
* feat(db): add new col: user appTheme * feat(event-types page): apply appTheme color * add appTheme for trpc * feat: mutate main page theme * feat: add english translation for app theme update section * modify keys in common (en) * add woring * Revert yarn.lock to its state before unintended changes * feat(i18n): add back the i18n string (for dark/light mode switching) * refactor: refactor ThemeLabel * fix: add new user field "appTheme" to test files * chore: modify TODO comment * chore: update common.json (en) for "theme" since we had appTheme now, I think it's better to update the wording here to avoid confusion * fix: update button's data-testid to fix e2e test error * chore: remove comment * fix: fix brand-color-not-apply bug * solve type error * fix: fix type error * fix: use correct storageKey for booker / dashboard * fix: tidy up * fix: skeleton --------- Co-authored-by: swh00tw <a6140000@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
14 files changed
+126
-18
No files matched your search
@@ -74,6 +74,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
theme: true,
|
||||
appTheme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
hideBranding: true,
|
||||
@@ -100,6 +101,7 @@ const schemaUserEditParams = z.object({
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
appTheme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
defaultScheduleId: z
|
||||
.number()
|
||||
@@ -122,6 +124,7 @@ const schemaUserCreateParams = z.object({
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
appTheme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
defaultScheduleId: z
|
||||
.number()
|
||||
@@ -159,6 +162,7 @@ export const schemaUserReadPublic = User.pick({
|
||||
weekStart: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
appTheme: true,
|
||||
theme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
|
||||
@@ -113,7 +113,7 @@ const enum ThemeSupport {
|
||||
// e.g. Login Page
|
||||
None = "none",
|
||||
// Entire App except Booking Pages
|
||||
App = "systemOnly",
|
||||
App = "userConfigured",
|
||||
// Booking Pages(including Routing Forms)
|
||||
Booking = "userConfigured",
|
||||
}
|
||||
|
||||
@@ -127,9 +127,9 @@ const enum ThemeSupport {
|
||||
// e.g. Login Page
|
||||
None = "none",
|
||||
// Entire App except Booking Pages
|
||||
App = "systemOnly",
|
||||
App = "appConfigured",
|
||||
// Booking Pages(including Routing Forms)
|
||||
Booking = "userConfigured",
|
||||
Booking = "bookingConfigured",
|
||||
}
|
||||
|
||||
type CalcomThemeProps = PropsWithChildren<
|
||||
@@ -144,8 +144,10 @@ const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
const embedNamespace = getEmbedNamespace(props.router.query);
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
const themeProviderProps = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
|
||||
|
||||
return (
|
||||
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace })}>
|
||||
<ThemeProvider {...themeProviderProps}>
|
||||
{/* 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. */}
|
||||
{typeof window !== "undefined" && !isEmbedMode && (
|
||||
|
||||
@@ -11,8 +11,10 @@ import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
SkeletonText,
|
||||
SettingsToggle,
|
||||
UpgradeTeamsBadge,
|
||||
useCalcomTheme,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -41,10 +44,10 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
</div>
|
||||
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<div className="[&>*]:bg-emphasis flex w-full items-center justify-center gap-x-2 [&>*]:animate-pulse">
|
||||
<div className="h-32 flex-1 rounded-md p-5" />
|
||||
<div className="h-32 flex-1 rounded-md p-5" />
|
||||
<div className="h-32 flex-1 rounded-md p-5" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
@@ -62,6 +65,26 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
);
|
||||
};
|
||||
|
||||
const useBrandColors = (
|
||||
currentTheme: string | null,
|
||||
{
|
||||
brandColor,
|
||||
darkBrandColor,
|
||||
}: {
|
||||
brandColor?: string | null;
|
||||
darkBrandColor?: string | null;
|
||||
}
|
||||
): void => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
const selectedTheme = currentTheme ? brandTheme[currentTheme as "light" | "dark"] : {};
|
||||
useCalcomTheme({
|
||||
root: selectedTheme,
|
||||
});
|
||||
};
|
||||
|
||||
const AppearanceView = ({
|
||||
user,
|
||||
hasPaidPlan,
|
||||
@@ -77,6 +100,22 @@ const AppearanceView = ({
|
||||
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
|
||||
);
|
||||
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
|
||||
useTheme(user?.appTheme);
|
||||
useBrandColors(user?.appTheme ?? null, {
|
||||
brandColor: user?.brandColor,
|
||||
darkBrandColor: user?.darkBrandColor,
|
||||
});
|
||||
|
||||
const userAppThemeFormMethods = useForm({
|
||||
defaultValues: {
|
||||
appTheme: user.appTheme,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting: isUserAppThemeSubmitting, isDirty: isUserAppThemeDirty },
|
||||
reset: resetUserAppThemeReset,
|
||||
} = userAppThemeFormMethods;
|
||||
|
||||
const userThemeFormMethods = useForm({
|
||||
defaultValues: {
|
||||
@@ -131,6 +170,7 @@ const AppearanceView = ({
|
||||
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
|
||||
resetBookerLayoutThemeReset({ metadata: data.metadata });
|
||||
resetUserThemeReset({ theme: data.theme });
|
||||
resetUserAppThemeReset({ appTheme: data.appTheme });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message) {
|
||||
@@ -147,6 +187,56 @@ const AppearanceView = ({
|
||||
return (
|
||||
<div>
|
||||
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
|
||||
<div>
|
||||
<p className="text-default text-base font-semibold">{t("app_theme")}</p>
|
||||
<p className="text-default">{t("app_theme_applies_note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
form={userAppThemeFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
appTheme: values.appTheme ?? null,
|
||||
});
|
||||
}}>
|
||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={undefined}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.appTheme === null}
|
||||
register={userAppThemeFormMethods.register}
|
||||
fieldName="appTheme"
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={user.appTheme === "light"}
|
||||
register={userAppThemeFormMethods.register}
|
||||
fieldName="appTheme"
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={user.appTheme === "dark"}
|
||||
register={userAppThemeFormMethods.register}
|
||||
fieldName="appTheme"
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions className="mb-6" align="end">
|
||||
<Button
|
||||
disabled={isUserAppThemeSubmitting || !isUserAppThemeDirty}
|
||||
type="submit"
|
||||
data-testid="update-app-theme-btn"
|
||||
color="primary">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
|
||||
<div>
|
||||
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
||||
|
||||
@@ -1473,8 +1473,10 @@
|
||||
"route_to_the_right_person": "Route to the right person based on the answers to your form",
|
||||
"form_description": "Create your form to route a booker",
|
||||
"copy_link_to_form": "Copy link to form",
|
||||
"theme": "Theme",
|
||||
"theme": "Booking Page Theme",
|
||||
"theme_applies_note": "This only applies to your public booking pages",
|
||||
"app_theme": "Dashboard Theme",
|
||||
"app_theme_applies_note": "This only applies to your logged in dashboard",
|
||||
"theme_system": "System default",
|
||||
"add_a_team": "Add a team",
|
||||
"add_webhook_description": "Receive meeting data in real-time when something happens in {{appName}}",
|
||||
|
||||
@@ -37,6 +37,7 @@ export const getSampleUserInSession = function () {
|
||||
disableImpersonation: false,
|
||||
organizationId: null,
|
||||
theme: "",
|
||||
appTheme: "",
|
||||
createdDate: new Date(),
|
||||
trialEndsAt: new Date(),
|
||||
completedOnboarding: false,
|
||||
|
||||
@@ -4,23 +4,24 @@ interface ThemeLabelProps {
|
||||
label: string;
|
||||
defaultChecked?: boolean;
|
||||
register: any;
|
||||
fieldName?: string;
|
||||
}
|
||||
|
||||
export default function ThemeLabel(props: ThemeLabelProps) {
|
||||
const { variant, label, value, defaultChecked, register } = props;
|
||||
const { variant, label, value, defaultChecked, register, fieldName = "theme" } = props;
|
||||
|
||||
return (
|
||||
<label
|
||||
className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mb-0 sm:mr-4"
|
||||
htmlFor={`theme-${variant}`}
|
||||
data-testid={`theme-${variant}`}>
|
||||
htmlFor={`${fieldName}-${variant}`}
|
||||
data-testid={`${fieldName}-${variant}`}>
|
||||
<input
|
||||
className="peer absolute left-8 top-8"
|
||||
type="radio"
|
||||
value={value}
|
||||
id={`theme-${variant}`}
|
||||
id={`${fieldName}-${variant}`}
|
||||
defaultChecked={defaultChecked}
|
||||
{...register("theme")}
|
||||
{...register(fieldName)}
|
||||
/>
|
||||
<div className="ring-inverted relative z-10 rounded-lg ring-offset-2 transition-all peer-checked:ring-2">
|
||||
<img
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useFormbricks } from "@calcom/lib/formbricks-client";
|
||||
import getBrandColours from "@calcom/lib/getBrandColours";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -329,13 +330,14 @@ type LayoutProps = {
|
||||
hideHeadingOnMobile?: boolean;
|
||||
};
|
||||
|
||||
const useBrandColors = () => {
|
||||
const useAppTheme = () => {
|
||||
const { data: user } = useMeQuery();
|
||||
const brandTheme = getBrandColours({
|
||||
lightVal: user?.brandColor,
|
||||
darkVal: user?.darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
useTheme(user?.appTheme);
|
||||
};
|
||||
|
||||
const KBarWrapper = ({ children, withKBar = false }: { withKBar: boolean; children: React.ReactNode }) =>
|
||||
@@ -361,9 +363,7 @@ export default function Shell(props: LayoutProps) {
|
||||
// if a page is unauthed and isPublic is true, the redirect does not happen.
|
||||
useRedirectToLoginIfUnauthenticated(props.isPublic);
|
||||
useRedirectToOnboardingIfNeeded();
|
||||
// System Theme is automatically supported using ThemeProvider. If we intend to use user theme throughout the app we need to uncomment this.
|
||||
// useTheme(profile.theme);
|
||||
useBrandColors();
|
||||
useAppTheme();
|
||||
|
||||
return !props.isPublic ? (
|
||||
<KBarWrapper withKBar>
|
||||
|
||||
@@ -234,6 +234,7 @@ export const buildUser = <T extends Partial<UserPayload>>(
|
||||
selectedCalendars: [],
|
||||
startTime: 0,
|
||||
theme: null,
|
||||
appTheme: null,
|
||||
timeFormat: null,
|
||||
trialEndsAt: null,
|
||||
twoFactorEnabled: false,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "appTheme" TEXT;
|
||||
@@ -221,7 +221,9 @@ model User {
|
||||
// </DEPRECATED>
|
||||
bufferTime Int @default(0)
|
||||
hideBranding Boolean @default(false)
|
||||
// TODO: should be renamed since it only affects the booking page
|
||||
theme String?
|
||||
appTheme String?
|
||||
createdDate DateTime @default(now()) @map(name: "created")
|
||||
trialEndsAt DateTime?
|
||||
eventTypes EventType[] @relation("user_eventtype")
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
||||
defaultScheduleId: true,
|
||||
bufferTime: true,
|
||||
theme: true,
|
||||
appTheme: true,
|
||||
createdDate: true,
|
||||
hideBranding: true,
|
||||
twoFactorEnabled: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
|
||||
bio: user.bio,
|
||||
weekStart: user.weekStart,
|
||||
theme: user.theme,
|
||||
appTheme: user.appTheme,
|
||||
hideBranding: user.hideBranding,
|
||||
metadata: user.metadata,
|
||||
defaultBookerLayouts: user.defaultBookerLayouts,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ZUpdateProfileInputSchema = z.object({
|
||||
brandColor: z.string().optional(),
|
||||
darkBrandColor: z.string().optional(),
|
||||
theme: z.string().optional().nullable(),
|
||||
appTheme: z.string().optional().nullable(),
|
||||
completedOnboarding: z.boolean().optional(),
|
||||
locale: z.string().optional(),
|
||||
timeFormat: z.number().optional(),
|
||||
|
||||
Reference in new issue
Block a user