Merge branch 'main' into fix/after-meeting-ends-migration

This commit is contained in:
kodiakhq[bot] 2022-08-26 00:14:52 +00:00 committed by GitHub
commit ea3e2396ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1322 additions and 29 deletions

View File

@ -0,0 +1,84 @@
import { useMutation } from "react-query";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import Badge from "@calcom/ui/v2/core/Badge";
import Switch from "@calcom/ui/v2/core/Switch";
import showToast from "@calcom/ui/v2/core/notfications";
export function CalendarSwitch(props: {
type: string;
externalId: string;
title: string;
defaultSelected: boolean;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const mutation = useMutation<
unknown,
unknown,
{
isOn: boolean;
}
>(
async ({ isOn }) => {
const body = {
integration: props.type,
externalId: props.externalId,
};
if (isOn) {
const res = await fetch("/api/availability/calendar", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Something went wrong");
}
} else {
const res = await fetch("/api/availability/calendar", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Something went wrong");
}
}
},
{
async onSettled() {
await utils.invalidateQueries(["viewer.integrations"]);
},
onError() {
showToast(`Something went wrong when toggling "${props.title}""`, "error");
},
}
);
return (
<div className="flex space-x-2 py-1">
<Switch
key={props.externalId}
name="enabled"
label={props.title}
defaultChecked={props.defaultSelected}
onCheckedChange={(isOn: boolean) => {
mutation.mutate({ isOn });
}}
/>
{props.defaultSelected && (
<Badge variant="gray">
<Icon.FiArrowLeft /> {t("adding_events_to")}
</Badge>
)}
</div>
);
}

View File

@ -0,0 +1,169 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import Cropper from "react-easy-crop";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import Button from "@calcom/ui/v2/core/Button";
import { Area, getCroppedImg } from "@lib/cropImage";
import { useFileReader } from "@lib/hooks/useFileReader";
import { useLocale } from "@lib/hooks/useLocale";
import Slider from "@components/Slider";
type ImageUploaderProps = {
id: string;
buttonMsg: string;
handleAvatarChange: (imageSrc: string) => void;
imageSrc?: string;
target: string;
};
interface FileEvent<T = Element> extends FormEvent<T> {
target: EventTarget & T;
}
// This is separate to prevent loading the component until file upload
function CropContainer({
onCropComplete,
imageSrc,
}: {
imageSrc: string;
onCropComplete: (croppedAreaPixels: Area) => void;
}) {
const { t } = useLocale();
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const handleZoomSliderChange = (value: number) => {
value < 1 ? setZoom(1) : setZoom(value);
};
return (
<div className="crop-container h-40 max-h-40 w-40 rounded-full">
<div className="relative h-40 w-40 rounded-full">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onCropComplete={(croppedArea, croppedAreaPixels) => onCropComplete(croppedAreaPixels)}
onZoomChange={setZoom}
/>
</div>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
label={t("slide_zoom_drag_instructions")}
changeHandler={handleZoomSliderChange}
/>
</div>
);
}
export default function ImageUploader({
target,
id,
buttonMsg,
handleAvatarChange,
...props
}: ImageUploaderProps) {
const { t } = useLocale();
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [{ result }, setFile] = useFileReader({
method: "readAsDataURL",
});
useEffect(() => {
if (props.imageSrc) setImageSrc(props.imageSrc);
}, [props.imageSrc]);
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
setFile(e.target.files[0]);
};
const showCroppedImage = useCallback(
async (croppedAreaPixels: Area | null) => {
try {
if (!croppedAreaPixels) return;
const croppedImage = await getCroppedImg(
result as string /* result is always string when using readAsDataUrl */,
croppedAreaPixels
);
setImageSrc(croppedImage);
handleAvatarChange(croppedImage);
} catch (e) {
console.error(e);
}
},
[result, handleAvatarChange]
);
return (
<Dialog
onOpenChange={
(opened) => !opened && setFile(null) // unset file on close
}>
<DialogTrigger asChild>
<div className="flex items-center">
<Button color="secondary" type="button" className="py-1 text-xs">
{buttonMsg}
</Button>
</div>
</DialogTrigger>
<DialogContent>
<div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<h3 className="font-cal text-lg leading-6 text-gray-900" id="modal-title">
{t("upload_target", { target })}
</h3>
</div>
</div>
<div className="mb-4">
<div className="cropper mt-6 flex flex-col items-center justify-center p-8">
{!result && (
<div className="flex h-20 max-h-20 w-20 items-center justify-start rounded-full bg-gray-50">
{!imageSrc && (
<p className="w-full text-center text-sm text-white sm:text-xs">
{t("no_target", { target })}
</p>
)}
{imageSrc && (
// eslint-disable-next-line @next/next/no-img-element
<img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />
)}
</div>
)}
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
<label className="mt-8 rounded-sm border border-gray-300 bg-white px-3 py-1 text-xs font-medium leading-4 text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
<input
onInput={onInputFile}
type="file"
name={id}
placeholder={t("upload_image")}
className="pointer-events-none absolute mt-4 opacity-0"
accept="image/*"
/>
{t("choose_a_file")}
</label>
</div>
</div>
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose asChild>
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</Button>
</DialogClose>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -5,7 +5,13 @@ import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { isIpInBanlist } from "@calcom/lib/getIP";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
const V2_WHITELIST = ["/settings/admin", "/availability", "/bookings", "/event-types"];
const V2_WHITELIST = [
"/settings/admin",
"/settings/my-account",
"/availability",
"/bookings",
"/event-types",
];
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;

View File

@ -126,6 +126,13 @@ const nextConfig = {
destination: "/settings/profile",
permanent: true,
},
/* V2 testers get redirected to the new settings */
{
source: "/settings/profile",
has: [{ type: "cookie", key: "calcom-v2-early-access" }],
destination: "/settings/my-account/profile",
permanent: false,
},
{
source: "/bookings",
destination: "/bookings/upcoming",

View File

@ -0,0 +1,212 @@
import { GetServerSidePropsContext } from "next";
import { Trans } from "next-i18next";
import { useRouter } from "next/router";
import { title } from "process";
import { useMemo, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import Avatar from "@calcom/ui/v2/core/Avatar";
import Badge from "@calcom/ui/v2/core/Badge";
import { Button } from "@calcom/ui/v2/core/Button";
import Loader from "@calcom/ui/v2/core/Loader";
import Switch from "@calcom/ui/v2/core/Switch";
import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect";
import ColorPicker from "@calcom/ui/v2/core/colorpicker";
import Select from "@calcom/ui/v2/core/form/Select";
import { TextField, Form, Label } from "@calcom/ui/v2/core/form/fields";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
import showToast from "@calcom/ui/v2/core/notfications";
import { getSession } from "@lib/auth";
import { inferSSRProps } from "@lib/types/inferSSRProps";
const AppearanceView = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
const { user } = props;
const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: () => {
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const themeOptions = [
{ value: "light", label: t("light") },
{ value: "dark", label: t("dark") },
];
const formMethods = useForm();
return (
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate({
...values,
theme: values.theme.value,
});
}}>
<Controller
name="theme"
control={formMethods.control}
defaultValue={user.theme}
render={({ field: { value } }) => (
<>
<div className="flex items-center">
<div>
<p className="font-semibold">{t("follow_system_preferences")}</p>
<p className="text-gray-600">
<Trans i18nKey="system_preference_description">
Automatically adjust theme based on invitee system preferences. Note: This only applies to
the booking pages.
</Trans>
</p>
</div>
<Switch
onCheckedChange={(checked) => formMethods.setValue("theme", checked ? null : themeOptions[0])}
checked={!value}
/>
</div>
<div>
<Select
options={themeOptions}
onChange={(event) => {
if (event) formMethods.setValue("theme", { ...event });
}}
isDisabled={!value}
defaultValue={value || themeOptions[0]}
value={value || themeOptions[0]}
/>
</div>
</>
)}
/>
<hr className="border-1 my-8 border-neutral-200" />
<div className="mb-6 flex items-center">
<div>
<p className="font-semibold">{t("custom_brand_colors")}</p>
<p className="text-gray-600">{t("customize_your_brand_colors")}</p>
</div>
</div>
<div className="flex justify-between">
<Controller
name="brandColor"
control={formMethods.control}
defaultValue={user.brandColor}
render={({ field: { value } }) => (
<div>
<p className="block text-sm font-medium text-gray-900">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={user.brandColor}
onChange={(value) => formMethods.setValue("brandColor", value)}
/>
</div>
)}
/>
<Controller
name="darkBrandColor"
control={formMethods.control}
defaultValue={user.darkBrandColor}
render={({ field: { value } }) => (
<div>
<p className="block text-sm font-medium text-gray-900">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={user.darkBrandColor}
onChange={(value) => formMethods.setValue("darkBrandColor", value)}
/>
</div>
)}
/>
</div>
{/* TODO future PR to preview brandColors */}
{/* <Button
color="secondary"
EndIcon={Icon.FiExternalLink}
className="mt-6"
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
Preview
</Button> */}
<hr className="border-1 my-8 border-neutral-200" />
<Controller
name="hideBranding"
control={formMethods.control}
defaultValue={user.hideBranding}
render={({ field: { value } }) => (
<>
<div className="flex items-center">
<div>
<div className="flex items-center">
<p className="mr-2 font-semibold">{t("disable_cal_branding")}</p>{" "}
<Badge variant="gray">{t("pro")}</Badge>
</div>
<p className="text-gray-600">{t("removes_cal_branding")}</p>
</div>
<Switch
onCheckedChange={(checked) => formMethods.setValue("hideBranding", checked)}
checked={value}
/>
</div>
</>
)}
/>
<Button color="primary" className="mt-8">
{t("update")}
</Button>
</Form>
);
};
AppearanceView.getLayout = getLayout;
export default AppearanceView;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
username: true,
timeZone: true,
timeFormat: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
hideBranding: true,
theme: true,
eventTypes: {
select: {
title: true,
},
},
},
});
if (!user) {
throw new Error("User seems logged in but cannot be found in the db");
}
return {
props: {
user,
},
};
};

