perf: server-fetch data for all pages in /settings/my-account (#20712)

* wip

* fix type check

* refactor "/settings/my-account/profile"

* refactor "settings/my-account/general"

* refactor "/settings/my-accouunt/calendars"

* refactor "/settings/my-account/appearance"

* fix: type-check

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Amit Sharma <samit91848@gmail.com>
This commit is contained in:
Benny Joo
2025-07-24 22:36:40 +01:00
committed by GitHub
co-authored by Amit Sharma Amit Sharma
parent 6259b0b3cb
commit 6b9115dfde
32 changed files with 721 additions and 524 deletions
@@ -1,9 +1,12 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { redirect } from "next/navigation";
import { z } from "zod";
import { AppCategories } from "@calcom/prisma/enums";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { calendarsRouter } from "@calcom/trpc/server/routers/viewer/calendars/_router";
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
@@ -28,7 +31,24 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
redirect("/apps/installed/calendar");
}
return <InstalledApps category={parsedParams.data.category} />;
const [calendarsCaller, appsCaller] = await Promise.all([
createRouterCaller(calendarsRouter),
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
return (
<InstalledApps
connectedCalendars={connectedCalendars}
installedCalendars={installedCalendars}
category={parsedParams.data.category}
/>
);
};
export default InstalledAppsWrapper;
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateSettingsAppearance() {
revalidatePath("/settings/my-account/appearance");
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "~/settings/my-account/appearance-skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -1,7 +1,15 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { getCachedHasTeamPlan } from "@calcom/web/app/cache/membership";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import AppearancePage from "~/settings/my-account/appearance-view";
@@ -15,13 +23,29 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userId = session?.user?.id;
const redirectUrl = "/auth/login?callbackUrl=/settings/my-account/appearance";
return (
<SettingsHeader title={t("appearance")} description={t("appearance_description")}>
<AppearancePage />
</SettingsHeader>
);
if (!userId) {
redirect(redirectUrl);
}
const [meCaller, hasTeamPlan] = await Promise.all([
createRouterCaller(meRouter),
getCachedHasTeamPlan(userId),
]);
const user = await meCaller.get();
if (!user) {
redirect(redirectUrl);
}
const isCurrentUsernamePremium =
user && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const hasPaidPlan = IS_SELF_HOSTED ? true : hasTeamPlan?.hasTeamPlan || isCurrentUsernamePremium;
return <AppearancePage user={user} hasPaidPlan={hasPaidPlan} />;
};
export default Page;
@@ -0,0 +1,5 @@
import { CalendarListContainerSkeletonLoader } from "@components/apps/CalendarListContainer";
export default function Loading() {
return <CalendarListContainerSkeletonLoader />;
}
@@ -1,8 +1,8 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { Button } from "@calcom/ui/components/button";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { calendarsRouter } from "@calcom/trpc/server/routers/viewer/calendars/_router";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
@@ -16,25 +16,18 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const AddCalendarButton = () => {
return (
<>
<Button color="secondary" StartIcon="plus" href="/apps/categories/calendar">
{t("add_calendar")}
</Button>
</>
);
};
const [calendarsCaller, appsCaller] = await Promise.all([
createRouterCaller(calendarsRouter),
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
return (
<SettingsHeader
title={t("calendars")}
description={t("calendars_description")}
CTA={<AddCalendarButton />}>
<CalendarListContainer />
</SettingsHeader>
<CalendarListContainer connectedCalendars={connectedCalendars} installedCalendars={installedCalendars} />
);
};
@@ -1,5 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import { ConferencingAppsViewWebWrapper } from "@calcom/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper";
@@ -13,15 +12,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
return (
<ConferencingAppsViewWebWrapper
title={t("conferencing")}
description={t("conferencing_description")}
add={t("add")}
/>
);
return <ConferencingAppsViewWebWrapper />;
};
export default Page;
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateSettingsGeneral() {
revalidatePath("/settings/my-account/general");
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "~/settings/my-account/general-skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -1,10 +1,15 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import { revalidatePath } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { getTravelSchedule } from "@calcom/web/app/cache/travelSchedule";
import GeneralQueryView from "~/settings/my-account/general-view";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import GeneralView from "~/settings/my-account/general-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -16,17 +21,20 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const revalidatePage = async () => {
"use server";
revalidatePath("settings/my-account/general");
};
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userId = session?.user?.id;
const redirectUrl = "/auth/login?callbackUrl=/settings/my-account/general";
return (
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
<GeneralQueryView revalidatePage={revalidatePage} />
</SettingsHeader>
);
if (!userId) {
return redirect(redirectUrl);
}
const meCaller = await createRouterCaller(meRouter);
const [user, travelSchedules] = await Promise.all([meCaller.get(), getTravelSchedule(userId)]);
if (!user) {
redirect(redirectUrl);
}
return <GeneralView user={user} travelSchedules={travelSchedules ?? []} />;
};
export default Page;
@@ -1,10 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import CreateNewOutOfOfficeEntryButton from "@calcom/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton";
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
import { OutOfOfficeToggleGroup } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,22 +11,8 @@ export const generateMetadata = async () =>
"/settings/my-account/out-of-office"
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader
title={t("out_of_office")}
description={t("out_of_office_description")}
CTA={
<div className="flex gap-2">
<OutOfOfficeToggleGroup />
<CreateNewOutOfOfficeEntryButton data-testid="add_entry_ooo" />
</div>
}>
<OutOfOfficeEntriesList />
</SettingsHeader>
);
const Page = () => {
return <OutOfOfficeEntriesList />;
};
export default Page;
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "~/settings/my-account/profile-skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -1,8 +1,8 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import { redirect } from "next/navigation";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME } from "@calcom/lib/constants";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import ProfileView from "~/settings/my-account/profile-view";
@@ -16,16 +16,13 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const meCaller = await createRouterCaller(meRouter);
const user = await meCaller.get({ includePasswordAdded: true });
if (!user) {
redirect("/auth/login");
}
return (
<SettingsHeader
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<ProfileView />
</SettingsHeader>
);
return <ProfileView user={user} />;
};
export default Page;
@@ -1,8 +1,5 @@
import { getTranslate } from "app/_utils";
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import PushNotificationsView from "~/settings/my-account/push-notifications-view";
export const generateMetadata = async () =>
@@ -14,17 +11,8 @@ export const generateMetadata = async () =>
"/settings/my-account/push-notifications"
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader
title={t("push_notifications")}
description={t("push_notifications_description")}
borderInShellHeader={true}>
<PushNotificationsView />
</SettingsHeader>
);
const Page = () => {
return <PushNotificationsView />;
};
export default Page;
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { revalidateTag, unstable_cache } from "next/cache";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
const CACHE_TAGS = {
HAS_TEAM_PLAN: "MembershipRepository.findFirstAcceptedMembershipByUserId",
} as const;
export const getCachedHasTeamPlan = unstable_cache(
async (userId: number) => {
const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId);
return { hasTeamPlan: !!hasTeamPlan };
},
["getCachedHasTeamPlan"],
{
revalidate: NEXTJS_CACHE_TTL,
tags: [CACHE_TAGS.HAS_TEAM_PLAN],
}
);
export const revalidateHasTeamPlan = async () => {
revalidateTag(CACHE_TAGS.HAS_TEAM_PLAN);
};
+11
View File
@@ -0,0 +1,11 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateSettingsProfile() {
revalidatePath("/settings/my-account/profile");
}
export async function revalidateSettingsCalendars() {
revalidatePath("/settings/my-account/calendars");
}
+26
View File
@@ -0,0 +1,26 @@
"use server";
import { revalidateTag } from "next/cache";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { TravelScheduleRepository } from "@calcom/lib/server/repository/travelSchedule";
import { unstable_cache } from "@calcom/lib/unstable_cache";
const CACHE_TAGS = {
TRAVEL_SCHEDULES: "TravelRepository.findTravelSchedulesByUserId",
} as const;
export const getTravelSchedule = unstable_cache(
async (userId: number) => {
return await TravelScheduleRepository.findTravelSchedulesByUserId(userId);
},
["getTravelSchedule"],
{
revalidate: NEXTJS_CACHE_TTL,
tags: [CACHE_TAGS.TRAVEL_SCHEDULES],
}
);
export const revalidateTravelSchedules = async () => {
revalidateTag(CACHE_TAGS.TRAVEL_SCHEDULES);
};
@@ -7,13 +7,16 @@ import { DestinationCalendarSettingsWebWrapper } from "@calcom/atoms/destination
import { SelectedCalendarsSettingsWebWrapper } from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
import AppListCard from "@calcom/features/apps/components/AppListCard";
import { SkeletonLoader } from "@calcom/features/apps/components/SkeletonLoader";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { ShellSubHeading } from "@calcom/ui/components/layout";
import { List } from "@calcom/ui/components/list";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateSettingsCalendars } from "@calcom/web/app/cache/path/settings/my-account";
import { QueryCell } from "@lib/QueryCell";
import useRouterQuery from "@lib/hooks/useRouterQuery";
@@ -63,9 +66,43 @@ function CalendarList(props: Props) {
);
}
export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) {
const AddCalendarButton = () => {
const { t } = useLocale();
return (
<>
<Button color="secondary" StartIcon="plus" href="/apps/categories/calendar">
{t("add_calendar")}
</Button>
</>
);
};
export const CalendarListContainerSkeletonLoader = () => {
const { t } = useLocale();
return (
<SettingsHeader
title={t("calendars")}
description={t("calendars_description")}
CTA={<AddCalendarButton />}>
<SkeletonLoader />
</SettingsHeader>
);
};
type CalendarListContainerProps = {
connectedCalendars: RouterOutputs["viewer"]["calendars"]["connectedCalendars"];
installedCalendars: RouterOutputs["viewer"]["apps"]["integrations"];
heading?: boolean;
fromOnboarding?: boolean;
};
export function CalendarListContainer({
connectedCalendars: data,
installedCalendars,
heading = true,
fromOnboarding,
}: CalendarListContainerProps) {
const { t } = useLocale();
const { heading = true, fromOnboarding } = props;
const { error, setQuery: setError } = useRouterQuery("error");
useEffect(() => {
@@ -85,70 +122,61 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
}
),
utils.viewer.calendars.connectedCalendars.invalidate(),
revalidateSettingsCalendars(),
]);
const query = trpc.viewer.calendars.connectedCalendars.useQuery();
const installedCalendars = trpc.viewer.apps.integrations.useQuery({
variant: "calendar",
onlyInstalled: true,
});
const mutation = trpc.viewer.calendars.setDestinationCalendar.useMutation({
onSuccess: () => {
utils.viewer.calendars.connectedCalendars.invalidate();
revalidateSettingsCalendars();
},
});
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
return (
<>
{!!data.connectedCalendars.length || !!installedCalendars.data?.items.length ? (
<>
{heading && (
<>
<DestinationCalendarSettingsWebWrapper />
<Suspense fallback={<SkeletonLoader />}>
<SelectedCalendarsSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
isPending={mutation.isPending}
/>
</Suspense>
</>
)}
</>
) : fromOnboarding ? (
<>
{!!query.data?.connectedCalendars.length && (
<ShellSubHeading
className="mt-4"
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
/>
)}
<CalendarList onChanged={onChanged} />
</>
) : (
<EmptyScreen
Icon="calendar"
headline={t("no_category_apps", {
category: t("calendar").toLowerCase(),
})}
description={t(`no_category_apps_description_calendar`)}
buttonRaw={
<Button
color="secondary"
data-testid="connect-calendar-apps"
href="/apps/categories/calendar">
{t(`connect_calendar_apps`)}
</Button>
}
/>
)}
</>
);
}}
/>
<SettingsHeader
title={t("calendars")}
description={t("calendars_description")}
CTA={<AddCalendarButton />}>
{!!data.connectedCalendars.length || !!installedCalendars?.items.length ? (
<>
{heading && (
<>
<DestinationCalendarSettingsWebWrapper />
<Suspense fallback={<SkeletonLoader />}>
<SelectedCalendarsSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
isPending={mutation.isPending}
/>
</Suspense>
</>
)}
</>
) : fromOnboarding ? (
<>
{!!data?.connectedCalendars.length && (
<ShellSubHeading
className="mt-4"
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
/>
)}
<CalendarList onChanged={onChanged} />
</>
) : (
<EmptyScreen
Icon="calendar"
headline={t("no_category_apps", {
category: t("calendar").toLowerCase(),
})}
description={t(`no_category_apps_description_calendar`)}
buttonRaw={
<Button color="secondary" data-testid="connect-calendar-apps" href="/apps/categories/calendar">
{t(`connect_calendar_apps`)}
</Button>
}
/>
)}
</SettingsHeader>
);
}
@@ -12,6 +12,7 @@ import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/Bul
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import type { Icon } from "@calcom/ui/components/icon";
@@ -181,9 +182,11 @@ type ModalState = {
type PageProps = {
category: AppCategories;
connectedCalendars: RouterOutputs["viewer"]["calendars"]["connectedCalendars"];
installedCalendars: RouterOutputs["viewer"]["apps"]["integrations"];
};
export default function InstalledApps({ category }: PageProps) {
export default function InstalledApps({ category, connectedCalendars, installedCalendars }: PageProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
@@ -233,7 +236,12 @@ export default function InstalledApps({ category }: PageProps) {
{categoryList.includes(category) && (
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
)}
{category === "calendar" && <CalendarListContainer />}
{category === "calendar" && (
<CalendarListContainer
connectedCalendars={connectedCalendars}
installedCalendars={installedCalendars}
/>
)}
{category === "other" && (
<IntegrationsContainer
handleDisconnect={handleDisconnect}
@@ -0,0 +1,37 @@
"use client";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<SettingsHeader title={t("appearance")} description={t("appearance_description")}>
<SkeletonContainer>
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<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="[&>*]: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" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-lg">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
</SettingsHeader>
);
};
@@ -1,5 +1,7 @@
"use client";
import { revalidateSettingsAppearance } from "app/(use-page-wrapper)/settings/(settings-layout)/my-account/appearance/actions";
import { revalidateHasTeamPlan } from "app/cache/membership";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -8,11 +10,11 @@ import type { z } from "zod";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
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";
@@ -23,38 +25,9 @@ import { Alert } from "@calcom/ui/components/alert";
import { UpgradeTeamsBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { SettingsToggle, ColorPicker, Form } from "@calcom/ui/components/form";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { useCalcomTheme } from "@calcom/ui/styles";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<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="[&>*]: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" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-lg">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
const useBrandColors = (
currentTheme: string | null,
{
@@ -158,6 +131,8 @@ const AppearanceView = ({
const mutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (data) => {
await utils.viewer.me.invalidate();
revalidateSettingsAppearance();
revalidateHasTeamPlan();
showToast(t("settings_updated_successfully"), "success");
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
resetBookerLayoutThemeReset({ metadata: data.metadata });
@@ -173,11 +148,13 @@ const AppearanceView = ({
},
onSettled: async () => {
await utils.viewer.me.invalidate();
revalidateSettingsAppearance();
revalidateHasTeamPlan();
},
});
return (
<div>
<SettingsHeader title={t("appearance")} description={t("appearance_description")}>
<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>
@@ -425,17 +402,8 @@ const AppearanceView = ({
/>
</>
)}
</div>
</SettingsHeader>
);
};
const AppearancePage = () => {
const { data: user, isPending } = trpc.viewer.me.get.useQuery();
const { isPending: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
if (isPending || isTeamPlanStatusLoading || !user) return <SkeletonLoader />;
return <AppearanceView user={user} hasPaidPlan={hasPaidPlan} />;
};
export default AppearancePage;
export default AppearanceView;
@@ -0,0 +1,23 @@
"use client";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
<SkeletonContainer>
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
</SettingsHeader>
);
};
@@ -1,11 +1,13 @@
"use client";
import { revalidateSettingsGeneral } from "app/(use-page-wrapper)/settings/(settings-layout)/my-account/general/actions";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { formatLocalizedDateTime } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localeOptions } from "@calcom/lib/i18n";
@@ -18,8 +20,8 @@ import { Form } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTravelSchedules } from "@calcom/web/app/cache/travelSchedule";
import TravelScheduleModal from "@components/settings/TravelScheduleModal";
@@ -45,55 +47,13 @@ export type FormValues = {
}[];
};
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
interface GeneralViewProps {
localeProp: string;
user: RouterOutputs["viewer"]["me"]["get"];
travelSchedules: RouterOutputs["viewer"]["travelSchedules"]["get"];
revalidatePage: GeneralQueryViewProps["revalidatePage"];
}
type GeneralQueryViewProps = {
revalidatePage: () => Promise<void>;
};
const GeneralQueryView = ({ revalidatePage }: GeneralQueryViewProps) => {
const { t } = useLocale();
const { data: user, isPending } = trpc.viewer.me.get.useQuery();
const { data: travelSchedules, isPending: isPendingTravelSchedules } =
trpc.viewer.travelSchedules.get.useQuery();
if (isPending || isPendingTravelSchedules) return <SkeletonLoader />;
if (!user) {
throw new Error(t("something_went_wrong"));
}
return (
<GeneralView
user={user}
travelSchedules={travelSchedules || []}
localeProp={user.locale}
revalidatePage={revalidatePage}
/>
);
};
const GeneralView = ({ localeProp, user, travelSchedules, revalidatePage }: GeneralViewProps) => {
const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
const localeProp = user.locale ?? "en";
const utils = trpc.useContext();
const {
t,
@@ -106,6 +66,8 @@ const GeneralView = ({ localeProp, user, travelSchedules, revalidatePage }: Gene
const mutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (res) => {
await utils.viewer.me.invalidate();
revalidateSettingsGeneral();
revalidateTravelSchedules();
reset(getValues());
showToast(t("settings_updated_successfully"), "success");
await update(res);
@@ -114,13 +76,14 @@ const GeneralView = ({ localeProp, user, travelSchedules, revalidatePage }: Gene
window.calNewLocale = res.locale;
document.cookie = `calNewLocale=${res.locale}; path=/`;
}
await revalidatePage();
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
onSettled: async () => {
await utils.viewer.me.invalidate();
revalidateSettingsGeneral();
revalidateTravelSchedules();
setIsUpdateBtnLoading(false);
},
});
@@ -188,215 +151,217 @@ const GeneralView = ({ localeProp, user, travelSchedules, revalidatePage }: Gene
const watchedTzSchedules = formMethods.watch("travelSchedules");
return (
<div>
<Form
form={formMethods}
handleSubmit={async (values) => {
setIsUpdateBtnLoading(true);
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
{!watchedTzSchedules.length ? (
<Button
color="secondary"
className="mt-2"
StartIcon="calendar"
onClick={() => setIsTZScheduleOpen(true)}>
{t("schedule_timezone_change")}
</Button>
) : (
<div className="bg-muted border-subtle mt-2 rounded-md border p-4">
<Label>{t("travel_schedule")}</Label>
<div className="border-subtle bg-default mt-4 rounded-md border text-sm">
{watchedTzSchedules.map((schedule, index) => {
return (
<div
className={classNames(
"flex items-center p-4",
index !== 0 ? "border-subtle border-t" : ""
)}
key={index}>
<div>
<div className="text-emphasis font-semibold">{`${formatLocalizedDateTime(
schedule.startDate,
{ day: "numeric", month: "long" },
language
)} ${
schedule.endDate
? `- ${formatLocalizedDateTime(
schedule.endDate,
{ day: "numeric", month: "long" },
language
)}`
: ``
}`}</div>
<div className="text-subtle">{schedule.timeZone.replace(/_/g, " ")}</div>
</div>
<Button
color="secondary"
className="ml-auto"
variant="icon"
StartIcon="trash-2"
onClick={() => {
const updatedSchedules = watchedTzSchedules.filter(
(s, filterIndex) => filterIndex !== index
);
formMethods.setValue("travelSchedules", updatedSchedules, { shouldDirty: true });
}}
/>
</div>
);
})}
</div>
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
<div>
<Form
form={formMethods}
handleSubmit={async (values) => {
setIsUpdateBtnLoading(true);
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
{!watchedTzSchedules.length ? (
<Button
StartIcon="plus"
color="secondary"
className="mt-4"
className="mt-2"
StartIcon="calendar"
onClick={() => setIsTZScheduleOpen(true)}>
{t("add")}
{t("schedule_timezone_change")}
</Button>
) : (
<div className="bg-muted border-subtle mt-2 rounded-md border p-4">
<Label>{t("travel_schedule")}</Label>
<div className="border-subtle bg-default mt-4 rounded-md border text-sm">
{watchedTzSchedules.map((schedule, index) => {
return (
<div
className={classNames(
"flex items-center p-4",
index !== 0 ? "border-subtle border-t" : ""
)}
key={index}>
<div>
<div className="text-emphasis font-semibold">{`${formatLocalizedDateTime(
schedule.startDate,
{ day: "numeric", month: "long" },
language
)} ${
schedule.endDate
? `- ${formatLocalizedDateTime(
schedule.endDate,
{ day: "numeric", month: "long" },
language
)}`
: ``
}`}</div>
<div className="text-subtle">{schedule.timeZone.replace(/_/g, " ")}</div>
</div>
<Button
color="secondary"
className="ml-auto"
variant="icon"
StartIcon="trash-2"
onClick={() => {
const updatedSchedules = watchedTzSchedules.filter(
(s, filterIndex) => filterIndex !== index
);
formMethods.setValue("travelSchedules", updatedSchedules, { shouldDirty: true });
}}
/>
</div>
);
})}
</div>
<Button
StartIcon="plus"
color="secondary"
className="mt-4"
onClick={() => setIsTZScheduleOpen(true)}>
{t("add")}
</Button>
</div>
)}
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-subtle mt-2 flex items-center text-xs">
{t("timeformat_profile_hint")}
</div>
)}
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-subtle mt-2 flex items-center text-xs">
{t("timeformat_profile_hint")}
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
</div>
<SectionBottomActions align="end">
<Button
loading={isUpdateBtnLoading}
disabled={isDisabled}
color="primary"
type="submit"
data-testid="general-submit-button">
<>{t("update")}</>
</Button>
</SectionBottomActions>
</Form>
<SectionBottomActions align="end">
<Button
loading={isUpdateBtnLoading}
disabled={isDisabled}
color="primary"
type="submit"
data-testid="general-submit-button">
<>{t("update")}</>
</Button>
</SectionBottomActions>
</Form>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
disabled={mutation.isPending}
checked={isAllowDynamicBookingChecked}
onCheckedChange={(checked) => {
setIsAllowDynamicBookingChecked(checked);
mutation.mutate({ allowDynamicBooking: checked });
}}
switchContainerClassName="mt-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
disabled={mutation.isPending}
checked={isAllowDynamicBookingChecked}
onCheckedChange={(checked) => {
setIsAllowDynamicBookingChecked(checked);
mutation.mutate({ allowDynamicBooking: checked });
}}
switchContainerClassName="mt-6"
/>
<SettingsToggle
data-testid="my-seo-indexing-switch"
toggleSwitchAtTheEnd={true}
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
disabled={mutation.isPending || user.organizationSettings?.allowSEOIndexing === false}
checked={isAllowSEOIndexingChecked}
onCheckedChange={(checked) => {
setIsAllowSEOIndexingChecked(checked);
mutation.mutate({ allowSEOIndexing: checked });
}}
switchContainerClassName="mt-6"
/>
<SettingsToggle
data-testid="my-seo-indexing-switch"
toggleSwitchAtTheEnd={true}
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
disabled={mutation.isPending || user.organizationSettings?.allowSEOIndexing === false}
checked={isAllowSEOIndexingChecked}
onCheckedChange={(checked) => {
setIsAllowSEOIndexingChecked(checked);
mutation.mutate({ allowSEOIndexing: checked });
}}
switchContainerClassName="mt-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
disabled={mutation.isPending}
checked={isReceiveMonthlyDigestEmailChecked}
onCheckedChange={(checked) => {
setIsReceiveMonthlyDigestEmailChecked(checked);
mutation.mutate({ receiveMonthlyDigestEmail: checked });
}}
switchContainerClassName="mt-6"
/>
<TravelScheduleModal
open={isTZScheduleOpen}
onOpenChange={() => setIsTZScheduleOpen(false)}
setValue={formMethods.setValue}
existingSchedules={formMethods.getValues("travelSchedules") ?? []}
/>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
disabled={mutation.isPending}
checked={isReceiveMonthlyDigestEmailChecked}
onCheckedChange={(checked) => {
setIsReceiveMonthlyDigestEmailChecked(checked);
mutation.mutate({ receiveMonthlyDigestEmail: checked });
}}
switchContainerClassName="mt-6"
/>
<TravelScheduleModal
open={isTZScheduleOpen}
onOpenChange={() => setIsTZScheduleOpen(false)}
setValue={formMethods.setValue}
existingSchedules={formMethods.getValues("travelSchedules") ?? []}
/>
</div>
</SettingsHeader>
);
};
export default GeneralQueryView;
export default GeneralView;
@@ -0,0 +1,35 @@
"use client";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
SkeletonButton,
SkeletonAvatar,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui/components/skeleton";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<SettingsHeader
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<SkeletonContainer>
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
</SettingsHeader>
);
};
@@ -1,6 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { revalidateSettingsProfile } from "app/cache/path/settings/my-account";
// eslint-disable-next-line no-restricted-imports
import { get, pick } from "lodash";
import { signOut, useSession } from "next-auth/react";
@@ -12,6 +13,7 @@ import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { emailSchema } from "@calcom/lib/emailSchema";
@@ -34,12 +36,6 @@ import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import {
SkeletonButton,
SkeletonContainer,
SkeletonText,
SkeletonAvatar,
} from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import TwoFactor from "@components/auth/TwoFactor";
@@ -50,24 +46,6 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
import type { TRPCClientErrorLike } from "@trpc/client";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
interface DeleteAccountValues {
totpCode: string;
}
@@ -87,18 +65,20 @@ export type FormValues = {
bio: string;
secondaryEmails: Email[];
};
type Props = {
user: RouterOutputs["viewer"]["me"]["get"];
};
const ProfileView = () => {
const ProfileView = ({ user }: Props) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { update } = useSession();
const { data: user, isPending } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
utils.viewer.me.invalidate();
utils.viewer.me.shouldVerifyEmail.invalidate();
revalidateSettingsProfile();
if (res.hasEmailBeenChanged && res.sendEmailVerification) {
showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success");
@@ -125,6 +105,7 @@ const ProfileView = () => {
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (e) => {
showToast(t(e.message), "error");
@@ -136,6 +117,7 @@ const ProfileView = () => {
setShowSecondaryEmailModalOpen(false);
setNewlyAddedSecondaryEmail(res?.data?.email);
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (error) => {
setSecondaryEmailAddErrorMessage(error?.message || "");
@@ -160,6 +142,8 @@ const ProfileView = () => {
const onDeleteMeSuccessMutation = async () => {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
showToast(t("Your account was deleted"), "success");
setHasDeleteErrors(false); // dismiss any open errors
@@ -189,6 +173,7 @@ const ProfileView = () => {
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
});
const deleteMeWithoutPasswordMutation = trpc.viewer.me.deleteMeWithoutPassword.useMutation({
@@ -196,6 +181,7 @@ const ProfileView = () => {
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
});
@@ -241,10 +227,6 @@ const ProfileView = () => {
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
if (isPending || !user) {
return <SkeletonLoader />;
}
const userEmail = user.email || "";
const defaultValues = {
username: user.username || "",
@@ -269,7 +251,10 @@ const ProfileView = () => {
};
return (
<>
<SettingsHeader
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
@@ -305,6 +290,7 @@ const ProfileView = () => {
onSuccessMutation={async () => {
showToast(t("settings_updated_successfully"), "success");
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
}}
onErrorMutation={() => {
showToast(t("error_updating_settings"), "error");
@@ -473,7 +459,7 @@ const ProfileView = () => {
onCancel={() => setNewlyAddedSecondaryEmail(undefined)}
/>
)}
</>
</SettingsHeader>
);
};
@@ -1,6 +1,7 @@
"use client";
import { useWebPush } from "@calcom/features/notifications/WebPushContext";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
@@ -9,11 +10,16 @@ const PushNotificationsView = () => {
const { subscribe, unsubscribe, isSubscribed, isLoading } = useWebPush();
return (
<div className="border-subtle rounded-b-xl border-x border-b px-4 pb-10 pt-8 sm:px-6">
<Button color="primary" onClick={isSubscribed ? unsubscribe : subscribe} disabled={isLoading}>
{isSubscribed ? t("disable_browser_notifications") : t("allow_browser_notifications")}
</Button>
</div>
<SettingsHeader
title={t("push_notifications")}
description={t("push_notifications_description")}
borderInShellHeader={true}>
<div className="border-subtle rounded-b-xl border-x border-b px-4 pb-10 pt-8 sm:px-6">
<Button color="primary" onClick={isSubscribed ? unsubscribe : subscribe} disabled={isLoading}>
{isSubscribed ? t("disable_browser_notifications") : t("allow_browser_notifications")}
</Button>
</div>
</SettingsHeader>
);
};
@@ -23,6 +23,7 @@ import {
DataTableSegment,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
@@ -39,7 +40,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
import CreateNewOutOfOfficeEntryButton from "./CreateNewOutOfOfficeEntryButton";
import { CreateOrEditOutOfOfficeEntryModal } from "./CreateOrEditOutOfOfficeModal";
import type { BookingRedirectForm } from "./CreateOrEditOutOfOfficeModal";
import { OutOfOfficeTab } from "./OutOfOfficeToggleGroup";
import { OutOfOfficeTab, OutOfOfficeToggleGroup } from "./OutOfOfficeToggleGroup";
interface OutOfOfficeEntry {
id: number;
@@ -63,10 +64,22 @@ interface OutOfOfficeEntry {
}
export default function OutOfOfficeEntriesList() {
const { t } = useLocale();
return (
<DataTableProvider useSegments={useSegments}>
<OutOfOfficeEntriesListContent />
</DataTableProvider>
<SettingsHeader
title={t("out_of_office")}
description={t("out_of_office_description")}
CTA={
<div className="flex gap-2">
<OutOfOfficeToggleGroup />
<CreateNewOutOfOfficeEntryButton data-testid="add_entry_ooo" />
</div>
}>
<DataTableProvider useSegments={useSegments}>
<OutOfOfficeEntriesListContent />
</DataTableProvider>
</SettingsHeader>
);
}
@@ -121,6 +121,20 @@ export class MembershipRepository {
});
}
static async findFirstAcceptedMembershipByUserId(userId: number) {
return await prisma.membership.findFirst({
where: {
accepted: true,
userId,
team: {
slug: {
not: null,
},
},
},
});
}
static async createMany(data: IMembership[]) {
return await prisma.membership.createMany({
data: data.map((item) => ({
+3
View File
@@ -0,0 +1,3 @@
import { cache as unstable_cache } from "./unstable_cache";
export { unstable_cache };
@@ -0,0 +1,24 @@
/**
* This implementation is adapted from https://github.com/vercel/next.js/issues/51613#issuecomment-1892644565.
* It is a wrapper around `unstable_cache` that adds serialization and deserialization
*/
import { unstable_cache } from "next/cache";
import { parse, stringify } from "superjson";
export const cache = <T, P extends unknown[]>(
fn: (...params: P) => Promise<T>,
keys: Parameters<typeof unstable_cache>[1],
opts: Parameters<typeof unstable_cache>[2]
) => {
const wrap = async (params: unknown[]): Promise<string> => {
const result = await fn(...(params as P));
return stringify(result);
};
const cachedFn = unstable_cache(wrap, keys, opts);
return async (...params: P): Promise<T> => {
const result = await cachedFn(params);
return parse(result);
};
};
@@ -12,12 +12,6 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
type ConferencingAppsViewWebWrapperProps = {
title: string;
description: string;
add: string;
};
export type UpdateUsersDefaultConferencingAppParams = {
appSlug: string;
appLink?: string;
@@ -175,11 +169,17 @@ const useDisconnectIntegrationModalController = () => {
};
};
export const ConferencingAppsViewWebWrapper = ({
title,
description,
add,
}: ConferencingAppsViewWebWrapperProps) => {
const AddConferencingButton = () => {
const { t } = useLocale();
return (
<Button color="secondary" StartIcon="plus" href="/apps/categories/conferencing">
{t("add")}
</Button>
);
};
export const ConferencingAppsViewWebWrapper = () => {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -203,20 +203,12 @@ export const ConferencingAppsViewWebWrapper = ({
);
};
const AddConferencingButton = () => {
return (
<Button color="secondary" StartIcon="plus" href="/apps/categories/conferencing">
{add}
</Button>
);
};
const disconnectIntegrationModalCtrl = useDisconnectIntegrationModalController();
return (
<SettingsHeader
title={title}
description={description}
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
borderInShellHeader={true}>
<>
@@ -1,4 +1,5 @@
import { prisma } from "@calcom/prisma";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type HasTeamPlanOptions = {
ctx: {
@@ -6,18 +7,11 @@ type HasTeamPlanOptions = {
};
};
export const hasTeamPlanHandler = async ({ ctx: { user } }: HasTeamPlanOptions) => {
const hasTeamPlan = await prisma.membership.findFirst({
where: {
accepted: true,
userId: user.id,
team: {
slug: {
not: null,
},
},
},
});
export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => {
const userId = ctx.user.id;
const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId);
return { hasTeamPlan: !!hasTeamPlan };
};