diff --git a/components/integrations/CalendarSwitch.tsx b/components/integrations/CalendarSwitch.tsx
new file mode 100644
index 00000000..9e4330d9
--- /dev/null
+++ b/components/integrations/CalendarSwitch.tsx
@@ -0,0 +1,75 @@
+import { useMutation } from "react-query";
+
+import showToast from "@lib/notification";
+import { trpc } from "@lib/trpc";
+
+import Switch from "@components/ui/Switch";
+
+export default function CalendarSwitch(props: {
+ type: string;
+ externalId: string;
+ title: string;
+ defaultSelected: boolean;
+}) {
+ 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 });
+ }}
+ />
+
+ );
+}
diff --git a/components/integrations/CalendarsList.tsx b/components/integrations/CalendarsList.tsx
new file mode 100644
index 00000000..c845dad3
--- /dev/null
+++ b/components/integrations/CalendarsList.tsx
@@ -0,0 +1,45 @@
+import React, { ReactNode } from "react";
+
+import { List } from "@components/List";
+import Button from "@components/ui/Button";
+
+import ConnectIntegration from "./ConnectIntegrations";
+import IntegrationListItem from "./IntegrationListItem";
+
+interface Props {
+ calendars: {
+ children?: ReactNode;
+ description: string;
+ imageSrc: string;
+ title: string;
+ type: string;
+ }[];
+ onChanged: () => void | Promise;
+}
+
+const CalendarsList = (props: Props): JSX.Element => {
+ const { calendars, onChanged } = props;
+ return (
+
+ {calendars.map((item) => (
+ (
+
+ )}
+ onOpenChange={onChanged}
+ />
+ }
+ />
+ ))}
+
+ );
+};
+
+export default CalendarsList;
diff --git a/components/integrations/ConnectIntegrations.tsx b/components/integrations/ConnectIntegrations.tsx
new file mode 100644
index 00000000..40b10d90
--- /dev/null
+++ b/components/integrations/ConnectIntegrations.tsx
@@ -0,0 +1,56 @@
+import { useState } from "react";
+import { useMutation } from "react-query";
+
+import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
+import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
+
+import { ButtonBaseProps } from "@components/ui/Button";
+
+export default function ConnectIntegration(props: {
+ type: string;
+ render: (renderProps: ButtonBaseProps) => JSX.Element;
+ onOpenChange: (isOpen: boolean) => void | Promise;
+}) {
+ const { type } = props;
+ const [isLoading, setIsLoading] = useState(false);
+ const mutation = useMutation(async () => {
+ const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add");
+ if (!res.ok) {
+ throw new Error("Something went wrong");
+ }
+ const json = await res.json();
+ window.location.href = json.url;
+ setIsLoading(true);
+ });
+ const [isModalOpen, _setIsModalOpen] = useState(false);
+
+ const setIsModalOpen = (v: boolean) => {
+ _setIsModalOpen(v);
+ props.onOpenChange(v);
+ };
+
+ return (
+ <>
+ {props.render({
+ onClick() {
+ if (["caldav_calendar", "apple_calendar"].includes(type)) {
+ // special handlers
+ setIsModalOpen(true);
+ return;
+ }
+
+ mutation.mutate();
+ },
+ loading: mutation.isLoading || isLoading,
+ disabled: isModalOpen,
+ })}
+ {type === "caldav_calendar" && (
+
+ )}
+
+ {type === "apple_calendar" && (
+
+ )}
+ >
+ );
+}
diff --git a/components/integrations/ConnectedCalendarsList.tsx b/components/integrations/ConnectedCalendarsList.tsx
new file mode 100644
index 00000000..5c1c185a
--- /dev/null
+++ b/components/integrations/ConnectedCalendarsList.tsx
@@ -0,0 +1,98 @@
+import React, { Fragment, ReactNode } from "react";
+
+import { List } from "@components/List";
+import { Alert } from "@components/ui/Alert";
+import Button from "@components/ui/Button";
+
+import CalendarSwitch from "./CalendarSwitch";
+import DisconnectIntegration from "./DisconnectIntegration";
+import IntegrationListItem from "./IntegrationListItem";
+
+type CalIntersection =
+ | {
+ calendars: {
+ externalId: string;
+ name: string;
+ isSelected: boolean;
+ }[];
+ error?: never;
+ }
+ | {
+ calendars?: never;
+ error: {
+ message: string;
+ };
+ };
+
+type Props = {
+ onChanged: (isOpen: boolean) => void | Promise;
+ connectedCalendars: (CalIntersection & {
+ credentialId: number;
+ integration: {
+ type: string;
+ imageSrc: string;
+ title: string;
+ children?: ReactNode;
+ };
+ primary?: { externalId: string } | undefined | null;
+ })[];
+};
+
+const ConnectedCalendarsList = (props: Props): JSX.Element => {
+ const { connectedCalendars, onChanged } = props;
+ return (
+
+ {connectedCalendars.map((item) => (
+
+ {item.calendars ? (
+ (
+
+ )}
+ onOpenChange={onChanged}
+ />
+ }>
+
+ {item.calendars.map((cal) => (
+
+ ))}
+
+
+ ) : (
+ (
+
+ )}
+ onOpenChange={onChanged}
+ />
+ }
+ />
+ )}
+
+ ))}
+
+ );
+};
+
+export default ConnectedCalendarsList;
diff --git a/components/integrations/DisconnectIntegration.tsx b/components/integrations/DisconnectIntegration.tsx
new file mode 100644
index 00000000..f656de80
--- /dev/null
+++ b/components/integrations/DisconnectIntegration.tsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+import { useMutation } from "react-query";
+
+import { Dialog } from "@components/Dialog";
+import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
+import { ButtonBaseProps } from "@components/ui/Button";
+
+export default function DisconnectIntegration(props: {
+ /** Integration credential id */
+ id: number;
+ render: (renderProps: ButtonBaseProps) => JSX.Element;
+ onOpenChange: (isOpen: boolean) => void | Promise;
+}) {
+ const [modalOpen, setModalOpen] = useState(false);
+ const mutation = useMutation(
+ async () => {
+ const res = await fetch("/api/integrations", {
+ method: "DELETE",
+ body: JSON.stringify({ id: props.id }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ if (!res.ok) {
+ throw new Error("Something went wrong");
+ }
+ },
+ {
+ async onSettled() {
+ props.onOpenChange(modalOpen);
+ },
+ onSuccess() {
+ setModalOpen(false);
+ },
+ }
+ );
+ return (
+ <>
+
+ {props.render({
+ onClick() {
+ setModalOpen(true);
+ },
+ disabled: modalOpen,
+ loading: mutation.isLoading,
+ })}
+ >
+ );
+}
diff --git a/components/integrations/IntegrationListItem.tsx b/components/integrations/IntegrationListItem.tsx
new file mode 100644
index 00000000..ae11c7b2
--- /dev/null
+++ b/components/integrations/IntegrationListItem.tsx
@@ -0,0 +1,30 @@
+import Image from "next/image";
+import { ReactNode } from "react";
+
+import classNames from "@lib/classNames";
+
+import { ListItem, ListItemText, ListItemTitle } from "@components/List";
+
+function IntegrationListItem(props: {
+ imageSrc: string;
+ title: string;
+ description: string;
+ actions?: ReactNode;
+ children?: ReactNode;
+}): JSX.Element {
+ return (
+
+
+
+
+ {props.title}
+ {props.description}
+
+
{props.actions}
+
+ {props.children && {props.children}
}
+
+ );
+}
+
+export default IntegrationListItem;
diff --git a/components/integrations/SubHeadingTitleWithConnections.tsx b/components/integrations/SubHeadingTitleWithConnections.tsx
new file mode 100644
index 00000000..80d120d3
--- /dev/null
+++ b/components/integrations/SubHeadingTitleWithConnections.tsx
@@ -0,0 +1,29 @@
+import { ReactNode } from "react";
+
+import Badge from "@components/ui/Badge";
+
+function pluralize(opts: { num: number; plural: string; singular: string }) {
+ if (opts.num === 0) {
+ return opts.singular;
+ }
+ return opts.singular;
+}
+
+export default function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
+ const num = props.numConnections;
+ return (
+ <>
+ {props.title}
+ {num ? (
+
+ {num}{" "}
+ {pluralize({
+ num,
+ singular: "connection",
+ plural: "connections",
+ })}
+
+ ) : null}
+ >
+ );
+}
diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx
index fa458e03..cd3b615f 100644
--- a/components/ui/UsernameInput.tsx
+++ b/components/ui/UsernameInput.tsx
@@ -1,6 +1,10 @@
import React from "react";
-const UsernameInput = React.forwardRef((props, ref) => (
+interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
+ label?: string;
+}
+
+const UsernameInput = React.forwardRef((props, ref) => (
// todo, check if username is already taken here?