Merge branch 'main' into fix/after-meeting-ends-migration
This commit is contained in:
commit
ea3e2396ef
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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 } };
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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.'"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user