Skip to content

Commit 3369419

Browse files
pumfleetPeer RichelsenzomarsPeerRich
authored
Calendly & SavvyCal import (#1512)
* Calendly & SavvyCal import * added string keys to import * Update pages/api/import/savvycal.ts Co-authored-by: Omar López <zomars@me.com> * Update pages/api/import/savvycal.ts Co-authored-by: Omar López <zomars@me.com> * Update pages/getting-started.tsx Co-authored-by: Omar López <zomars@me.com> * fixed string * prettier Co-authored-by: Peer Richelsen <peeroke@richelsen.net> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
1 parent b5569c6 commit 3369419

File tree

4 files changed

+274
-37
lines changed

4 files changed

+274
-37
lines changed

pages/api/import/calendly.ts

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { PrismaClient } from "@prisma/client";
2+
import type { NextApiRequest, NextApiResponse } from "next";
3+
4+
import { getSession } from "@lib/auth";
5+
6+
const prisma = new PrismaClient();
7+
8+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
9+
const session = await getSession({ req });
10+
const authenticatedUser = await prisma.user.findFirst({
11+
rejectOnNotFound: true,
12+
where: {
13+
id: session?.user.id,
14+
},
15+
select: {
16+
id: true,
17+
},
18+
});
19+
if (req.method == "POST") {
20+
const userResult = await fetch("https://api.calendly.com/users/me", {
21+
method: "GET",
22+
headers: {
23+
"Content-Type": "application/json",
24+
Authorization: "Bearer " + req.body.token,
25+
},
26+
});
27+
28+
if (userResult.status == 200) {
29+
const userData = await userResult.json();
30+
31+
await prisma.user.update({
32+
where: {
33+
id: authenticatedUser.id,
34+
},
35+
data: {
36+
name: userData.resource.name,
37+
},
38+
});
39+
40+
const eventTypesResult = await fetch(
41+
"https://api.calendly.com/event_types?user=" + userData.resource.uri,
42+
{
43+
method: "GET",
44+
headers: {
45+
"Content-Type": "application/json",
46+
Authorization: "Bearer " + req.body.token,
47+
},
48+
}
49+
);
50+
51+
const eventTypesData = await eventTypesResult.json();
52+
53+
eventTypesData.collection.forEach(async (eventType: any) => {
54+
await prisma.eventType.create({
55+
data: {
56+
title: eventType.name,
57+
slug: eventType.slug,
58+
length: eventType.duration,
59+
description: eventType.description_plain,
60+
hidden: eventType.secret,
61+
users: {
62+
connect: {
63+
id: authenticatedUser.id,
64+
},
65+
},
66+
userId: authenticatedUser.id,
67+
},
68+
});
69+
});
70+
71+
res.status(201).end();
72+
} else {
73+
res.status(500).end();
74+
}
75+
} else {
76+
res.status(405).end();
77+
}
78+
}