View File

@ -0,0 +1,126 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import { Fragment } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import Badge from "@calcom/ui/v2/core/Badge";
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
import { List, ListItem, ListItemText, ListItemTitle } from "@calcom/ui/v2/modules/List";
import DestinationCalendarSelector from "@calcom/ui/v2/modules/event-types/DestinationCalendarSelector";
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
import { QueryCell } from "@lib/QueryCell";
import { CalendarSwitch } from "@components/v2/settings/CalendarSwitch";
const CalendarsView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const query = trpc.useQuery(["viewer.connectedCalendars"]);
const mutation = trpc.useMutation("viewer.setDestinationCalendar", {
async onSettled() {
await utils.invalidateQueries(["viewer.connectedCalendars"]);
},
});
return (
<QueryCell
query={query}
success={({ data }) => {
console.log("🚀 ~ file: calendars.tsx ~ line 28 ~ CalendarsView ~ data", data);
return data.connectedCalendars.length ? (
<div>
<div className="mt-4 rounded-md border-neutral-200 bg-white p-2 sm:mx-0 sm:p-10 md:border md:p-6 xl:mt-0">
<div className="mt-4 rounded-md border-neutral-200 bg-white p-2 sm:mx-0 sm:p-10 md:border md:p-2 xl:mt-0">
<Icon.FiCalendar className="h-5 w-5" />
</div>
<h4 className="leading-20 mt-2 text-xl font-semibold text-black">{t("add_to_calendar")}</h4>
<p className="pb-2 text-sm text-gray-600">
<Trans i18nKey="add_to_calendar_description">
Where to add events when you re booked. You can override this on a per-event basis in
advanced settings in the event type.
</Trans>
</p>
<DestinationCalendarSelector
hidePlaceholder
value={data.destinationCalendar?.externalId}
onChange={mutation.mutate}
isLoading={mutation.isLoading}
/>
</div>
<h4 className="leading-20 mt-12 text-xl font-semibold text-black">{t("check_for_conflicts")}</h4>
<p className="pb-2 text-sm text-gray-600">{t("select_calendars")}</p>
<List>
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars && (
<ListItem expanded className="flex-col">
<div className="flex w-full flex-1 items-center space-x-3 pb-5 pl-1 pt-1 rtl:space-x-reverse">
{
// eslint-disable-next-line @next/next/no-img-element
item.integration.logo && (
<img
className="h-10 w-10"
src={item.integration.logo}
alt={item.integration.title}
/>
)
}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3" className="mb-1 space-x-2">
<Link href={"/apps/" + item.integration.slug}>
{item.integration.name || item.integration.title}
</Link>
{data?.destinationCalendar?.credentialId === item.credentialId && (
<Badge variant="green">Default</Badge>
)}
</ListItemTitle>
<ListItemText component="p">{item.integration.description}</ListItemText>
</div>
<div>
<DisconnectIntegration credentialId={item.credentialId} label={t("disconnect")} />
</div>
</div>
<div className="w-full border-t border-gray-200">
<p className="px-2 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-2 px-2 pt-4">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
type={item.integration.type}
defaultSelected={cal.externalId === data?.destinationCalendar?.externalId}
/>
))}
</ul>
</div>
</ListItem>
)}
</Fragment>
))}
</List>
</div>
) : (
<EmptyScreen
Icon={Icon.FiCalendar}
headline="No calendar installed"
description="You have not yet connected any of your calendars"
buttonText="Add a calendar"
buttonOnClick={() => console.log("Button Clicked")}
/>
);
}}
/>
);
};
CalendarsView.getLayout = getLayout;
export default CalendarsView;

