feat: add theming options to teams (#6807)

This commit is contained in:
Nafees Nazik 2023-02-01 23:57:26 +05:30 committed by GitHub
parent 5b165e6ebf
commit 7ee5967075
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 251 additions and 116 deletions

View File

@ -1,6 +1,7 @@
import { GetServerSidePropsContext } from "next";
import { Controller, useForm } from "react-hook-form";
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME } from "@calcom/lib/constants";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
@ -225,36 +226,3 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
export default AppearanceView;
interface ThemeLabelProps {
variant: "light" | "dark" | "system";
value?: "light" | "dark" | null;
label: string;
defaultChecked?: boolean;
register: any;
}
const ThemeLabel = ({ variant, label, value, defaultChecked, register }: ThemeLabelProps) => {
return (
<label
className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mr-4 sm:mb-0"
htmlFor={`theme-${variant}`}>
<input
className="peer absolute top-8 left-8"
type="radio"
value={value}
id={`theme-${variant}`}
defaultChecked={defaultChecked}
{...register("theme")}
/>
<div className="relative z-10 rounded-lg ring-black transition-all peer-checked:ring-2">
<img
aria-hidden="true"
className="cover w-full rounded-lg"
src={`/theme-${variant}.svg`}
alt={`theme ${variant}`}
/>
</div>
<p className="mt-2 text-sm font-medium text-gray-600 peer-checked:text-gray-900">{label}</p>
</label>
);
};

View File

@ -27,7 +27,7 @@ const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({ team }: TeamPageProps) {
useTheme();
useTheme(team.theme);
const showMembers = useToggleQuery("members");
const { t } = useLocale();
const isEmbed = useIsEmbed();

View File

@ -43,6 +43,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
slug: true,
logo: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
eventTypes: {
where: {
slug: typeParam,
@ -68,6 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
},
},
title: true,
availability: true,
description: true,
@ -150,10 +154,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
name: team.name || team.slug,
slug: team.slug,
image: team.logo,
theme: null as string | null,
theme: team.theme,
weekStart: "Sunday",
brandColor: "" /* TODO: Add a way to set a brand color for Teams */,
darkBrandColor: "" /* TODO: Add a way to set a brand color for Teams */,
brandColor: team.brandColor,
darkBrandColor: team.darkBrandColor,
},
date: dateParam,
eventType: eventTypeObject,

View File

@ -67,6 +67,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: true,
name: true,
logo: true,
theme: true,
brandColor: true,
darkBrandColor: true,
},
},
users: {
@ -128,9 +131,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// FIXME: This slug is used as username on success page which is wrong. This is correctly set as username for user booking.
slug: "team/" + eventTypeObject.slug,
image: eventTypeObject.team?.logo || null,
theme: null as string | null /* Teams don't have a theme, and `BookingPage` uses it */,
brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */,
darkBrandColor: null /* Teams don't have a darkBrandColor, and `BookingPage` uses it */,
eventName: null,
},
eventType: eventTypeObject,

View File