pages/api/import/savvycal.ts

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { PrismaClient } from "@prisma/client";
2+
import type { NextApiRequest, NextApiResponse } from "next";
3+
4+
import { getSession } from "@lib/auth";
5+
6+
const prisma = new PrismaClient();
7+
8+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
9+
const session = await getSession({ req });
10+
const authenticatedUser = await prisma.user.findFirst({
11+
rejectOnNotFound: true,
12+
where: {
13+
id: session?.user.id,
14+
},
15+
select: {
16+
id: true,
17+
},
18+
});
19+
if (req.method === "POST") {
20+
const userResult = await fetch("https://api.savvycal.com/v1/me", {
21+
method: "GET",
22+
headers: {
23+
"Content-Type": "application/json",
24+
Authorization: "Bearer " + req.body.token,
25+
},
26+
});
27+
28+
if (userResult.status === 200) {
29+
const userData = await userResult.json();
30+
31+
await prisma.user.update({
32+
where: {
33+
id: authenticatedUser.id,
34+
},
35+
data: {
36+
name: userData.display_name,
37+
timeZone: userData.time_zone,
38+
weekStart: userData.first_day_of_week === 0 ? "Sunday" : "Monday",
39+
avatar: userData.avatar_url,
40+
},
41+
});
42+
43+
const eventTypesResult = await fetch("https://api.savvycal.com/v1/links?limit=100", {
44+
method: "GET",
45+
headers: {
46+
"Content-Type": "application/json",
47+
Authorization: "Bearer " + req.body.token,
48+
},
49+
});
50+
51+
const eventTypesData = await eventTypesResult.json();
52+
53+
eventTypesData.entries.forEach(async (eventType: any) => {
54+
await prisma.eventType.create({
55+
data: {
56+
title: eventType.name,
57+
slug: eventType.slug,
58+
length: eventType.durations[0],
59+
description: eventType.description.replace(/<[^>]*>?/gm, ""),
60+
hidden: eventType.state === "active" ? true : false,
61+
users: {
62+
connect: {
63+
id: authenticatedUser.id,
64+
},
65+
},
66+
userId: authenticatedUser.id,
67+
},
68+
});
69+
});
70+
71+
res.status(201).end();
72+
} else {
73+
res.status(500).end();
74+
}
75+
} else {
76+
res.status(405).end();
77+
}
78+
}

pages/getting-started.tsx

+114-37
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ArrowRightIcon } from "@heroicons/react/outline";
2+
import { zodResolver } from "@hookform/resolvers/zod/dist/zod";
23
import { Prisma } from "@prisma/client";
34
import classnames from "classnames";
45
import dayjs from "dayjs";
@@ -14,6 +15,7 @@ import { useRouter } from "next/router";
1415
import React, { useEffect, useRef, useState } from "react";
1516
import { useForm } from "react-hook-form";
1617
import TimezoneSelect from "react-timezone-select";
18+
import * as z from "zod";
1719

1820
import { getSession } from "@lib/auth";
1921
import { DEFAULT_SCHEDULE } from "@lib/availability";
@@ -71,6 +73,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
7173
const { status } = useSession();
7274
const loading = status === "loading";
7375
const [ready, setReady] = useState(false);
76+
const [selectedImport, setSelectedImport] = useState("");
7477
const [error, setError] = useState<Error | null>(null);
7578

7679
const updateUser = async (data: Prisma.UserUpdateInput) => {
@@ -229,51 +232,125 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
229232
router.push("/event-types");
230233
};
231234

