diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index dae3ccb0cb..dcfaefc959 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -49,6 +49,7 @@ interface CalendarEvent { timeZone: string; endTime: string; description?: string; + location?: string; organizer: { name?: string, email: string }; attendees: { name?: string, email: string }[]; }; @@ -57,28 +58,37 @@ const MicrosoftOffice365Calendar = (credential) => { const auth = o365Auth(credential); - const translateEvent = (event: CalendarEvent) => ({ - subject: event.title, - body: { - contentType: 'HTML', - content: event.description, - }, - start: { - dateTime: event.startTime, - timeZone: event.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.timeZone, - }, - attendees: event.attendees.map(attendee => ({ - emailAddress: { - address: attendee.email, - name: attendee.name + const translateEvent = (event: CalendarEvent) => { + + let optional = {}; + if (event.location) { + optional.location = { displayName: event.location }; + } + + return { + subject: event.title, + body: { + contentType: 'HTML', + content: event.description, }, - type: "required" - })) - }); + start: { + dateTime: event.startTime, + timeZone: event.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.timeZone, + }, + attendees: event.attendees.map(attendee => ({ + emailAddress: { + address: attendee.email, + name: attendee.name + }, + type: "required" + })), + ...optional + } + }; return { getAvailability: (dateFrom, dateTo) => { @@ -119,7 +129,7 @@ const MicrosoftOffice365Calendar = (credential) => { 'Content-Type': 'application/json', }, body: JSON.stringify(translateEvent(event)) - })) + }).then(handleErrors)) } }; @@ -165,6 +175,10 @@ const GoogleCalendar = (credential) => { }, }; + if (event.location) { + payload['location'] = event.location; + } + const calendar = google.calendar({version: 'v3', auth: myGoogleAuth }); calendar.events.insert({ auth: myGoogleAuth, diff --git a/lib/location.ts b/lib/location.ts new file mode 100644 index 0000000000..b1ec56af04 --- /dev/null +++ b/lib/location.ts @@ -0,0 +1,6 @@ + +export enum LocationType { + InPerson = 'inPerson', + Phone = 'phone', +} + diff --git a/package.json b/package.json index 7deef52d3c..4eeb16ced1 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "next-transpile-modules": "^7.0.0", "react": "17.0.1", "react-dom": "17.0.1", + "react-phone-number-input": "^3.1.21", + "react-select": "^4.3.0", "react-timezone-select": "^1.0.2" }, "devDependencies": { diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index ce56b0f617..ad59279ec7 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -1,22 +1,39 @@ import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { ClockIcon, CalendarIcon } from '@heroicons/react/solid'; +import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid'; import prisma from '../../lib/prisma'; import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; -import {useEffect} from "react"; -const dayjs = require('dayjs'); +import { useEffect, useState } from "react"; +import dayjs from 'dayjs'; +import 'react-phone-number-input/style.css'; +import PhoneInput from 'react-phone-number-input'; +import { LocationType } from '../../lib/location'; export default function Book(props) { const router = useRouter(); const { date, user } = router.query; + const [ selectedLocation, setSelectedLocation ] = useState(props.eventType.locations.length === 1 ? props.eventType.locations[0].type : ''); const telemetry = useTelemetry(); useEffect(() => { telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); - }) + }); + + const locationInfo = (type: LocationType) => props.eventType.locations.find( + (location) => location.type === type + ); + + // TODO: Move to translations + const locationLabels = { + [LocationType.InPerson]: 'In-person meeting', + [LocationType.Phone]: 'Phone call', + }; const bookingHandler = event => { event.preventDefault(); + + const locationText = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address; + telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())); const res = fetch( '/api/book/' + user, @@ -26,6 +43,7 @@ export default function Book(props) { end: dayjs(date).add(props.eventType.length, 'minute').format(), name: event.target.name.value, email: event.target.email.value, + location: locationText, notes: event.target.notes.value }), headers: { @@ -34,7 +52,8 @@ export default function Book(props) { method: 'POST' } ); - router.push("/success?date=" + date + "&type=" + props.eventType.id + "&user=" + props.user.username); + + router.push(`/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&location=${encodeURIComponent(locationText)}`); } return ( @@ -55,6 +74,10 @@ export default function Book(props) { {props.eventType.length} minutes

+ {selectedLocation === LocationType.InPerson &&

+ + {locationInfo(selectedLocation).address} +

}

{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")} @@ -75,6 +98,23 @@ export default function Book(props) { + {props.eventType.locations.length > 1 && ( +

+ Location + {props.eventType.locations.map( (location) => ( + + ))} +
+ )} + {selectedLocation === LocationType.Phone && (
+ +
+ {}} /> +
+
)}
@@ -117,7 +157,8 @@ export async function getServerSideProps(context) { title: true, slug: true, description: true, - length: true + length: true, + locations: true, } }); diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index 3725120ba3..ea37b2fc52 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -4,99 +4,61 @@ import prisma from '../../../lib/prisma'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({req: req}); - if (!session) { res.status(401).json({message: "Not authenticated"}); return; } + // TODO: Add user ID to user session object + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true + } + }); - if (req.method == "POST") { - // TODO: Add user ID to user session object - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true - } - }); - - if (!user) { res.status(404).json({message: 'User not found'}); return; } - - const title = req.body.title; - const slug = req.body.slug; - const description = req.body.description; - const length = parseInt(req.body.length); - const hidden = req.body.hidden; - - const createEventType = await prisma.eventType.create({ - data: { - title: title, - slug: slug, - description: description, - length: length, - hidden: hidden, - userId: user.id, - }, - }); - - res.status(200).json({message: 'Event created successfully'}); + if (!user) { + res.status(404).json({message: 'User not found'}); + return; } - if (req.method == "PATCH") { - // TODO: Add user ID to user session object - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true - } - }); + if (req.method == "PATCH" || req.method == "POST") { - if (!user) { res.status(404).json({message: 'User not found'}); return; } + const data = { + title: req.body.title, + slug: req.body.slug, + description: req.body.description, + length: parseInt(req.body.length), + hidden: req.body.hidden, + locations: req.body.locations, + }; - const id = req.body.id; - const title = req.body.title; - const slug = req.body.slug; - const description = req.body.description; - const length = parseInt(req.body.length); - const hidden = req.body.hidden; - - const updateEventType = await prisma.eventType.update({ - where: { - id: id, - }, - data: { - title: title, - slug: slug, - description: description, - length: length, - hidden: hidden - }, - }); - - res.status(200).json({message: 'Event updated successfully'}); + if (req.method == "POST") { + const createEventType = await prisma.eventType.create({ + data: { + userId: user.id, + ...data, + }, + }); + res.status(200).json({message: 'Event created successfully'}); + } + else if (req.method == "PATCH") { + const updateEventType = await prisma.eventType.update({ + where: { + id: req.body.id, + }, + data, + }); + res.status(200).json({message: 'Event updated successfully'}); + } } if (req.method == "DELETE") { - // TODO: Add user ID to user session object - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true - } - }); - - if (!user) { res.status(404).json({message: 'User not found'}); return; } - - const id = req.body.id; const deleteEventType = await prisma.eventType.delete({ where: { - id: id, + id: req.body.id, }, }); diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index dd00c24568..d832901c59 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -21,6 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) startTime: req.body.start, endTime: req.body.end, timeZone: currentUser.timeZone, + location: req.body.location, attendees: [ { email: req.body.email, name: req.body.name } ] diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx index 8829c3336d..80215c9987 100644 --- a/pages/availability/event/[type].tsx +++ b/pages/availability/event/[type].tsx @@ -1,14 +1,22 @@ import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; +import Select, { OptionBase } from 'react-select'; import prisma from '../../../lib/prisma'; +import { LocationType } from '../../../lib/location'; import Shell from '../../../components/Shell'; import { useSession, getSession } from 'next-auth/client'; +import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from '@heroicons/react/outline'; export default function EventType(props) { const router = useRouter(); + const [ session, loading ] = useSession(); + const [ showLocationModal, setShowLocationModal ] = useState(false); + const [ selectedLocation, setSelectedLocation ] = useState(undefined); + const [ locations, setLocations ] = useState(props.eventType.locations || []); + const titleRef = useRef(); const slugRef = useRef(); const descriptionRef = useRef(); @@ -27,12 +35,11 @@ export default function EventType(props) { const enteredDescription = descriptionRef.current.value; const enteredLength = lengthRef.current.value; const enteredIsHidden = isHiddenRef.current.checked; - // TODO: Add validation const response = await fetch('/api/availability/eventtype', { method: 'PATCH', - body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden}), + body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations }), headers: { 'Content-Type': 'application/json' } @@ -55,6 +62,72 @@ export default function EventType(props) { router.push('/availability'); } + // TODO: Tie into translations instead of abstracting to locations.ts + const locationOptions: OptionBase[] = [ + { value: LocationType.InPerson, label: 'In-person meeting' }, + { value: LocationType.Phone, label: 'Phone call', }, + ]; + + const openLocationModal = (type: LocationType) => { + setSelectedLocation(locationOptions.find( (option) => option.value === type)); + setShowLocationModal(true); + } + + const closeLocationModal = () => { + setSelectedLocation(undefined); + setShowLocationModal(false); + }; + + const LocationOptions = () => { + if (!selectedLocation) { + return null; + } + switch (selectedLocation.value) { + case LocationType.InPerson: + const address = locations.find( + (location) => location.type === LocationType.InPerson + )?.address; + return ( +
+ +
+ +
+
+ ) + case LocationType.Phone: + + return ( +

Calendso will ask your invitee to enter a phone number before scheduling.

+ ) + } + return null; + }; + + const updateLocations = (e) => { + e.preventDefault(); + + let details = {}; + if (e.target.location.value === LocationType.InPerson) { + details = { address: e.target.address.value }; + } + + const existingIdx = locations.findIndex( (loc) => e.target.location.value === loc.type ); + if (existingIdx !== -1) { + let copy = locations; + copy[ existingIdx ] = { ...locations[ existingIdx ], ...details }; + setLocations(copy); + } else { + setLocations(locations.concat({ type: e.target.location.value, ...details })); + } + + setShowLocationModal(false); + }; + + const removeLocation = (selectedLocation) => { + setLocations(locations.filter( (location) => location.type !== selectedLocation.type )); + }; + return (
@@ -92,6 +165,53 @@ export default function EventType(props) {
+
+ + {locations.length === 0 &&
+
+ + +
+ + +
+ +
+
+
+ } ); @@ -182,7 +341,8 @@ export async function getServerSideProps(context) { slug: true, description: true, length: true, - hidden: true + hidden: true, + locations: true, } }); diff --git a/pages/success.tsx b/pages/success.tsx index fd1c24070d..7283d3e1dd 100644 --- a/pages/success.tsx +++ b/pages/success.tsx @@ -3,13 +3,13 @@ import Link from 'next/link'; import prisma from '../lib/prisma'; import { useRouter } from 'next/router'; import { CheckIcon } from '@heroicons/react/outline'; -import { ClockIcon, CalendarIcon } from '@heroicons/react/solid'; +import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid'; const dayjs = require('dayjs'); const ics = require('ics'); export default function Success(props) { const router = useRouter(); - const { date } = router.query; + const { date, location } = router.query; function eventLink(): string { @@ -17,12 +17,18 @@ export default function Success(props) { (parts) => parts.split('-').length > 1 ? parts.split('-').map( (n) => parseInt(n, 10) ) : parts.split(':').map( (n) => parseInt(n, 10) ) )); + let optional = {}; + if (location) { + optional['location'] = location; + } + const event = ics.createEvent({ start, startInputType: 'utc', title: props.eventType.title + ' with ' + props.user.name, description: props.eventType.description, - duration: { minutes: props.eventType.length } + duration: { minutes: props.eventType.length }, + ...optional }); if (event.error) { @@ -60,10 +66,14 @@ export default function Success(props) {

