Fixes Apple Calendar onboarding and type fixes (#988)
* Type fixes * Type fixes * Attemp to prevent unknown error in prod * Type fixes * Type fixes for onboarding * Extracts ConnectIntegration * Extracts IntegrationListItem * Extracts CalendarsList * Uses CalendarList on onboarding * Removes deprecated Alert * Extracts DisconnectIntegration * Extracts CalendarSwitch * Extracts ConnectedCalendarsList * Extracted connectedCalendar logic for reuse * Extracted SubHeadingTitleWithConnections * Type fixes * Fetched connected calendars in onboarding * Refreshes data on when adding/removing calendars on onboarding * Removed testing code * Type fixes * Feedback * Moved integration helpers * I was sleepy Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
bd99a06765
commit
85d7122e43
27 changed files with 698 additions and 614 deletions
75
components/integrations/CalendarSwitch.tsx
Normal file
75
components/integrations/CalendarSwitch.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className="py-1">
|
||||||
|
<Switch
|
||||||
|
key={props.externalId}
|
||||||
|
name="enabled"
|
||||||
|
label={props.title}
|
||||||
|
defaultChecked={props.defaultSelected}
|
||||||
|
onCheckedChange={(isOn: boolean) => {
|
||||||
|
mutation.mutate({ isOn });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
45
components/integrations/CalendarsList.tsx
Normal file
45
components/integrations/CalendarsList.tsx
Normal file
|
@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarsList = (props: Props): JSX.Element => {
|
||||||
|
const { calendars, onChanged } = props;
|
||||||
|
return (
|
||||||
|
<List>
|
||||||
|
{calendars.map((item) => (
|
||||||
|
<IntegrationListItem
|
||||||
|
key={item.title}
|
||||||
|
{...item}
|
||||||
|
actions={
|
||||||
|
<ConnectIntegration
|
||||||
|
type={item.type}
|
||||||
|
render={(btnProps) => (
|
||||||
|
<Button color="secondary" {...btnProps}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
onOpenChange={onChanged}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarsList;
|
56
components/integrations/ConnectIntegrations.tsx
Normal file
56
components/integrations/ConnectIntegrations.tsx
Normal file
|
@ -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<void>;
|
||||||
|
}) {
|
||||||
|
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" && (
|
||||||
|
<AddCalDavIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "apple_calendar" && (
|
||||||
|
<AddAppleIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
98
components/integrations/ConnectedCalendarsList.tsx
Normal file
98
components/integrations/ConnectedCalendarsList.tsx
Normal file
|
@ -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<void>;
|
||||||
|
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 (
|
||||||
|
<List>
|
||||||
|
{connectedCalendars.map((item) => (
|
||||||
|
<Fragment key={item.credentialId}>
|
||||||
|
{item.calendars ? (
|
||||||
|
<IntegrationListItem
|
||||||
|
{...item.integration}
|
||||||
|
description={item.primary?.externalId || "No external Id"}
|
||||||
|
actions={
|
||||||
|
<DisconnectIntegration
|
||||||
|
id={item.credentialId}
|
||||||
|
render={(btnProps) => (
|
||||||
|
<Button {...btnProps} color="warn">
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
onOpenChange={onChanged}
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
<ul className="p-4 space-y-2">
|
||||||
|
{item.calendars.map((cal) => (
|
||||||
|
<CalendarSwitch
|
||||||
|
key={cal.externalId}
|
||||||
|
externalId={cal.externalId}
|
||||||
|
title={cal.name}
|
||||||
|
type={item.integration.type}
|
||||||
|
defaultSelected={cal.isSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</IntegrationListItem>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
title="Something went wrong"
|
||||||
|
message={item.error?.message}
|
||||||
|
actions={
|
||||||
|
<DisconnectIntegration
|
||||||
|
id={item.credentialId}
|
||||||
|
render={(btnProps) => (
|
||||||
|
<Button {...btnProps} color="warn">
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
onOpenChange={onChanged}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConnectedCalendarsList;
|
60
components/integrations/DisconnectIntegration.tsx
Normal file
60
components/integrations/DisconnectIntegration.tsx
Normal file
|
@ -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<void>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<ConfirmationDialogContent
|
||||||
|
variety="danger"
|
||||||
|
title="Disconnect Integration"
|
||||||
|
confirmBtnText="Yes, disconnect integration"
|
||||||
|
cancelBtnText="Cancel"
|
||||||
|
onConfirm={() => {
|
||||||
|
mutation.mutate();
|
||||||
|
}}>
|
||||||
|
Are you sure you want to disconnect this integration?
|
||||||
|
</ConfirmationDialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{props.render({
|
||||||
|
onClick() {
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
disabled: modalOpen,
|
||||||
|
loading: mutation.isLoading,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
30
components/integrations/IntegrationListItem.tsx
Normal file
30
components/integrations/IntegrationListItem.tsx
Normal file
|
@ -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 (
|
||||||
|
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
||||||
|
<div className={classNames("flex flex-1 space-x-2 w-full p-3 items-center")}>
|
||||||
|
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
|
||||||
|
<div className="flex-grow pl-2 truncate">
|
||||||
|
<ListItemTitle component="h3">{props.title}</ListItemTitle>
|
||||||
|
<ListItemText component="p">{props.description}</ListItemText>
|
||||||
|
</div>
|
||||||
|
<div>{props.actions}</div>
|
||||||
|
</div>
|
||||||
|
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IntegrationListItem;
|
29
components/integrations/SubHeadingTitleWithConnections.tsx
Normal file
29
components/integrations/SubHeadingTitleWithConnections.tsx
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<span>{props.title}</span>
|
||||||
|
{num ? (
|
||||||
|
<Badge variant="success">
|
||||||
|
{num}{" "}
|
||||||
|
{pluralize({
|
||||||
|
num,
|
||||||
|
singular: "connection",
|
||||||
|
plural: "connections",
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const UsernameInput = React.forwardRef((props, ref) => (
|
interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UsernameInput = React.forwardRef<HTMLInputElement, UsernameInputProps>((props, ref) => (
|
||||||
// todo, check if username is already taken here?
|
// todo, check if username is already taken here?
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export const WeekdaySelect = (props) => {
|
interface WeekdaySelectProps {
|
||||||
const [activeDays, setActiveDays] = useState(
|
defaultValue: number[];
|
||||||
[...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i))
|
onSelect: (selected: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WeekdaySelect = (props: WeekdaySelectProps) => {
|
||||||
|
const [activeDays, setActiveDays] = useState<boolean[]>(
|
||||||
|
Array.from(Array(7).keys()).map((v, i) => (props.defaultValue || []).includes(i))
|
||||||
);
|
);
|
||||||
|
|
||||||
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
@ -11,10 +16,9 @@ export const WeekdaySelect = (props) => {
|
||||||
props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
|
props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
|
||||||
}, [activeDays]);
|
}, [activeDays]);
|
||||||
|
|
||||||
const toggleDay = (e, idx: number) => {
|
const toggleDay = (idx: number) => {
|
||||||
e.preventDefault();
|
|
||||||
activeDays[idx] = !activeDays[idx];
|
activeDays[idx] = !activeDays[idx];
|
||||||
setActiveDays([].concat(activeDays));
|
setActiveDays(([] as boolean[]).concat(activeDays));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -24,7 +28,10 @@ export const WeekdaySelect = (props) => {
|
||||||
activeDays[idx] ? (
|
activeDays[idx] ? (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={(e) => toggleDay(e, idx)}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDay(idx);
|
||||||
|
}}
|
||||||
className={`
|
className={`
|
||||||
w-10 h-10
|
w-10 h-10
|
||||||
bg-black text-white focus:outline-none px-3 py-1 rounded
|
bg-black text-white focus:outline-none px-3 py-1 rounded
|
||||||
|
@ -38,7 +45,10 @@ export const WeekdaySelect = (props) => {
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={(e) => toggleDay(e, idx)}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDay(idx);
|
||||||
|
}}
|
||||||
style={{ marginTop: "1px", marginBottom: "1px" }}
|
style={{ marginTop: "1px", marginBottom: "1px" }}
|
||||||
className={`w-10 h-10 bg-gray-50 focus:outline-none px-3 py-1 rounded-none ${
|
className={`w-10 h-10 bg-gray-50 focus:outline-none px-3 py-1 rounded-none ${
|
||||||
idx === 0 ? "rounded-l" : "border-l-0"
|
idx === 0 ? "rounded-l" : "border-l-0"
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { PropsWithChildren, useState } from "react";
|
import React, { ReactNode, useState } from "react";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
type RadioAreaProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
type RadioAreaProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> & {
|
||||||
onChange: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
defaultChecked: boolean;
|
defaultChecked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RadioArea = (props: RadioAreaProps) => {
|
const RadioArea = (props: RadioAreaProps) => {
|
||||||
|
@ -16,9 +16,13 @@ const RadioArea = (props: RadioAreaProps) => {
|
||||||
props.className
|
props.className
|
||||||
)}>
|
)}>
|
||||||
<input
|
<input
|
||||||
onChange={(e) => props.onChange(e.target.value)}
|
onChange={(e) => {
|
||||||
|
if (typeof props.onChange === "function") {
|
||||||
|
props.onChange(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
className="float-right text-neutral-900 focus:ring-neutral-500 ml-3"
|
className="float-right ml-3 text-neutral-900 focus:ring-neutral-500"
|
||||||
name={props.name}
|
name={props.name}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
type="radio"
|
type="radio"
|
||||||
|
@ -28,17 +32,17 @@ const RadioArea = (props: RadioAreaProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type RadioAreaGroupProps = {
|
type ChildrenProps = {
|
||||||
name?: string;
|
props: RadioAreaProps;
|
||||||
onChange?: (value) => void;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
interface RadioAreaGroupProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange"> {
|
||||||
|
children: ChildrenProps | ChildrenProps[];
|
||||||
|
name?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const RadioAreaGroup = ({
|
const RadioAreaGroup = ({ children, name, onChange, ...passThroughProps }: RadioAreaGroupProps) => {
|
||||||
children,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
...passThroughProps
|
|
||||||
}: PropsWithChildren<RadioAreaGroupProps>) => {
|
|
||||||
const [checkedIdx, setCheckedIdx] = useState<number | null>(null);
|
const [checkedIdx, setCheckedIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
const changeHandler = (value: string, idx: number) => {
|
const changeHandler = (value: string, idx: number) => {
|
||||||
|
@ -50,23 +54,21 @@ const RadioAreaGroup = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...passThroughProps}>
|
<div {...passThroughProps}>
|
||||||
{(Array.isArray(children) ? children : [children]).map(
|
{(Array.isArray(children) ? children : [children]).map((child, idx: number) => {
|
||||||
(child: React.ReactElement<RadioAreaProps>, idx: number) => {
|
if (checkedIdx === null && child.props.defaultChecked) {
|
||||||
if (checkedIdx === null && child.props.defaultChecked) {
|
setCheckedIdx(idx);
|
||||||
setCheckedIdx(idx);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Item
|
|
||||||
{...child.props}
|
|
||||||
key={idx}
|
|
||||||
name={name}
|
|
||||||
checked={idx === checkedIdx}
|
|
||||||
onChange={(value: string) => changeHandler(value, idx)}>
|
|
||||||
{child.props.children}
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
)}
|
return (
|
||||||
|
<Item
|
||||||
|
{...child.props}
|
||||||
|
key={idx}
|
||||||
|
name={name}
|
||||||
|
checked={idx === checkedIdx}
|
||||||
|
onChange={(value: string) => changeHandler(value, idx)}>
|
||||||
|
{child.props.children}
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { buffer } from "micro";
|
import { buffer } from "micro";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getErrorFromUnknown } from "pages/_error";
|
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
import stripe from "@ee/lib/stripe/server";
|
import stripe from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
import { CalendarEvent } from "@lib/calendarClient";
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
import EventManager from "@lib/events/EventManager";
|
import EventManager from "@lib/events/EventManager";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,10 @@ import { Maybe } from "@trpc/server";
|
||||||
import { i18n } from "../../../next-i18next.config";
|
import { i18n } from "../../../next-i18next.config";
|
||||||
|
|
||||||
export function getLocaleFromHeaders(req: IncomingMessage): string {
|
export function getLocaleFromHeaders(req: IncomingMessage): string {
|
||||||
const preferredLocale = parser.pick(i18n.locales, req.headers["accept-language"]) as Maybe<string>;
|
let preferredLocale: string | null | undefined;
|
||||||
|
if (req.headers["accept-language"]) {
|
||||||
|
preferredLocale = parser.pick(i18n.locales, req.headers["accept-language"]) as Maybe<string>;
|
||||||
|
}
|
||||||
return preferredLocale ?? i18n.defaultLocale;
|
return preferredLocale ?? i18n.defaultLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
11
lib/errors.ts
Normal file
11
lib/errors.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number } {
|
||||||
|
if (cause instanceof Error) {
|
||||||
|
return cause;
|
||||||
|
}
|
||||||
|
if (typeof cause === "string") {
|
||||||
|
// @ts-expect-error https://github.com/tc39/proposal-error-cause
|
||||||
|
return new Error(cause, { cause });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(`Unhandled error of type '${typeof cause}''`);
|
||||||
|
}
|
|
@ -53,7 +53,6 @@
|
||||||
"@trpc/next": "^9.9.1",
|
"@trpc/next": "^9.9.1",
|
||||||
"@trpc/react": "^9.9.1",
|
"@trpc/react": "^9.9.1",
|
||||||
"@trpc/server": "^9.9.1",
|
"@trpc/server": "^9.9.1",
|
||||||
"@types/stripe": "^8.0.417",
|
|
||||||
"@wojtekmaj/react-daterange-picker": "^3.3.1",
|
"@wojtekmaj/react-daterange-picker": "^3.3.1",
|
||||||
"accept-language-parser": "^1.5.0",
|
"accept-language-parser": "^1.5.0",
|
||||||
"async": "^3.2.1",
|
"async": "^3.2.1",
|
||||||
|
@ -99,6 +98,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@trivago/prettier-plugin-sort-imports": "2.0.4",
|
"@trivago/prettier-plugin-sort-imports": "2.0.4",
|
||||||
|
"@types/accept-language-parser": "1.5.2",
|
||||||
"@types/async": "^3.2.7",
|
"@types/async": "^3.2.7",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/jest": "^27.0.1",
|
"@types/jest": "^27.0.1",
|
||||||
|
@ -110,6 +110,7 @@
|
||||||
"@types/react": "^17.0.18",
|
"@types/react": "^17.0.18",
|
||||||
"@types/react-phone-number-input": "^3.0.13",
|
"@types/react-phone-number-input": "^3.0.13",
|
||||||
"@types/react-select": "^4.0.17",
|
"@types/react-select": "^4.0.17",
|
||||||
|
"@types/stripe": "^8.0.417",
|
||||||
"@types/uuid": "8.3.1",
|
"@types/uuid": "8.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||||
"@typescript-eslint/parser": "^4.29.2",
|
"@typescript-eslint/parser": "^4.29.2",
|
||||||
|
|
|
@ -7,6 +7,7 @@ import NextError, { ErrorProps } from "next/error";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
|
||||||
import { ErrorPage } from "@components/error/error-page";
|
import { ErrorPage } from "@components/error/error-page";
|
||||||
|
@ -25,23 +26,6 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[error]"] });
|
const log = logger.getChildLogger({ prefix: ["[error]"] });
|
||||||
|
|
||||||
export function getErrorFromUnknown(cause: unknown): Error & {
|
|
||||||
// status code error
|
|
||||||
statusCode?: number;
|
|
||||||
// prisma error
|
|
||||||
code?: unknown;
|
|
||||||
} {
|
|
||||||
if (cause instanceof Error) {
|
|
||||||
return cause;
|
|
||||||
}
|
|
||||||
if (typeof cause === "string") {
|
|
||||||
// @ts-expect-error https://github.com/tc39/proposal-error-cause
|
|
||||||
return new Error(cause, { cause });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Error(`Unhandled error of type '${typeof cause}''`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomError: NextPage<CustomErrorProps> = (props) => {
|
const CustomError: NextPage<CustomErrorProps> = (props) => {
|
||||||
const { statusCode, err, message, hasGetInitialPropsRun } = props;
|
const { statusCode, err, message, hasGetInitialPropsRun } = props;
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,8 @@ import * as RadioArea from "@components/ui/form/radio-area";
|
||||||
import UserCalendarIllustration from "@components/ui/svg/UserCalendarIllustration";
|
import UserCalendarIllustration from "@components/ui/svg/UserCalendarIllustration";
|
||||||
|
|
||||||
type Profiles = inferQueryOutput<"viewer.eventTypes">["profiles"];
|
type Profiles = inferQueryOutput<"viewer.eventTypes">["profiles"];
|
||||||
|
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
|
||||||
|
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||||
|
|
||||||
interface CreateEventTypeProps {
|
interface CreateEventTypeProps {
|
||||||
canAddEvents: boolean;
|
canAddEvents: boolean;
|
||||||
|
@ -223,7 +225,7 @@ const EventTypeList = ({ readOnly, types, profile }: EventTypeListProps): JSX.El
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EventTypeListHeadingProps {
|
interface EventTypeListHeadingProps {
|
||||||
profile: Profile;
|
profile: EventTypeGroupProfile;
|
||||||
membershipCount: number;
|
membershipCount: number;
|
||||||
}
|
}
|
||||||
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => (
|
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => (
|
||||||
|
|
|
@ -1,12 +1,5 @@
|
||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import {
|
import { Prisma } from "@prisma/client";
|
||||||
EventType,
|
|
||||||
EventTypeCreateInput,
|
|
||||||
Schedule,
|
|
||||||
ScheduleCreateInput,
|
|
||||||
User,
|
|
||||||
UserUpdateInput,
|
|
||||||
} from "@prisma/client";
|
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
@ -22,36 +15,36 @@ import TimezoneSelect from "react-timezone-select";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import AddCalDavIntegration, {
|
|
||||||
ADD_CALDAV_INTEGRATION_FORM_TITLE,
|
|
||||||
} from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
|
||||||
import getIntegrations from "@lib/integrations/getIntegrations";
|
import getIntegrations from "@lib/integrations/getIntegrations";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@components/Dialog";
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
import { ShellSubHeading } from "@components/Shell";
|
||||||
|
import CalendarsList from "@components/integrations/CalendarsList";
|
||||||
|
import ConnectedCalendarsList from "@components/integrations/ConnectedCalendarsList";
|
||||||
|
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||||
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import SchedulerForm, { SCHEDULE_FORM_ID } from "@components/ui/Schedule/Schedule";
|
import SchedulerForm, { SCHEDULE_FORM_ID } from "@components/ui/Schedule/Schedule";
|
||||||
import Text from "@components/ui/Text";
|
import Text from "@components/ui/Text";
|
||||||
import ErrorAlert from "@components/ui/alerts/Error";
|
|
||||||
|
|
||||||
import { AddCalDavIntegrationRequest } from "../lib/integrations/CalDav/components/AddCalDavIntegration";
|
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
|
||||||
|
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
|
||||||
|
|
||||||
import getEventTypes from "../lib/queries/event-types/get-event-types";
|
import getEventTypes from "../lib/queries/event-types/get-event-types";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
type OnboardingProps = {
|
export default function Onboarding(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
user: User;
|
|
||||||
integrations?: Record<string, string>[];
|
|
||||||
eventTypes?: EventType[];
|
|
||||||
schedules?: Schedule[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Onboarding(props: OnboardingProps) {
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
router.replace(router.asPath);
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_EVENT_TYPES = [
|
const DEFAULT_EVENT_TYPES = [
|
||||||
{
|
{
|
||||||
title: t("15min_meeting"),
|
title: t("15min_meeting"),
|
||||||
|
@ -72,12 +65,12 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
];
|
];
|
||||||
|
|
||||||
const [isSubmitting, setSubmitting] = React.useState(false);
|
const [isSubmitting, setSubmitting] = React.useState(false);
|
||||||
const [enteredName, setEnteredName] = React.useState();
|
const [enteredName, setEnteredName] = React.useState("");
|
||||||
const Sess = useSession();
|
const Sess = useSession();
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const updateUser = async (data: UserUpdateInput) => {
|
const updateUser = async (data: Prisma.UserUpdateInput) => {
|
||||||
const res = await fetch(`/api/user/${props.user.id}`, {
|
const res = await fetch(`/api/user/${props.user.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ data: { ...data } }),
|
body: JSON.stringify({ data: { ...data } }),
|
||||||
|
@ -93,7 +86,7 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
return responseData.data;
|
return responseData.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEventType = async (data: EventTypeCreateInput) => {
|
const createEventType = async (data: Prisma.EventTypeCreateInput) => {
|
||||||
const res = await fetch(`/api/availability/eventtype`, {
|
const res = await fetch(`/api/availability/eventtype`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
@ -109,7 +102,7 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
return responseData.data;
|
return responseData.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createSchedule = async (data: ScheduleCreateInput) => {
|
const createSchedule = async (data: Prisma.ScheduleCreateInput) => {
|
||||||
const res = await fetch(`/api/schedule`, {
|
const res = await fetch(`/api/schedule`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ data: { ...data } }),
|
body: JSON.stringify({ data: { ...data } }),
|
||||||
|
@ -125,53 +118,9 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
return responseData.data;
|
return responseData.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddIntegration = (type: string) => {
|
|
||||||
if (type === "caldav_calendar") {
|
|
||||||
setAddCalDavError(null);
|
|
||||||
setIsAddCalDavIntegrationDialogOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch("/api/integrations/" + type.replace("_", "") + "/add")
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
window.location.href = data.url;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Internal Components */
|
|
||||||
const IntegrationGridListItem = ({ integration }: { integration: Integration }) => {
|
|
||||||
if (!integration || !integration.installed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
onClick={() => handleAddIntegration(integration.type)}
|
|
||||||
key={integration.type}
|
|
||||||
className="flex px-4 py-3 items-center">
|
|
||||||
<div className="w-1/12 mr-4">
|
|
||||||
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
|
|
||||||
</div>
|
|
||||||
<div className="w-10/12">
|
|
||||||
<Text className="text-gray-900 text-sm font-medium">{integration.title}</Text>
|
|
||||||
<Text className="text-gray-400" variant="subtitle">
|
|
||||||
{integration.description}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="w-2/12 text-right">
|
|
||||||
<Button className="btn-sm" color="secondary" onClick={() => handleAddIntegration(integration.type)}>
|
|
||||||
{t("connect")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/** End Internal Components */
|
|
||||||
|
|
||||||
/** Name */
|
/** Name */
|
||||||
const nameRef = useRef(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const bioRef = useRef(null);
|
const bioRef = useRef<HTMLInputElement>(null);
|
||||||
/** End Name */
|
/** End Name */
|
||||||
/** TimeZone */
|
/** TimeZone */
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState({
|
const [selectedTimeZone, setSelectedTimeZone] = useState({
|
||||||
|
@ -183,88 +132,6 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
}, [selectedTimeZone]);
|
}, [selectedTimeZone]);
|
||||||
/** End TimeZone */
|
/** End TimeZone */
|
||||||
|
|
||||||
/** CalDav Form */
|
|
||||||
const addCalDavIntegrationRef = useRef<HTMLFormElement>(null);
|
|
||||||
const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false);
|
|
||||||
const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null);
|
|
||||||
|
|
||||||
const handleAddCalDavIntegration = async ({ url, username, password }: AddCalDavIntegrationRequest) => {
|
|
||||||
const requestBody = JSON.stringify({
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await fetch("/api/integrations/caldav/add", {
|
|
||||||
method: "POST",
|
|
||||||
body: requestBody,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCalDavIntegrationSaveButtonPress = async () => {
|
|
||||||
const form = addCalDavIntegrationRef.current.elements;
|
|
||||||
const url = form.url.value;
|
|
||||||
const password = form.password.value;
|
|
||||||
const username = form.username.value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAddCalDavError(null);
|
|
||||||
const addCalDavIntegrationResponse = await handleAddCalDavIntegration({ username, password, url });
|
|
||||||
if (addCalDavIntegrationResponse.ok) {
|
|
||||||
setIsAddCalDavIntegrationDialogOpen(false);
|
|
||||||
incrementStep();
|
|
||||||
} else {
|
|
||||||
const j = await addCalDavIntegrationResponse.json();
|
|
||||||
setAddCalDavError({ message: j.message });
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
|
||||||
console.error(reason);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ConnectCalDavServerDialog = () => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={isAddCalDavIntegrationDialogOpen}
|
|
||||||
onOpenChange={(isOpen) => setIsAddCalDavIntegrationDialogOpen(isOpen)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader title={t("connect_caldav")} subtitle={t("credentials_stored_and_encrypted")} />
|
|
||||||
<div className="my-4">
|
|
||||||
{addCalDavError && (
|
|
||||||
<p className="text-red-700 text-sm">
|
|
||||||
<span className="font-bold">{t("error")}: </span>
|
|
||||||
{addCalDavError.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<AddCalDavIntegration
|
|
||||||
ref={addCalDavIntegrationRef}
|
|
||||||
onSubmit={handleAddCalDavIntegrationSaveButtonPress}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
form={ADD_CALDAV_INTEGRATION_FORM_TITLE}
|
|
||||||
className="flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
|
||||||
{t("save")}
|
|
||||||
</button>
|
|
||||||
<DialogClose
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddCalDavIntegrationDialogOpen(false);
|
|
||||||
}}
|
|
||||||
asChild>
|
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/**End CalDav Form */
|
|
||||||
|
|
||||||
/** Onboarding Steps */
|
/** Onboarding Steps */
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const detectStep = () => {
|
const detectStep = () => {
|
||||||
|
@ -274,7 +141,7 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
step = 1;
|
step = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasConfigureCalendar = props.integrations.some((integration) => integration.credential != null);
|
const hasConfigureCalendar = props.integrations.some((integration) => integration.credential !== null);
|
||||||
if (hasConfigureCalendar) {
|
if (hasConfigureCalendar) {
|
||||||
step = 2;
|
step = 2;
|
||||||
}
|
}
|
||||||
|
@ -292,17 +159,17 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
if (
|
if (
|
||||||
steps[currentStep] &&
|
steps[currentStep] &&
|
||||||
steps[currentStep]?.onComplete &&
|
steps[currentStep].onComplete &&
|
||||||
typeof steps[currentStep]?.onComplete === "function"
|
typeof steps[currentStep].onComplete === "function"
|
||||||
) {
|
) {
|
||||||
await steps[currentStep].onComplete();
|
await steps[currentStep].onComplete!();
|
||||||
}
|
}
|
||||||
incrementStep();
|
incrementStep();
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("handleConfirmStep", error);
|
console.log("handleConfirmStep", error);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setError(error);
|
setError(error as Error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -385,7 +252,7 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
placeholder={t("your_name")}
|
placeholder={t("your_name")}
|
||||||
defaultValue={props.user.name ?? enteredName}
|
defaultValue={props.user.name ?? enteredName}
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
@ -403,7 +270,7 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
id="timeZone"
|
id="timeZone"
|
||||||
value={selectedTimeZone}
|
value={selectedTimeZone}
|
||||||
onChange={setSelectedTimeZone}
|
onChange={setSelectedTimeZone}
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
@ -417,13 +284,13 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await updateUser({
|
await updateUser({
|
||||||
name: nameRef.current.value,
|
name: nameRef.current?.value,
|
||||||
timeZone: selectedTimeZone.value,
|
timeZone: selectedTimeZone.value,
|
||||||
});
|
});
|
||||||
setEnteredName(nameRef.current.value);
|
setEnteredName(nameRef.current?.value || "");
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error);
|
setError(error as Error);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -433,11 +300,28 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
title: t("connect_your_calendar"),
|
title: t("connect_your_calendar"),
|
||||||
description: t("connect_your_calendar_instructions"),
|
description: t("connect_your_calendar_instructions"),
|
||||||
Component: (
|
Component: (
|
||||||
<ul className="divide-y divide-gray-200 sm:mx-auto sm:w-full border border-gray-200 rounded-sm">
|
<>
|
||||||
{props.integrations.map((integration) => {
|
{props.connectedCalendars.length > 0 && (
|
||||||
return <IntegrationGridListItem key={integration.type} integration={integration} />;
|
<>
|
||||||
})}
|
<ConnectedCalendarsList
|
||||||
</ul>
|
connectedCalendars={props.connectedCalendars}
|
||||||
|
onChanged={() => {
|
||||||
|
refreshData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ShellSubHeading
|
||||||
|
className="mt-6"
|
||||||
|
title={<SubHeadingTitleWithConnections title="Connect an additional calendar" />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CalendarsList
|
||||||
|
calendars={props.integrations}
|
||||||
|
onChanged={() => {
|
||||||
|
refreshData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
hideConfirm: true,
|
hideConfirm: true,
|
||||||
confirmText: t("continue"),
|
confirmText: t("continue"),
|
||||||
|
@ -450,7 +334,7 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
description: t("set_availability_instructions"),
|
description: t("set_availability_instructions"),
|
||||||
Component: (
|
Component: (
|
||||||
<>
|
<>
|
||||||
<section className="bg-white dark:bg-opacity-5 text-black dark:text-white mx-auto max-w-lg">
|
<section className="max-w-lg mx-auto text-black bg-white dark:bg-opacity-5 dark:text-white">
|
||||||
<SchedulerForm
|
<SchedulerForm
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
try {
|
try {
|
||||||
|
@ -461,12 +345,12 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
debouncedHandleConfirmStep();
|
debouncedHandleConfirmStep();
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error);
|
setError(error as Error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<footer className="py-6 sm:mx-auto sm:w-full flex flex-col space-y-6">
|
<footer className="flex flex-col py-6 space-y-6 sm:mx-auto sm:w-full">
|
||||||
<Button className="justify-center" EndIcon={ArrowRightIcon} type="submit" form={SCHEDULE_FORM_ID}>
|
<Button className="justify-center" EndIcon={ArrowRightIcon} type="submit" form={SCHEDULE_FORM_ID}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -496,7 +380,7 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
placeholder={t("your_name")}
|
placeholder={t("your_name")}
|
||||||
defaultValue={props.user.name || enteredName}
|
defaultValue={props.user.name || enteredName}
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
@ -509,8 +393,8 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
name="bio"
|
name="bio"
|
||||||
id="bio"
|
id="bio"
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||||
defaultValue={props.user.bio}
|
defaultValue={props.user.bio || undefined}
|
||||||
/>
|
/>
|
||||||
<Text variant="caption" className="mt-2">
|
<Text variant="caption" className="mt-2">
|
||||||
{t("few_sentences_about_yourself")}
|
{t("few_sentences_about_yourself")}
|
||||||
|
@ -528,11 +412,11 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
console.log("updating");
|
console.log("updating");
|
||||||
await updateUser({
|
await updateUser({
|
||||||
description: bioRef.current.value,
|
bio: bioRef.current?.value,
|
||||||
});
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error);
|
setError(error as Error);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -550,20 +434,20 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black min-h-screen">
|
<div className="min-h-screen bg-black">
|
||||||
<Head>
|
<Head>
|
||||||
<title>Cal.com - {t("getting_started")}</title>
|
<title>Cal.com - {t("getting_started")}</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
{isSubmitting && (
|
{isSubmitting && (
|
||||||
<div className="fixed w-full h-full bg-white bg-opacity-25 flex flex-col justify-center items-center content-center z-10">
|
<div className="fixed z-10 flex flex-col items-center content-center justify-center w-full h-full bg-white bg-opacity-25">
|
||||||
<Loader />
|
<Loader />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mx-auto py-24 px-4">
|
<div className="px-4 py-24 mx-auto">
|
||||||
<article className="relative">
|
<article className="relative">
|
||||||
<section className="sm:mx-auto sm:w-full sm:max-w-lg space-y-4">
|
<section className="space-y-4 sm:mx-auto sm:w-full sm:max-w-lg">
|
||||||
<header>
|
<header>
|
||||||
<Text className="text-white" variant="largetitle">
|
<Text className="text-white" variant="largetitle">
|
||||||
{steps[currentStep].title}
|
{steps[currentStep].title}
|
||||||
|
@ -572,14 +456,14 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
{steps[currentStep].description}
|
{steps[currentStep].description}
|
||||||
</Text>
|
</Text>
|
||||||
</header>
|
</header>
|
||||||
<section className="space-y-2 pt-4">
|
<section className="pt-4 space-y-2">
|
||||||
<Text variant="footnote">
|
<Text variant="footnote">
|
||||||
Step {currentStep + 1} of {steps.length}
|
Step {currentStep + 1} of {steps.length}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{error && <ErrorAlert {...error} />}
|
{error && <Alert severity="error" {...error} />}
|
||||||
|
|
||||||
<section className="w-full space-x-2 flex">
|
<section className="flex w-full space-x-2">
|
||||||
{steps.map((s, index) => {
|
{steps.map((s, index) => {
|
||||||
return index <= currentStep ? (
|
return index <= currentStep ? (
|
||||||
<div
|
<div
|
||||||
|
@ -590,17 +474,17 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
index < currentStep ? "cursor-pointer" : ""
|
index < currentStep ? "cursor-pointer" : ""
|
||||||
)}></div>
|
)}></div>
|
||||||
) : (
|
) : (
|
||||||
<div key={`step-${index}`} className="h-1 bg-white bg-opacity-25 w-1/4"></div>
|
<div key={`step-${index}`} className="w-1/4 h-1 bg-white bg-opacity-25"></div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section className="mt-10 mx-auto max-w-xl bg-white p-10 rounded-sm">
|
<section className="max-w-xl p-10 mx-auto mt-10 bg-white rounded-sm">
|
||||||
{steps[currentStep].Component}
|
{steps[currentStep].Component}
|
||||||
|
|
||||||
{!steps[currentStep].hideConfirm && (
|
{!steps[currentStep].hideConfirm && (
|
||||||
<footer className="sm:mx-auto sm:w-full flex flex-col space-y-6 mt-8">
|
<footer className="flex flex-col mt-8 space-y-6 sm:mx-auto sm:w-full">
|
||||||
<Button
|
<Button
|
||||||
className="justify-center"
|
className="justify-center"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
@ -611,8 +495,8 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="py-8 mx-auto max-w-xl">
|
<section className="max-w-xl py-8 mx-auto">
|
||||||
<div className="flex justify-between flex-row-reverse">
|
<div className="flex flex-row-reverse justify-between">
|
||||||
<button disabled={isSubmitting} onClick={handleSkipStep}>
|
<button disabled={isSubmitting} onClick={handleSkipStep}>
|
||||||
<Text variant="caption">Skip Step</Text>
|
<Text variant="caption">Skip Step</Text>
|
||||||
</button>
|
</button>
|
||||||
|
@ -625,7 +509,6 @@ export default function Onboarding(props: OnboardingProps) {
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<ConnectCalDavServerDialog />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -634,6 +517,7 @@ export async function getServerSideProps(context: NextPageContext) {
|
||||||
const session = await getSession(context);
|
const session = await getSession(context);
|
||||||
|
|
||||||
let integrations = [];
|
let integrations = [];
|
||||||
|
let connectedCalendars = [];
|
||||||
let credentials = [];
|
let credentials = [];
|
||||||
let eventTypes = [];
|
let eventTypes = [];
|
||||||
let schedules = [];
|
let schedules = [];
|
||||||
|
@ -660,6 +544,12 @@ export async function getServerSideProps(context: NextPageContext) {
|
||||||
avatar: true,
|
avatar: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
completedOnboarding: true,
|
completedOnboarding: true,
|
||||||
|
selectedCalendars: {
|
||||||
|
select: {
|
||||||
|
externalId: true,
|
||||||
|
integration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -686,7 +576,14 @@ export async function getServerSideProps(context: NextPageContext) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
integrations = getIntegrations(credentials).map((item) => omit(item, "key"));
|
integrations = getIntegrations(credentials)
|
||||||
|
.filter((item) => item.type.endsWith("_calendar"))
|
||||||
|
.map((item) => omit(item, "key"));
|
||||||
|
|
||||||
|
// get user's credentials + their connected integrations
|
||||||
|
const calendarCredentials = getCalendarCredentials(credentials, user.id);
|
||||||
|
// get all the connected integrations' calendars (from third party)
|
||||||
|
connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
||||||
|
|
||||||
eventTypes = await prisma.eventType.findMany({
|
eventTypes = await prisma.eventType.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
@ -716,6 +613,7 @@ export async function getServerSideProps(context: NextPageContext) {
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
integrations,
|
integrations,
|
||||||
|
connectedCalendars,
|
||||||
eventTypes,
|
eventTypes,
|
||||||
schedules,
|
schedules,
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,17 +2,15 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||||
import { ClipboardIcon } from "@heroicons/react/solid";
|
import { ClipboardIcon } from "@heroicons/react/solid";
|
||||||
import { WebhookTriggerEvents } from "@prisma/client";
|
import { WebhookTriggerEvents } from "@prisma/client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getErrorFromUnknown } from "pages/_error";
|
import { useState } from "react";
|
||||||
import { Fragment, ReactNode, useState } from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import * as fetcher from "@lib/core/http/fetch-wrapper";
|
import * as fetcher from "@lib/core/http/fetch-wrapper";
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
|
|
||||||
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
|
||||||
import showToast from "@lib/notification";
|
import showToast from "@lib/notification";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
@ -22,18 +20,16 @@ import Shell, { ShellSubHeading } from "@components/Shell";
|
||||||
import { Tooltip } from "@components/Tooltip";
|
import { Tooltip } from "@components/Tooltip";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import { FieldsetLegend, Form, InputGroupBox, TextField } from "@components/form/fields";
|
import { FieldsetLegend, Form, InputGroupBox, TextField } from "@components/form/fields";
|
||||||
|
import CalendarsList from "@components/integrations/CalendarsList";
|
||||||
|
import ConnectIntegration from "@components/integrations/ConnectIntegrations";
|
||||||
|
import ConnectedCalendarsList from "@components/integrations/ConnectedCalendarsList";
|
||||||
|
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||||
|
import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
||||||
|
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Badge from "@components/ui/Badge";
|
import Button from "@components/ui/Button";
|
||||||
import Button, { ButtonBaseProps } from "@components/ui/Button";
|
|
||||||
import Switch from "@components/ui/Switch";
|
import Switch from "@components/ui/Switch";
|
||||||
|
|
||||||
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
|
||||||
if (opts.num === 0) {
|
|
||||||
return opts.singular;
|
|
||||||
}
|
|
||||||
return opts.singular;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TIntegrations = inferQueryOutput<"viewer.integrations">;
|
type TIntegrations = inferQueryOutput<"viewer.integrations">;
|
||||||
type TWebhook = TIntegrations["webhooks"][number];
|
type TWebhook = TIntegrations["webhooks"][number];
|
||||||
|
|
||||||
|
@ -123,8 +119,8 @@ function WebhookDialogForm(props: {
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
defaultValues = {
|
defaultValues = {
|
||||||
id: "",
|
id: "",
|
||||||
|
@ -378,129 +374,6 @@ function WebhookEmbed(props: { webhooks: TWebhook[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
|
|
||||||
const num = props.numConnections;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span>{props.title}</span>
|
|
||||||
{num ? (
|
|
||||||
<Badge variant="success">
|
|
||||||
{num}{" "}
|
|
||||||
{pluralize({
|
|
||||||
num,
|
|
||||||
singular: "connection",
|
|
||||||
plural: "connections",
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectIntegration(props: { type: string; render: (renderProps: ButtonBaseProps) => JSX.Element }) {
|
|
||||||
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 utils = trpc.useContext();
|
|
||||||
|
|
||||||
const setIsModalOpen: typeof _setIsModalOpen = (v) => {
|
|
||||||
_setIsModalOpen(v);
|
|
||||||
// refetch intergrations on modal toggles
|
|
||||||
|
|
||||||
utils.invalidateQueries(["viewer.integrations"]);
|
|
||||||
};
|
|
||||||
|
|
||||||
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" && (
|
|
||||||
<AddCalDavIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type === "apple_calendar" && (
|
|
||||||
<AddAppleIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DisconnectIntegration(props: {
|
|
||||||
/**
|
|
||||||
* Integration credential id
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
|
||||||
}) {
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
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() {
|
|
||||||
await utils.invalidateQueries(["viewer.integrations"]);
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
setModalOpen(false);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
||||||
<ConfirmationDialogContent
|
|
||||||
variety="danger"
|
|
||||||
title="Disconnect Integration"
|
|
||||||
confirmBtnText="Yes, disconnect integration"
|
|
||||||
cancelBtnText="Cancel"
|
|
||||||
onConfirm={() => {
|
|
||||||
mutation.mutate();
|
|
||||||
}}>
|
|
||||||
Are you sure you want to disconnect this integration?
|
|
||||||
</ConfirmationDialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{props.render({
|
|
||||||
onClick() {
|
|
||||||
setModalOpen(true);
|
|
||||||
},
|
|
||||||
disabled: modalOpen,
|
|
||||||
loading: mutation.isLoading,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectOrDisconnectIntegrationButton(props: {
|
function ConnectOrDisconnectIntegrationButton(props: {
|
||||||
//
|
//
|
||||||
credentialIds: number[];
|
credentialIds: number[];
|
||||||
|
@ -508,6 +381,11 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [credentialId] = props.credentialIds;
|
const [credentialId] = props.credentialIds;
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
const handleOpenChange = () => {
|
||||||
|
utils.invalidateQueries(["viewer.integrations"]);
|
||||||
|
};
|
||||||
|
|
||||||
if (credentialId) {
|
if (credentialId) {
|
||||||
return (
|
return (
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
|
@ -517,6 +395,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -543,103 +422,17 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IntegrationListItem(props: {
|
|
||||||
imageSrc: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
actions?: ReactNode;
|
|
||||||
children?: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
|
||||||
<div className={classNames("flex flex-1 space-x-2 w-full p-3 items-center")}>
|
|
||||||
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
|
|
||||||
<div className="flex-grow pl-2 truncate">
|
|
||||||
<ListItemTitle component="h3">{props.title}</ListItemTitle>
|
|
||||||
<ListItemText component="p">{props.description}</ListItemText>
|
|
||||||
</div>
|
|
||||||
<div>{props.actions}</div>
|
|
||||||
</div>
|
|
||||||
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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 (
|
|
||||||
<div className="py-1">
|
|
||||||
<Switch
|
|
||||||
key={props.externalId}
|
|
||||||
name="enabled"
|
|
||||||
label={props.title}
|
|
||||||
defaultChecked={props.defaultSelected}
|
|
||||||
onCheckedChange={(isOn: boolean) => {
|
|
||||||
mutation.mutate({ isOn });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IntegrationsPage() {
|
export default function IntegrationsPage() {
|
||||||
const query = trpc.useQuery(["viewer.integrations"]);
|
const query = trpc.useQuery(["viewer.integrations"]);
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
const handleOpenChange = () => {
|
||||||
|
utils.invalidateQueries(["viewer.integrations"]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell heading="Integrations" subtitle="Connect your favourite apps.">
|
<Shell heading="Integrations" subtitle="Connect your favourite apps.">
|
||||||
|
@ -701,79 +494,17 @@ export default function IntegrationsPage() {
|
||||||
|
|
||||||
{data.connectedCalendars.length > 0 && (
|
{data.connectedCalendars.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<List>
|
<ConnectedCalendarsList
|
||||||
{data.connectedCalendars.map((item) => (
|
connectedCalendars={data.connectedCalendars}
|
||||||
<Fragment key={item.credentialId}>
|
onChanged={handleOpenChange}
|
||||||
{item.calendars ? (
|
/>
|
||||||
<IntegrationListItem
|
|
||||||
{...item.integration}
|
|
||||||
description={item.primary.externalId}
|
|
||||||
actions={
|
|
||||||
<DisconnectIntegration
|
|
||||||
id={item.credentialId}
|
|
||||||
render={(btnProps) => (
|
|
||||||
<Button {...btnProps} color="warn">
|
|
||||||
Disconnect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<ul className="p-4 space-y-2">
|
|
||||||
{item.calendars.map((cal) => (
|
|
||||||
<CalendarSwitch
|
|
||||||
key={cal.externalId}
|
|
||||||
externalId={cal.externalId}
|
|
||||||
title={cal.name}
|
|
||||||
type={item.integration.type}
|
|
||||||
defaultSelected={cal.isSelected}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</IntegrationListItem>
|
|
||||||
) : (
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
title="Something went wrong"
|
|
||||||
message={item.error.message}
|
|
||||||
actions={
|
|
||||||
<DisconnectIntegration
|
|
||||||
id={item.credentialId}
|
|
||||||
render={(btnProps) => (
|
|
||||||
<Button {...btnProps} color="warn">
|
|
||||||
Disconnect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
<ShellSubHeading
|
<ShellSubHeading
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
title={<SubHeadingTitleWithConnections title="Connect an additional calendar" />}
|
title={<SubHeadingTitleWithConnections title="Connect an additional calendar" />}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<List>
|
<CalendarsList calendars={data.calendar.items} onChanged={handleOpenChange} />
|
||||||
{data.calendar.items.map((item) => (
|
|
||||||
<IntegrationListItem
|
|
||||||
key={item.title}
|
|
||||||
{...item}
|
|
||||||
actions={
|
|
||||||
<ConnectIntegration
|
|
||||||
type={item.type}
|
|
||||||
render={(btnProps) => (
|
|
||||||
<Button color="secondary" {...btnProps}>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
<WebhookEmbed webhooks={data.webhooks} />
|
<WebhookEmbed webhooks={data.webhooks} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -44,7 +44,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const eventType = booking.eventType;
|
const eventType = booking.eventType;
|
||||||
|
|
||||||
const eventPage =
|
const eventPage =
|
||||||
(eventType.team ? "team/" + eventType.team.slug : booking.user.username) + "/" + booking.eventType.slug;
|
(eventType.team
|
||||||
|
? "team/" + eventType.team.slug
|
||||||
|
: booking.user?.username || "rick") /* This shouldn't happen */ +
|
||||||
|
"/" +
|
||||||
|
booking.eventType.slug;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { i18n } from "next-i18next.config";
|
import { i18n } from "next-i18next.config";
|
||||||
import { ComponentProps, RefObject, useEffect, useRef, useState } from "react";
|
import { ComponentProps, FormEvent, RefObject, useEffect, useRef, useState } from "react";
|
||||||
import Select, { OptionTypeBase } from "react-select";
|
import Select, { OptionTypeBase } from "react-select";
|
||||||
import TimezoneSelect from "react-timezone-select";
|
import TimezoneSelect from "react-timezone-select";
|
||||||
|
|
||||||
|
@ -32,6 +32,9 @@ type Props = inferSSRProps<typeof getServerSideProps>;
|
||||||
const getLocaleOptions = (displayLocale: string | string[]): OptionTypeBase[] => {
|
const getLocaleOptions = (displayLocale: string | string[]): OptionTypeBase[] => {
|
||||||
return i18n.locales.map((locale) => ({
|
return i18n.locales.map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
|
// FIXME
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
label: new Intl.DisplayNames(displayLocale, { type: "language" }).of(locale),
|
label: new Intl.DisplayNames(displayLocale, { type: "language" }).of(locale),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
@ -151,7 +154,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function updateProfileHandler(event) {
|
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const enteredUsername = usernameRef.current.value.toLowerCase();
|
const enteredUsername = usernameRef.current.value.toLowerCase();
|
||||||
|
@ -186,7 +189,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
<div className="flex-grow space-y-6">
|
<div className="flex-grow space-y-6">
|
||||||
<div className="block sm:flex">
|
<div className="block sm:flex">
|
||||||
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
|
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
|
||||||
<UsernameInput ref={usernameRef} defaultValue={props.user.username} />
|
<UsernameInput ref={usernameRef} defaultValue={props.user.username || undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full sm:w-1/2 sm:ml-2">
|
<div className="w-full sm:w-1/2 sm:ml-2">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
|
@ -201,7 +204,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
placeholder={t("your_name")}
|
placeholder={t("your_name")}
|
||||||
required
|
required
|
||||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||||
defaultValue={props.user.name}
|
defaultValue={props.user.name || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -247,7 +250,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
<div>
|
<div>
|
||||||
<div className="flex mt-1">
|
<div className="flex mt-1">
|
||||||
<Avatar
|
<Avatar
|
||||||
displayName={props.user.name}
|
alt={props.user.name || ""}
|
||||||
className="relative w-10 h-10 rounded-full"
|
className="relative w-10 h-10 rounded-full"
|
||||||
gravatarFallbackMd5={props.user.emailMd5}
|
gravatarFallbackMd5={props.user.emailMd5}
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
|
@ -270,11 +273,11 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
window.HTMLInputElement.prototype,
|
window.HTMLInputElement.prototype,
|
||||||
"value"
|
"value"
|
||||||
).set;
|
)?.set;
|
||||||
nativeInputValueSetter.call(avatarRef.current, newAvatar);
|
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||||
const ev2 = new Event("input", { bubbles: true });
|
const ev2 = new Event("input", { bubbles: true });
|
||||||
avatarRef.current.dispatchEvent(ev2);
|
avatarRef.current.dispatchEvent(ev2);
|
||||||
updateProfileHandler(ev2);
|
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
|
||||||
setImageSrc(newAvatar);
|
setImageSrc(newAvatar);
|
||||||
}}
|
}}
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
|
@ -350,7 +353,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
id="theme-adjust-os"
|
id="theme-adjust-os"
|
||||||
name="theme-adjust-os"
|
name="theme-adjust-os"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={(e) => setSelectedTheme(e.target.checked ? null : themeOptions[0])}
|
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
|
||||||
checked={!selectedTheme}
|
checked={!selectedTheme}
|
||||||
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
|
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -15,7 +15,7 @@ export default function Security() {
|
||||||
<Shell heading={t("security")} subtitle={t("manage_account_security")}>
|
<Shell heading={t("security")} subtitle={t("manage_account_security")}>
|
||||||
<SettingsShell>
|
<SettingsShell>
|
||||||
<ChangePasswordSection />
|
<ChangePasswordSection />
|
||||||
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled} />
|
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} />
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
|
|
25
server/integrations/getCalendarCredentials.ts
Normal file
25
server/integrations/getCalendarCredentials.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { Credential } from "@prisma/client";
|
||||||
|
|
||||||
|
import { getCalendarAdapterOrNull } from "@lib/calendarClient";
|
||||||
|
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
||||||
|
|
||||||
|
export default function getCalendarCredentials(
|
||||||
|
credentials: Array<Omit<Credential, "userId">>,
|
||||||
|
userId: number
|
||||||
|
) {
|
||||||
|
const calendarCredentials = credentials
|
||||||
|
.filter((credential) => credential.type.endsWith("_calendar"))
|
||||||
|
.flatMap((credential) => {
|
||||||
|
const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type);
|
||||||
|
|
||||||
|
const adapter = getCalendarAdapterOrNull({
|
||||||
|
...credential,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return integration && adapter && integration.variant === "calendar"
|
||||||
|
? [{ integration, credential, adapter }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return calendarCredentials;
|
||||||
|
}
|
50
server/integrations/getConnectedCalendars.ts
Normal file
50
server/integrations/getConnectedCalendars.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
|
|
||||||
|
import getCalendarCredentials from "./getCalendarCredentials";
|
||||||
|
|
||||||
|
export default async function getConnectedCalendars(
|
||||||
|
calendarCredentials: ReturnType<typeof getCalendarCredentials>,
|
||||||
|
selectedCalendars: { externalId: string }[]
|
||||||
|
) {
|
||||||
|
const connectedCalendars = await Promise.all(
|
||||||
|
calendarCredentials.map(async (item) => {
|
||||||
|
const { adapter, integration, credential } = item;
|
||||||
|
|
||||||
|
const credentialId = credential.id;
|
||||||
|
try {
|
||||||
|
const cals = await adapter.listCalendars();
|
||||||
|
const calendars = _(cals)
|
||||||
|
.map((cal) => ({
|
||||||
|
...cal,
|
||||||
|
primary: cal.primary || null,
|
||||||
|
isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
|
||||||
|
}))
|
||||||
|
.sortBy(["primary"])
|
||||||
|
.value();
|
||||||
|
const primary = calendars.find((item) => item.primary) ?? calendars[0];
|
||||||
|
if (!primary) {
|
||||||
|
throw new Error("No primary calendar found");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
integration,
|
||||||
|
credentialId,
|
||||||
|
primary,
|
||||||
|
calendars,
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
const error = getErrorFromUnknown(_error);
|
||||||
|
return {
|
||||||
|
integration,
|
||||||
|
credentialId,
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return connectedCalendars;
|
||||||
|
}
|
|
@ -1,6 +1,4 @@
|
||||||
import { BookingStatus, Prisma } from "@prisma/client";
|
import { BookingStatus, Prisma } from "@prisma/client";
|
||||||
import _ from "lodash";
|
|
||||||
import { getErrorFromUnknown } from "pages/_error";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
|
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
|
||||||
|
@ -9,9 +7,10 @@ import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||||
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
||||||
import slugify from "@lib/slugify";
|
import slugify from "@lib/slugify";
|
||||||
|
|
||||||
|
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
|
||||||
|
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { getCalendarAdapterOrNull } from "../../lib/calendarClient";
|
|
||||||
import { createProtectedRouter, createRouter } from "../createRouter";
|
import { createProtectedRouter, createRouter } from "../createRouter";
|
||||||
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
||||||
|
|
||||||
|
@ -314,57 +313,10 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
const calendar = integrations.flatMap((item) => (item.variant === "calendar" ? [item] : []));
|
const calendar = integrations.flatMap((item) => (item.variant === "calendar" ? [item] : []));
|
||||||
|
|
||||||
// get user's credentials + their connected integrations
|
// get user's credentials + their connected integrations
|
||||||
const calendarCredentials = user.credentials
|
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
|
||||||
.filter((credential) => credential.type.endsWith("_calendar"))
|
|
||||||
.flatMap((credential) => {
|
|
||||||
const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type);
|
|
||||||
|
|
||||||
const adapter = getCalendarAdapterOrNull({
|
|
||||||
...credential,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
return integration && adapter && integration.variant === "calendar"
|
|
||||||
? [{ integration, credential, adapter }]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// get all the connected integrations' calendars (from third party)
|
// get all the connected integrations' calendars (from third party)
|
||||||
const connectedCalendars = await Promise.all(
|
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
||||||
calendarCredentials.map(async (item) => {
|
|
||||||
const { adapter, integration, credential } = item;
|
|
||||||
|
|
||||||
const credentialId = credential.id;
|
|
||||||
try {
|
|
||||||
const cals = await adapter.listCalendars();
|
|
||||||
const calendars = _(cals)
|
|
||||||
.map((cal) => ({
|
|
||||||
...cal,
|
|
||||||
isSelected: user.selectedCalendars.some((selected) => selected.externalId === cal.externalId),
|
|
||||||
}))
|
|
||||||
.sortBy(["primary"])
|
|
||||||
.value();
|
|
||||||
const primary = calendars.find((item) => item.primary) ?? calendars[0];
|
|
||||||
if (!primary) {
|
|
||||||
throw new Error("No primary calendar found");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
integration,
|
|
||||||
credentialId,
|
|
||||||
primary,
|
|
||||||
calendars,
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
|
||||||
const error = getErrorFromUnknown(_error);
|
|
||||||
return {
|
|
||||||
integration,
|
|
||||||
credentialId,
|
|
||||||
error: {
|
|
||||||
message: error.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const webhooks = await ctx.prisma.webhook.findMany({
|
const webhooks = await ctx.prisma.webhook.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
@ -10,5 +10,12 @@ export const ssg = createSSGHelpers({
|
||||||
prisma,
|
prisma,
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
i18n: {
|
||||||
|
_nextI18Next: {
|
||||||
|
initialI18nStore: null,
|
||||||
|
userConfig: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locale: "en",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ it("can fit 24 hourly slots for an empty day", async () => {
|
||||||
getSlots({
|
getSlots({
|
||||||
inviteeDate: dayjs().add(1, "day"),
|
inviteeDate: dayjs().add(1, "day"),
|
||||||
frequency: 60,
|
frequency: 60,
|
||||||
workingHours: [{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }],
|
workingHours: [{ days: Array.from(Array(7).keys()), startTime: 0, endTime: 1440 }],
|
||||||
organizerTimeZone: "Europe/London",
|
organizerTimeZone: "Europe/London",
|
||||||
})
|
})
|
||||||
).toHaveLength(24);
|
).toHaveLength(24);
|
||||||
|
@ -29,7 +29,7 @@ it.skip("only shows future booking slots on the same day", async () => {
|
||||||
getSlots({
|
getSlots({
|
||||||
inviteeDate: dayjs(),
|
inviteeDate: dayjs(),
|
||||||
frequency: 60,
|
frequency: 60,
|
||||||
workingHours: [{ days: [...Array(7).keys()], startTime: 0, endTime: 1440 }],
|
workingHours: [{ days: Array.from(Array(7).keys()), startTime: 0, endTime: 1440 }],
|
||||||
organizerTimeZone: "GMT",
|
organizerTimeZone: "GMT",
|
||||||
})
|
})
|
||||||
).toHaveLength(12);
|
).toHaveLength(12);
|
||||||
|
|
|
@ -1763,6 +1763,11 @@
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
|
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
|
||||||
|
|
||||||
|
"@types/accept-language-parser@1.5.2":
|
||||||
|
version "1.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/accept-language-parser/-/accept-language-parser-1.5.2.tgz#ea48ed07a3dc9d2ba6666d45c018ad1b5e59d665"
|
||||||
|
integrity sha512-G8NhvYQ4JVT0GhvgPSVDVskFwWhjFvjbTNou3rRkkDgB8dTBZtxZ1xcU9jqJSth5qTGCzbrKwRf+vKleKdrb7w==
|
||||||
|
|
||||||
"@types/async@^3.2.7":
|
"@types/async@^3.2.7":
|
||||||
version "3.2.8"
|
version "3.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@types/async/-/async-3.2.8.tgz#c4171a8990ed9ae4f0843cacbdceb4fabd7cc7e8"
|
resolved "https://registry.yarnpkg.com/@types/async/-/async-3.2.8.tgz#c4171a8990ed9ae4f0843cacbdceb4fabd7cc7e8"
|
||||||
|
|
Loading…
Reference in a new issue