235+
const schema = z.object({
236+
token: z.string(),
237+
});
238+
239+
const formMethods = useForm<{
240+
token: string;
241+
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
242+
232243
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
233244
const steps = [
234245
{
235246
id: t("welcome"),
236247
title: t("welcome_to_calcom"),
237248
description: t("welcome_instructions"),
238249
Component: (
239-
<form className="sm:mx-auto sm:w-full">
240-
<section className="space-y-8">
241-
<fieldset>
242-
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
243-
{t("full_name")}
244-
</label>
245-
<input
246-
ref={nameRef}
247-
type="text"
248-
name="name"
249-
id="name"
250-
autoComplete="given-name"
251-
placeholder={t("your_name")}
252-
defaultValue={props.user.name ?? enteredName}
253-
required
254-
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"
255-
/>
256-
</fieldset>
257-
258-
<fieldset>
259-
<section className="flex justify-between">
260-
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
261-
{t("timezone")}
250+
<>
251+
{selectedImport == "" && (
252+
<div className="grid grid-cols-2 mb-4 gap-x-4">
253+
<Button color="secondary" onClick={() => setSelectedImport("calendly")}>
254+
{t("import_from")} Calendly
255+
</Button>
256+
<Button color="secondary" onClick={() => setSelectedImport("savvycal")}>
257+
{t("import_from")} SavvyCal
258+
</Button>
259+
</div>
260+
)}
261+
{selectedImport && (
262+
<div>
263+
<h2 className="text-2xl text-gray-900 font-cal">
264+
{t("import_from")} {selectedImport === "calendly" ? "Calendly" : "SavvyCal"}
265+
</h2>
266+
<p className="mb-2 text-sm text-gray-500">{t("you_will_need_to_generate")}</p>
267+
<form
268+
className="flex"
269+
onSubmit={formMethods.handleSubmit(async (values) => {
270+
setSubmitting(true);
271+
const response = await fetch(`/api/import/${selectedImport}`, {
272+
method: "POST",
273+
body: JSON.stringify({
274+
token: values.token,
275+
}),
276+
headers: {
277+
"Content-Type": "application/json",
278+
},
279+
});
280+
if (response.status === 201) {
281+
setSubmitting(false);
282+
handleSkipStep();
283+
} else {
284+
await response.json().catch((e) => {
285+
console.log("Error: response.json invalid: " + e);
286+
setSubmitting(false);
287+
});
288+
}
289+
})}>
290+
<input
291+
onChange={async (e) => {
292+
formMethods.setValue("token", e.target.value);
293+
}}
294+
type="text"
295+
name="token"
296+
id="token"
297+
placeholder={t("access_token")}
298+
required
299+
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"
300+
/>
301+
<Button type="submit" className="h-10 mt-1 ml-4">
302+
{t("import")}
303+
</Button>
304+
</form>
305+
</div>
306+
)}
307+
<div className="relative my-4">
308+
<div className="absolute inset-0 flex items-center" aria-hidden="true">
309+
<div className="w-full border-t border-gray-300" />
310+
</div>
311+
<div className="relative flex justify-center">
312+
<span className="px-2 text-sm text-gray-500 bg-white">or</span>
313+
</div>
314+
</div>
315+
<form className="sm:mx-auto sm:w-full">
316+
<section className="space-y-8">
317+
<fieldset>
318+
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
319+
{t("full_name")}
262320
</label>
263-
<Text variant="caption">
264-
{t("current_time")}:&nbsp;
265-
<span className="text-black">{dayjs().tz(selectedTimeZone).format("LT")}</span>
266-
</Text>
267-
</section>
268-
<TimezoneSelect
269-
id="timeZone"
270-
value={selectedTimeZone}
271-
onChange={({ value }) => setSelectedTimeZone(value)}
272-
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
273-
/>
274-
</fieldset>
275-
</section>
276-
</form>
321+
<input
322+
ref={nameRef}
323+
type="text"
324+
name="name"
325+
id="name"
326+
autoComplete="given-name"
327+
placeholder={t("your_name")}
328+
defaultValue={props.user.name ?? enteredName}
329+
required
330+
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"
331+
/>
332+
</fieldset>
333+
334+
<fieldset>
335+
<section className="flex justify-between">
336+
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
337+
{t("timezone")}
338+
</label>
339+
<Text variant="caption">
340+
{t("current_time")}:&nbsp;
341+
<span className="text-black">{dayjs().tz(selectedTimeZone).format("LT")}</span>
342+
</Text>
343+
</section>
344+
<TimezoneSelect
345+
id="timeZone"
346+
value={selectedTimeZone}
347+
onChange={({ value }) => setSelectedTimeZone(value)}
348+
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
349+
/>
350+
</fieldset>
351+
</section>
352+
</form>
353+
</>
277354
),
278355
hideConfirm: false,
279356
confirmText: t("continue"),

public/static/locales/en/common.json

+4
Original file line numberDiff line numberDiff line change
@@ -595,5 +595,9 @@
595595
"saml_configuration_update_failed": "SAML configuration update failed",
596596
"saml_configuration_delete_failed": "SAML configuration delete failed",
597597
"saml_email_required": "Please enter an email so we can find your SAML Identity Provider",
598+
"you_will_need_to_generate": "You will need to generate an access token from the integrations page.",
599+
"import": "Import",
600+
"import_from": "Import from",
601+
"access_token": "Access token",
598602
"visit_roadmap": "Roadmap"
599603
}

0 commit comments

Comments
 (0)