View File

@ -0,0 +1,100 @@
import { GetServerSidePropsContext } from "next";
import getApps from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Icon } from "@calcom/ui";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/v2/core/Dropdown";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
import { getSession } from "@lib/auth";
import { inferSSRProps } from "@lib/types/inferSSRProps";
const ConferencingLayout = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
const { apps } = props;
// Error reason: getaddrinfo EAI_AGAIN http
// const query = trpc.useQuery(["viewer.integrations", { variant: "conferencing", onlyInstalled: true }], {
// suspense: true,
// });
return (
<div className="m-4 rounded-md border-neutral-200 bg-white sm:mx-0 md:border xl:mt-0">
{apps.map((app) => (
<div
key={app.title}
className="flex w-full flex-1 items-center space-x-3 border-b py-5 rtl:space-x-reverse">
<img className="h-10 w-10" src={app.logo} alt={app.title} />
<div className="flex-grow truncate pl-2">
<h3 className="truncate text-sm font-medium text-neutral-900">{app.title}</h3>
<p className="truncate text-sm text-gray-500">{app.description}</p>
</div>
<Dropdown>
<DropdownMenuTrigger className="focus:ring-brand-900 mr-4 block h-[36px] w-auto justify-center rounded-md border border-gray-200 bg-transparent text-gray-700 focus:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-1">
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DisconnectIntegration
credentialId={app.credentialId}
label={t("remove_app")}
trashIcon
isGlobal={app.isGlobal}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
))}
</div>
);
};
ConferencingLayout.getLayout = getLayout;
export default ConferencingLayout;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const videoCredentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
app: {
categories: {
has: "video",
},
},
},
});
const apps = getApps(videoCredentials)
.filter((app) => {
return app.variant === "conferencing" && app.credentials.length;
})
.map((app) => {
return {
slug: app.slug,
title: app.title,
logo: app.logo,
description: app.description,
credentialId: app.credentials[0].id,
isGlobal: app.isGlobal,
};
});
return { props: { apps } };
};