@ -5,13 +5,50 @@ import { Controller, useForm } from "react-hook-form";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Form, Meta, showToast, Switch } from "@calcom/ui";
import {
Button,
ColorPicker,
Form,
Meta,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
Switch,
} from "@calcom/ui";
import ThemeLabel from "../../../settings/ThemeLabel";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mt-6 mb-8 space-y-6 divide-y">
<div className="flex items-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>
<div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
interface TeamAppearanceValues {
hideBranding: boolean;
hideBookATeamMember: boolean;
brandColor: string;
darkBrandColor: string;
theme: string | null | undefined;
}
const ProfileView = () => {
@ -29,8 +66,6 @@ const ProfileView = () => {
},
});
const form = useForm<TeamAppearanceValues>();
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
{ teamId: Number(router.query.id) },
{
@ -40,86 +75,163 @@ const ProfileView = () => {
}
);
const form = useForm<TeamAppearanceValues>({
defaultValues: {
theme: team?.theme,
brandColor: team?.brandColor,
darkBrandColor: team?.darkBrandColor,
hideBranding: team?.hideBranding,
},
});
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
if (isLoading) {
return <SkeletonLoader title={t("booking_appearance")} description={t("appearance_team_description")} />;
}
return (
<>
<Meta title={t("booking_appearance")} description={t("appearance_team_description")} />
{!isLoading && (
<>
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
if (team) {
mutation.mutate({
id: team.id,
hideBranding: values.hideBranding,
hideBookATeamMember: values.hideBookATeamMember,
});
}
}}>
<div className="flex flex-col gap-8">
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding", { appName: APP_NAME })}
</label>
<p className="text-gray-500">
{t("team_disable_cal_branding_description", { appName: APP_NAME })}
</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={team?.hideBranding ?? false}
name="hideBranding"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBranding", isChecked);
}}
/>
)}
/>
</div>
</div>
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("hide_book_a_team_member")}
</label>
<p className="text-gray-500">{t("hide_book_a_team_member_description")}</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={team?.hideBookATeamMember ?? false}
name="hideBookATeamMember"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBookATeamMember", isChecked);
}}
/>
)}
/>
</div>
</div>
</div>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
</Form>
) : (
<div className="rounded-md border border-gray-200 p-5">
<span className="text-sm text-gray-600">{t("only_owner_change")}</span>
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
mutation.mutate({
id: team.id,
...values,
theme: values.theme || null,
});
}}>
<div className="mb-6 flex items-center text-sm">
<div>
<p className="font-semibold">{t("theme")}</p>
<p className="text-gray-600">{t("theme_applies_note")}</p>
</div>
)}
</>
</div>
<div className="flex flex-col justify-between sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={team.theme === null}
register={form.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("theme_light")}
defaultChecked={team.theme === "light"}
register={form.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("theme_dark")}
defaultChecked={team.theme === "dark"}
register={form.register}
/>
</div>
<hr className="my-8 border-gray-200" />
<div className="mb-6 flex items-center text-sm">
<div>
<p className="font-semibold">{t("custom_brand_colors")}</p>
<p className="mt-0.5 leading-5 text-gray-600">{t("customize_your_brand_colors")}</p>
</div>
</div>
<div className="block justify-between sm:flex">
<Controller
name="brandColor"
control={form.control}
defaultValue={team.brandColor}
render={() => (
<div>
<p className="mb-2 block text-sm font-medium text-gray-900">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={team.brandColor}
onChange={(value) => form.setValue("brandColor", value, { shouldDirty: true })}
/>
</div>
)}
/>
<Controller
name="darkBrandColor"
control={form.control}
defaultValue={team.darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="mb-2 block text-sm font-medium text-gray-900">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={team.darkBrandColor}
onChange={(value) => form.setValue("darkBrandColor", value, { shouldDirty: true })}
/>
</div>
)}
/>
</div>
<hr className="my-8 border-gray-200" />
<div className="flex flex-col gap-8">
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding", { appName: APP_NAME })}
</label>
<p className="text-gray-500">
{t("team_disable_cal_branding_description", { appName: APP_NAME })}
</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={team?.hideBranding ?? false}
name="hideBranding"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBranding", isChecked);
}}
/>
)}
/>
</div>
</div>
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("hide_book_a_team_member")}
</label>
<p className="text-gray-500">{t("hide_book_a_team_member_description")}</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={team?.hideBookATeamMember ?? false}
name="hideBookATeamMember"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBookATeamMember", isChecked);
}}
/>
)}
/>
</div>
</div>
</div>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
</Form>
) : (
<div className="rounded-md border border-gray-200 p-5">
<span className="text-sm text-gray-600">{t("only_owner_change")}</span>
</div>
)}
</>
);

View File

@ -0,0 +1,35 @@
interface ThemeLabelProps {
variant: "light" | "dark" | "system";
value?: "light" | "dark" | null;
label: string;
defaultChecked?: boolean;
register: any;
}
export default function ThemeLabel(props: ThemeLabelProps) {
const { variant, label, value, defaultChecked, register } = props;
return (
<label
className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mr-4 sm:mb-0"
htmlFor={`theme-${variant}`}>
<input
className="peer absolute top-8 left-8"
type="radio"
value={value}
id={`theme-${variant}`}
defaultChecked={defaultChecked}
{...register("theme")}
/>
<div className="relative z-10 rounded-lg ring-black transition-all peer-checked:ring-2">
<img
aria-hidden="true"
className="cover w-full rounded-lg"
src={`/theme-${variant}.svg`}
alt={`theme ${variant}`}
/>
</div>
<p className="mt-2 text-sm font-medium text-gray-600 peer-checked:text-gray-900">{label}</p>
</label>
);
}

View File

@ -33,6 +33,9 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
},
},
},
theme: true,
brandColor: true,
darkBrandColor: true,
eventTypes: {
where: {
hidden: false,

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "brandColor" TEXT NOT NULL DEFAULT '#292929',
ADD COLUMN "darkBrandColor" TEXT NOT NULL DEFAULT '#fafafa',
ADD COLUMN "theme" TEXT;

View File

@ -217,6 +217,9 @@ model Team {
createdAt DateTime @default(now())
/// @zod.custom(imports.teamMetadataSchema)
metadata Json?
theme String?
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
}
enum MembershipRole {

View File

@ -125,6 +125,9 @@ export const viewerTeamsRouter = router({
slug: z.string().optional(),
hideBranding: z.boolean().optional(),
hideBookATeamMember: z.boolean().optional(),
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
@ -153,6 +156,9 @@ export const viewerTeamsRouter = router({
bio: input.bio,
hideBranding: input.hideBranding,
hideBookATeamMember: input.hideBookATeamMember,
brandColor: input.brandColor,
darkBrandColor: input.darkBrandColor,
theme: input.theme,
};
if (