diff --git a/apps/web/components/v2/settings/CalendarSwitch.tsx b/apps/web/components/v2/settings/CalendarSwitch.tsx new file mode 100644 index 0000000000..9642ab7f17 --- /dev/null +++ b/apps/web/components/v2/settings/CalendarSwitch.tsx @@ -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 ( +
+ { + mutation.mutate({ isOn }); + }} + /> + {props.defaultSelected && ( + + {t("adding_events_to")} + + )} +
+ ); +} diff --git a/apps/web/components/v2/settings/ImageUploader.tsx b/apps/web/components/v2/settings/ImageUploader.tsx new file mode 100644 index 0000000000..e31a6dd87a --- /dev/null +++ b/apps/web/components/v2/settings/ImageUploader.tsx @@ -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 extends FormEvent { + 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 ( +
+
+ onCropComplete(croppedAreaPixels)} + onZoomChange={setZoom} + /> +
+ +
+ ); +} + +export default function ImageUploader({ + target, + id, + buttonMsg, + handleAvatarChange, + ...props +}: ImageUploaderProps) { + const { t } = useLocale(); + const [imageSrc, setImageSrc] = useState(null); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const [{ result }, setFile] = useFileReader({ + method: "readAsDataURL", + }); + + useEffect(() => { + if (props.imageSrc) setImageSrc(props.imageSrc); + }, [props.imageSrc]); + + const onInputFile = (e: FileEvent) => { + 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 ( + !opened && setFile(null) // unset file on close + }> + +
+ +
+
+ +
+
+ +
+
+
+
+ {!result && ( +
+ {!imageSrc && ( +

+ {t("no_target", { target })} +

+ )} + {imageSrc && ( + // eslint-disable-next-line @next/next/no-img-element + {target} + )} +
+ )} + {result && } + +
+
+
+ + + + + + +
+
+
+ ); +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 900cdae271..09ed4c0183 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index bb1b38a732..789e2f3e83 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -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", diff --git a/apps/web/pages/v2/settings/my-account/appearance.tsx b/apps/web/pages/v2/settings/my-account/appearance.tsx new file mode 100644 index 0000000000..8d8565bacb --- /dev/null +++ b/apps/web/pages/v2/settings/my-account/appearance.tsx @@ -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) => { + 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 ( +
{ + mutation.mutate({ + ...values, + theme: values.theme.value, + }); + }}> + ( + <> +
+
+

{t("follow_system_preferences")}

+

+ + Automatically adjust theme based on invitee system preferences. Note: This only applies to + the booking pages. + +

+
+ formMethods.setValue("theme", checked ? null : themeOptions[0])} + checked={!value} + /> +
+
+ { + if (event) formMethods.setValue("locale", { ...event }); + }} + /> + + )} + /> + ( + <> + + { + if (event) formMethods.setValue("timeZone", event.value); + }} + /> + + )} + /> + ( + <> + + { + if (event) formMethods.setValue("weekStart", { ...event }); + }} + /> + + )} + /> + + + ); +}; + +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, + }, + }; +}; diff --git a/apps/web/pages/v2/settings/my-account/profile.tsx b/apps/web/pages/v2/settings/my-account/profile.tsx new file mode 100644 index 0000000000..d33da3a381 --- /dev/null +++ b/apps/web/pages/v2/settings/my-account/profile.tsx @@ -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) => { + 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(null!); + + return ( + <> +
{ + mutation.mutate(values); + }}> +
+ {/* TODO upload new avatar */} + ( + <> + +
+ { + formMethods.setValue("avatar", newAvatar); + }} + imageSrc={value} + /> +
+ + )} + /> +
+ ( +
+ { + formMethods.setValue("username", e?.target.value); + }} + /> +
+ )} + /> + ( +
+ { + formMethods.setValue("name", e?.target.value); + }} + /> +
+ )} + /> + ( +
+ { + formMethods.setValue("bio", e?.target.value); + }} + /> +
+ )} + /> + + +
+ + + {/* Delete account Dialog */} + + + + + deleteAccount()}> + {/* Use trans component for translation */} +

+ + 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 + +

+
+
+ + + ); +}; + +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"), + }, + }, + }; +}; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 777c0e385d..8de61fc84a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.'" } diff --git a/packages/ui/v2/core/Shell.tsx b/packages/ui/v2/core/Shell.tsx index f17fc081f1..3f7b076263 100644 --- a/packages/ui/v2/core/Shell.tsx +++ b/packages/ui/v2/core/Shell.tsx @@ -135,7 +135,7 @@ const Layout = (props: LayoutProps) => {
- + {props.SidebarContainer || }
@@ -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 diff --git a/packages/ui/v2/core/layouts/SettingsLayout.tsx b/packages/ui/v2/core/layouts/SettingsLayout.tsx index ecf230379c..25b8e6b693 100644 --- a/packages/ui/v2/core/layouts/SettingsLayout.tsx +++ b/packages/ui/v2/core/layouts/SettingsLayout.tsx @@ -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) { return ( - -
- -
+ + + + }>
{children}
); diff --git a/packages/ui/v2/core/navigation/tabs/VerticalTabItem.tsx b/packages/ui/v2/core/navigation/tabs/VerticalTabItem.tsx index 27850646ef..ce8e244d2a 100644 --- a/packages/ui/v2/core/navigation/tabs/VerticalTabItem.tsx +++ b/packages/ui/v2/core/navigation/tabs/VerticalTabItem.tsx @@ -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 = ({ name, href, tabName, info, {props.icon && } -
-

{t(name)}

+
+

{t(name)}

{info &&

{t(info)}

}
{/* {isCurrent && ( diff --git a/packages/ui/v2/core/navigation/tabs/VerticalTabs.tsx b/packages/ui/v2/core/navigation/tabs/VerticalTabs.tsx index b770c2f187..3d85409baa 100644 --- a/packages/ui/v2/core/navigation/tabs/VerticalTabs.tsx +++ b/packages/ui/v2/core/navigation/tabs/VerticalTabs.tsx @@ -4,17 +4,18 @@ import VerticalTabItem, { VerticalTabItemProps } from "./VerticalTabItem"; export interface NavTabProps { tabs: VerticalTabItemProps[]; + children?: React.ReactNode; + className?: string; } -const NavTabs: FC = ({ tabs, ...props }) => { +const NavTabs: FC = ({ tabs, className = "", ...props }) => { return ( - <> - - + ); }; diff --git a/packages/ui/v2/modules/List.tsx b/packages/ui/v2/modules/List.tsx new file mode 100644 index 0000000000..94cc0523b5 --- /dev/null +++ b/packages/ui/v2/modules/List.tsx @@ -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 ( +
    + {props.children} +
+ ); +} + +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 ? ( + + {element} + + ) : ( + element + ); +} + +export function ListItemTitle( + 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( + 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 + ); +} diff --git a/packages/ui/v2/modules/integrations/DisconnectIntegration.tsx b/packages/ui/v2/modules/integrations/DisconnectIntegration.tsx new file mode 100644 index 0000000000..41b284f337 --- /dev/null +++ b/packages/ui/v2/modules/integrations/DisconnectIntegration.tsx @@ -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 ( + <> + + + + + mutation.mutate({ id: credentialId })} + /> + + + ); +}