View File

@ -0,0 +1,214 @@
import { GetServerSidePropsContext } from "next";
import { TFunction } from "next-i18next";
import { useRouter } from "next/router";
import { useMemo } from "react";
import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/v2/core/Button";
import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect";
import Select from "@calcom/ui/v2/core/form/Select";
import { Form, Label } from "@calcom/ui/v2/core/form/fields";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
import showToast from "@calcom/ui/v2/core/notfications";
import { withQuery } from "@lib/QueryCell";
import { getSession } from "@lib/auth";
import { nameOfDay } from "@lib/core/i18n/weekday";
import { inferSSRProps } from "@lib/types/inferSSRProps";
interface GeneralViewProps {
localeProp: string;
t: TFunction;
user: {
timeZone: string;
timeFormat: number | null;
weekStart: string;
};
}
const WithQuery = withQuery(["viewer.public.i18n"], { context: { skipBatch: true } });
const GeneralQueryView = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
return <WithQuery success={({ data }) => <GeneralView localeProp={data.locale} t={t} {...props} />} />;
};
const GeneralView = ({ localeProp, t, user }: GeneralViewProps) => {
const router = useRouter();
// const { data: user, isLoading } = trpc.useQuery(["viewer.me"]);
const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: () => {
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const localeOptions = useMemo(() => {
return (router.locales || []).map((locale) => ({
value: locale,
label: new Intl.DisplayNames(localeProp, { type: "language" }).of(locale) || "",
}));
}, [localeProp, router.locales]);
const timeFormatOptions = [
{ value: 12, label: t("12_hour") },
{ value: 24, label: t("24_hour") },
];
const weekStartOptions = [
{ value: "Sunday", label: nameOfDay(localeProp, 0) },
{ value: "Monday", label: nameOfDay(localeProp, 1) },
{ value: "Tuesday", label: nameOfDay(localeProp, 2) },
{ value: "Wednesday", label: nameOfDay(localeProp, 3) },
{ value: "Thursday", label: nameOfDay(localeProp, 4) },
{ value: "Friday", label: nameOfDay(localeProp, 5) },
{ value: "Saturday", label: nameOfDay(localeProp, 6) },
];
const formMethods = useForm({
defaultValues: {
locale: {
value: localeProp || "",
label: localeOptions.find((option) => option.value === localeProp)?.label || "",
},
timeZone: user?.timeZone || "",
timeFormat: {
value: user?.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === user?.timeFormat)?.label || 12,
},
weekStart: {
value: user?.weekStart,
label: nameOfDay(localeProp, user?.weekStart === "Sunday" ? 0 : 1),
},
},
});
return (
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Controller
name="locale"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="mt-8 text-gray-900">
<>{t("language")}</>
</Label>
<Select
options={localeOptions}
value={value}
onChange={(event) => {
if (event) formMethods.setValue("locale", { ...event });
}}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="mt-8 text-gray-900">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value);
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="mt-8 text-gray-900">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event });
}}
/>
</>
)}
/>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="mt-8 text-gray-900">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event });
}}
/>
</>
)}
/>
<Button color="primary" className="mt-8">
<>{t("update")}</>
</Button>
</Form>
);
};
GeneralQueryView.getLayout = getLayout;
export default GeneralQueryView;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
timeZone: true,
timeFormat: true,
weekStart: true,
},
});
if (!user) {
throw new Error("User seems logged in but cannot be found in the db");
}
return {
props: {
user,
},
};
};

