feat: add theming options to teams (#6807)
parent
5b165e6ebf
commit
7ee5967075
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -33,6 +33,9 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
|
|||
},
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
hidden: false,
|
||||
|
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
|
|
Loading…
Reference in New Issue