{props.eventType.title} with {props.user.name}

-

+

{props.eventType.length} minutes

+

+ + {location} +

{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")} @@ -74,17 +84,17 @@ export default function Success(props) {

Add to your calendar
- + Google - + Microsoft Outlook - + Microsoft Office diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 288682e774..f72eaee29d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,7 @@ model EventType { title String slug String description String? + locations Json? length Int hidden Boolean @default(false) user User? @relation(fields: [userId], references: [id]) diff --git a/yarn.lock b/yarn.lock index b3f1af6893..fc932f7198 100644 --- a/yarn.lock +++ b/yarn.lock @@ -756,6 +756,11 @@ classnames@2.2.6: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== +classnames@^2.2.5: + version "2.3.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" + integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== + cli-highlight@^2.1.10: version "2.1.11" resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.11.tgz#49736fa452f0aaf4fae580e30acb26828d2dc1bf" @@ -859,6 +864,11 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +country-flag-icons@^1.0.2: + version "1.2.10" + resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.2.10.tgz#c60fdf25883abacd28fbbf3842b920890f944591" + integrity sha512-nG+kGe4wVU9M+EsLUhP4buSuNdBH0leTm0Fv6RToXxO9BbbxUKV9VUq+9AcztnW7nEnweK7WYdtJsfyNLmQugQ== + create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" @@ -1564,6 +1574,13 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= +input-format@^0.3.6: + version "0.3.6" + resolved "https://registry.yarnpkg.com/input-format/-/input-format-0.3.6.tgz#b9b167dbd16435eb3c0012347964b230ea0024c8" + integrity sha512-SbUu43CDVV5GlC8Xi6NYBUoiU+tLpN/IMYyQl0mzSXDiU1w0ql8wpcwjDOFpaCVLySLoreLUimhI82IA5y42Pw== + dependencies: + prop-types "^15.7.2" + is-arguments@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.0.tgz#62353031dfbee07ceb34656a6bde59efecae8dd9" @@ -1827,6 +1844,11 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +libphonenumber-js@^1.9.17: + version "1.9.17" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.9.17.tgz#fef2e6fd7a981be69ba358c24495725ee8daf331" + integrity sha512-ElJki901OynMg1l+evooPH1VyHrECuLqpgc12z2BkK25dFU5lUKTuMHEYV2jXxvtns/PIuJax56cBeoSK7ANow== + loader-utils@1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" @@ -2483,7 +2505,7 @@ process@0.11.10, process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -prop-types@15.7.2, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2: +prop-types@15.7.2, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -2607,12 +2629,23 @@ react-is@16.13.1, react-is@^16.7.0, react-is@^16.8.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-phone-number-input@^3.1.21: + version "3.1.21" + resolved "https://registry.yarnpkg.com/react-phone-number-input/-/react-phone-number-input-3.1.21.tgz#7c6de442d9d2ebd6e757e93c6603698aa008e82b" + integrity sha512-Q1CS7RKFE+DyiZxEKrs00wf7geQ4qBJpOflCVNtTXnO0a2iXG42HFF7gtUpKQpro8THr7ejNy8H+zm2zD+EgvQ== + dependencies: + classnames "^2.2.5" + country-flag-icons "^1.0.2" + input-format "^0.3.6" + libphonenumber-js "^1.9.17" + prop-types "^15.7.2" + react-refresh@0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== -react-select@^4.2.1: +react-select@^4.2.1, react-select@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/react-select/-/react-select-4.3.0.tgz#6bde634ae7a378b49f3833c85c126f533483fa2e" integrity sha512-SBPD1a3TJqE9zoI/jfOLCAoLr/neluaeokjOixr3zZ1vHezkom8K0A9J4QG9IWDqIDE9K/Mv+0y1GjidC2PDtQ==