View File

@ -0,0 +1,219 @@
import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { Trans } from "next-i18next";
import { useState, useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import Avatar from "@calcom/ui/v2/core/Avatar";
import { Button } from "@calcom/ui/v2/core/Button";
import { Dialog, DialogTrigger, DialogContent } from "@calcom/ui/v2/core/Dialog";
import { TextField, Form, Label } from "@calcom/ui/v2/core/form/fields";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
import showToast from "@calcom/ui/v2/core/notfications";
import { getSession } from "@lib/auth";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import ImageUploader from "@components/v2/settings/ImageUploader";
const ProfileView = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
const { user } = props;
// const { data: user, isLoading } = trpc.useQuery(["viewer.me"]);
const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: () => {
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const deleteAccount = async () => {
await fetch("/api/user/me", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}).catch((e) => {
console.error(`Error Removing user: ${user?.id}, email: ${user?.email} :`, e);
});
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
signOut({ callbackUrl: "/auth/logout?survey=true" });
} else {
signOut({ callbackUrl: "/auth/logout" });
}
};
const formMethods = useForm({
defaultValues: {
avatar: user.avatar || "",
username: user?.username || "",
name: user?.name || "",
bio: user?.bio || "",
},
});
const avatarRef = useRef<HTMLInputElement>(null!);
return (
<>
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate(values);
}}>
<div className="flex items-center">
{/* TODO upload new avatar */}
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={user.emailMd5} size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar);
}}
imageSrc={value}
/>
</div>
</>
)}
/>
</div>
<Controller
control={formMethods.control}
name="username"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="username"
label={t("personal_cal_url")}
addOnLeading="https://"
value={value}
onChange={(e) => {
formMethods.setValue("username", e?.target.value);
}}
/>
</div>
)}
/>
<Controller
control={formMethods.control}
name="name"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="username"
label={t("full_name")}
value={value}
onChange={(e) => {
formMethods.setValue("name", e?.target.value);
}}
/>
</div>
)}
/>
<Controller
control={formMethods.control}
name="bio"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="bio"
label={t("about")}
hint={t("bio_hint")}
value={value}
onChange={(e) => {
formMethods.setValue("bio", e?.target.value);
}}
/>
</div>
)}
/>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
<hr className="my-6 border-2 border-neutral-200" />
<Label>{t("danger_zone")}</Label>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<DialogTrigger asChild>
<Button color="destructive" className="mt-1 border-2" StartIcon={Icon.FiTrash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
<DialogContent
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal")}
type="confirmation"
actionText={t("delete_my_account")}
Icon={Icon.FiAlertTriangle}
actionOnClick={() => deleteAccount()}>
{/* Use trans component for translation */}
<p>
<Trans i18nKey="delete_account_warning">
Anyone who you have shared your account link with will no longer be able to book using it and
any preferences you have saved will be lost
</Trans>
</p>
</DialogContent>
</Dialog>
</Form>
</>
);
};
ProfileView.getLayout = getLayout;
export default ProfileView;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
id: true,
username: true,
email: true,
name: true,
bio: true,
avatar: true,
},
});
if (!user) {
throw new Error("User seems logged in but cannot be found in the db");
}
return {
props: {
user: {
...user,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
},
};
};

View File

@ -1055,5 +1055,21 @@
"event_advanced_tab_title":"Advanced",
"select_which_cal":"Select which calendar to add bookings to",
"custom_event_name":"Custom event name",
"custom_event_name_description":"Create customised event names to display on calendar event"
"custom_event_name_description":"Create customised event names to display on calendar event",
"settings_updated_successfully": "Settings updated successfully",
"error_updating_settings":"Error updating settings",
"personal_cal_url": "My personal Cal URL",
"bio_hint": "A few sentences about yourself. this will appear on your personal url page.",
"delete_account_modal_title": "Delete Account",
"confirm_delete_account_modal": "Are you sure you want to delete your Cal.com account?",
"delete_my_account": "Delete my account",
"start_of_week": "Start of week",
"select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.",
"check_for_conflicts": "Check for conflicts",
"adding_events_to": "Adding events to",
"follow_system_preferences": "Follow system preferences",
"custom_brand_colors": "Custom brand colors",
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
"pro": "Pro",
"removes_cal_branding": "Removes any Cal related brandings, i.e. 'Powered by Cal.'"
}

View File

@ -135,7 +135,7 @@ const Layout = (props: LayoutProps) => {
</div>
<div className="flex h-screen overflow-hidden" data-testid="dashboard-shell">
<SideBarContainer />
{props.SidebarContainer || <SideBarContainer />}
<div className="flex w-0 flex-1 flex-col overflow-hidden">
<ImpersonatingBanner />
<MainContainer {...props} />
@ -153,6 +153,7 @@ type LayoutProps = {
children: ReactNode;
CTA?: ReactNode;
large?: boolean;
SidebarContainer?: ReactNode;
HeadingLeftIcon?: ReactNode;
backPath?: string; // renders back button to specified path
// use when content needs to expand with flex

View File

@ -2,6 +2,7 @@ import React, { ComponentProps } from "react";
import { Icon } from "../../../Icon";
import Shell from "../Shell";
import { VerticalTabItem } from "../navigation/tabs";
import VerticalTabs from "../navigation/tabs/VerticalTabs";
const tabs = [
@ -10,12 +11,13 @@ const tabs = [
href: "/settings/profile",
icon: Icon.FiUser,
children: [
{ name: "profile", href: "/settings/profile" },
{ name: "general", href: "/settings/profile" },
{ name: "calendars", href: "/settings/profile" },
{ name: "conferencing", href: "/settings/profile" },
{ name: "appearance", href: "/settings/profile" },
{ name: "referrals", href: "/settings/profile" },
{ name: "profile", href: "/settings/my-account/profile" },
{ name: "general", href: "/settings/my-account/general" },
{ name: "calendars", href: "/settings/my-account/calendars" },
{ name: "conferencing", href: "/settings/my-account/conferencing" },
{ name: "appearance", href: "/settings/my-account/appearance" },
// TODO
{ name: "referrals", href: "/settings/my-account/referrals" },
],
},
{
@ -72,10 +74,20 @@ export default function SettingsLayout({
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
return (
<Shell {...rest}>
<div className="flex-grow-0 bg-gray-50 p-2">
<VerticalTabs tabs={tabs} />
</div>
<Shell
flexChildrenContainer
{...rest}
SidebarContainer={
<VerticalTabs tabs={tabs} className="py-3 pl-3">
<VerticalTabItem
name="Settings"
href="/"
icon={Icon.FiArrowLeft}
textClassNames="text-md font-medium leading-none text-black"
className="mb-1"
/>
</VerticalTabs>
}>
<div className="flex-1 [&>*]:flex-1">{children}</div>
</Shell>
);

View File

@ -14,6 +14,8 @@ export type VerticalTabItemProps = {
icon?: SVGComponent;
disabled?: boolean;
children?: VerticalTabItemProps[];
textClassNames?: string;
className?: string;
isChild?: boolean;
} & (
| {
@ -59,19 +61,17 @@ const VerticalTabItem: FC<VerticalTabItemProps> = ({ name, href, tabName, info,
<a
onClick={onClick}
className={classNames(
isCurrent ? "bg-gray-200 text-gray-900" : "text-gray-600 hover:bg-gray-100",
"group flex h-14 w-64 flex-row items-center rounded-md px-3 py-[10px]",
props.textClassNames || "text-sm font-medium leading-none text-gray-600",
"group flex h-14 w-64 flex-row items-center rounded-md px-3 py-[10px] hover:bg-gray-100 group-hover:text-gray-700 [&[aria-current='page']]:bg-gray-200 [&[aria-current='page']]:text-gray-900",
props.disabled && "pointer-events-none !opacity-30",
(isChild || !props.icon) && "ml-9 mr-5 w-auto",
!info ? "h-9" : "h-14"
!info ? "h-9" : "h-14",
props.className
)}
aria-current={isCurrent ? "page" : undefined}>
{props.icon && <props.icon className="mr-[10px] h-[16px] w-[16px] self-start stroke-[2px]" />}
<div
className={classNames(
isCurrent ? "font-bold text-gray-900" : "text-gray-600 group-hover:text-gray-700"
)}>
<p className="text-sm font-medium leading-none">{t(name)}</p>
<div>
<p>{t(name)}</p>
{info && <p className="pt-1 text-xs font-normal">{t(info)}</p>}
</div>
{/* {isCurrent && (

View File

@ -4,17 +4,18 @@ import VerticalTabItem, { VerticalTabItemProps } from "./VerticalTabItem";
export interface NavTabProps {
tabs: VerticalTabItemProps[];
children?: React.ReactNode;
className?: string;
}
const NavTabs: FC<NavTabProps> = ({ tabs, ...props }) => {
const NavTabs: FC<NavTabProps> = ({ tabs, className = "", ...props }) => {
return (
<>
<nav className="no-scrollbar flex flex-col space-y-1" aria-label="Tabs" {...props}>
{tabs.map((tab, idx) => (
<VerticalTabItem {...tab} key={idx} />
))}
</nav>
</>
<nav className={`no-scrollbar flex flex-col space-y-1 ${className}`} aria-label="Tabs" {...props}>
{props.children}
{tabs.map((tab, idx) => (
<VerticalTabItem {...tab} key={idx} />
))}
</nav>
);
};

View File

@ -0,0 +1,72 @@
import Link from "next/link";
import { createElement } from "react";
import classNames from "@calcom/lib/classNames";
export function List(props: JSX.IntrinsicElements["ul"]) {
return (
<ul {...props} className={classNames("-mx-4 rounded-sm sm:mx-0 sm:overflow-hidden", props.className)}>
{props.children}
</ul>
);
}
export type ListItemProps = { expanded?: boolean } & ({ href?: never } & JSX.IntrinsicElements["li"]);
export function ListItem(props: ListItemProps) {
const { href, expanded, ...passThroughProps } = props;
const elementType = href ? "a" : "li";
const element = createElement(
elementType,
{
...passThroughProps,
className: classNames(
"items-center rounded-md bg-white min-w-0 flex-1 flex border-neutral-200 p-2 sm:mx-0 sm:p-8 md:border md:p-4 xl:mt-0",
expanded ? "my-2 border" : "border -mb-px last:mb-0",
props.className,
(props.onClick || href) && "hover:bg-neutral-50"
),
},
props.children
);
return href ? (
<Link passHref href={href}>
{element}
</Link>
) : (
element
);
}
export function ListItemTitle<TComponent extends keyof JSX.IntrinsicElements = "span">(
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
) {
const { component = "span", ...passThroughProps } = props;
return createElement(
component,
{
...passThroughProps,
className: classNames("text-sm font-medium text-neutral-900 truncate", props.className),
},
props.children
);
}
export function ListItemText<TComponent extends keyof JSX.IntrinsicElements = "span">(
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
) {
const { component = "span", ...passThroughProps } = props;
return createElement(
component,
{
...passThroughProps,
className: classNames("text-sm text-gray-500 truncate", props.className),
},
props.children
);
}

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Button } from "@calcom/ui/v2/core/Button";
import { Dialog, DialogTrigger, DialogContent } from "@calcom/ui/v2/core/Dialog";
import showToast from "@calcom/ui/v2/core/notfications";
export default function DisconnectIntegration({
credentialId,
label,
trashIcon,
isGlobal,
}: {
credentialId: number;
label: string;
trashIcon?: boolean;
isGlobal?: boolean;
}) {
const { t } = useLocale();
const [modalOpen, setModalOpen] = useState(false);
const mutation = trpc.useMutation("viewer.deleteCredential", {
onSuccess: () => {
showToast("Integration deleted successfully", "success");
setModalOpen(false);
},
onError: () => {
showToast("Error deleting app", "error");
setModalOpen(false);
},
});
return (
<>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogTrigger asChild>
<Button color="destructive" StartIcon={trashIcon ? Icon.FiTrash : undefined} disabled={isGlobal}>
{label}
</Button>
</DialogTrigger>
<DialogContent
title="Remove app"
description="Are you sure you want to remove this app?"
type="confirmation"
actionText="Yes, remove app"
Icon={Icon.FiAlertCircle}
actionOnClick={() => mutation.mutate({ id: credentialId })}
/>
</Dialog>
</>
);
}