Merge pull request #176 from emrysal/feature/implement-phone-and-physical-locations

Implemented configurable eventType phone or physical locations.
This commit is contained in:
Bailey Pumfleet 2021-05-08 20:49:04 +01:00 committed by GitHub
commit dc09fc833b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 349 additions and 119 deletions

View File

@ -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,

6
lib/location.ts Normal file
View File

@ -0,0 +1,6 @@
export enum LocationType {
InPerson = 'inPerson',
Phone = 'phone',
}

View File

@ -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": {

View File

@ -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<LocationType>(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) {
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
{selectedLocation === LocationType.InPerson && <p className="text-gray-500 mb-2">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{locationInfo(selectedLocation).address}
</p>}
<p className="text-blue-600 mb-4">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")}
@ -75,6 +98,23 @@ export default function Book(props) {
<input type="email" name="email" id="email" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="you@example.com" />
</div>
</div>
{props.eventType.locations.length > 1 && (
<div className="mb-4">
<span className="block text-sm font-medium text-gray-700">Location</span>
{props.eventType.locations.map( (location) => (
<label key={location.type} className="block">
<input type="radio" required onChange={(e) => setSelectedLocation(e.target.value)} className="location" name="location" value={location.type} checked={selectedLocation === location.type} />
<span className="text-sm ml-2">{locationLabels[location.type]}</span>
</label>
))}
</div>
)}
{selectedLocation === LocationType.Phone && (<div className="mb-4">
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">Phone Number</label>
<div className="mt-1">
<PhoneInput name="phone" placeholder="Enter phone number" id="phone" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" onChange={() => {}} />
</div>
</div>)}
<div className="mb-4">
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label>
<textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Please share anything that will help prepare for our meeting."></textarea>
@ -117,7 +157,8 @@ export async function getServerSideProps(context) {
title: true,
slug: true,
description: true,
length: true
length: true,
locations: true,
}
});

View File

@ -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,
},
});

View File

@ -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 }
]

View File

@ -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<OptionBase | undefined>(undefined);
const [ locations, setLocations ] = useState(props.eventType.locations || []);
const titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>();
const descriptionRef = useRef<HTMLTextAreaElement>();
@ -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 (
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">Set an address or place</label>
<div className="mt-1">
<input type="text" name="address" id="address" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" defaultValue={address} />
</div>
</div>
)
case LocationType.Phone:
return (
<p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p>
)
}
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 (
<div>
<Head>
@ -92,6 +165,53 @@ export default function EventType(props) {
</div>
</div>
</div>
<div className="mb-4">
<label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label>
{locations.length === 0 && <div className="mt-1 mb-2">
<div className="flex rounded-md shadow-sm">
<Select
name="location"
id="location"
options={locationOptions}
isSearchable="false"
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
onChange={(e) => openLocationModal(e.value)}
/>
</div>
</div>}
{locations.length > 0 && <ul className="w-96 mt-1">
{locations.map( (location) => (
<li key={location.type} className="bg-blue-50 mb-2 p-2 border">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex-grow flex">
<LocationMarkerIcon className="h-6 w-6" />
<span className="ml-2 text-sm">{location.address}</span>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex-grow flex">
<PhoneIcon className="h-6 w-6" />
<span className="ml-2 text-sm">Phone call</span>
</div>
)}
<div className="flex">
<button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button>
<button onClick={() => removeLocation(location)}>
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
</button>
</div>
</div>
</li>
))}
{locations.length > 0 && locations.length !== locationOptions.length && <li>
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowLocationModal(true)}>
<PlusCircleIcon className="h-6 w-6" />
<span className="ml-1">Add another location option</span>
</button>
</li>}
</ul>}
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label>
<div className="mt-1">
@ -153,6 +273,45 @@ export default function EventType(props) {
</div>
</div>
</div>
{showLocationModal &&
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<LocationMarkerIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit location</h3>
</div>
</div>
<form onSubmit={updateLocations}>
<Select
name="location"
defaultValue={selectedLocation}
options={locationOptions}
isSearchable="false"
className="mb-2 flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
onChange={setSelectedLocation}
/>
<LocationOptions />
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Update
</button>
<button onClick={closeLocationModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
</Shell>
</div>
);
@ -182,7 +341,8 @@ export async function getServerSideProps(context) {
slug: true,
description: true,
length: true,
hidden: true
hidden: true,
locations: true,
}
});

View File

@ -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) {
</div>
<div className="mt-4 border-t border-b py-4">
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.eventType.title} with {props.user.name}</h2>
<p className="text-gray-500 mb-2">
<p className="text-gray-500 mb-1">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
<p className="text-gray-500 mb-1">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{location}
</p>
<p className="text-gray-500">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")}
@ -74,17 +84,17 @@ export default function Success(props) {
<div className="mt-5 sm:mt-6 text-center">
<span className="font-medium text-gray-500">Add to your calendar</span>
<div className="flex mt-2">
<Link href={`https://calendar.google.com/calendar/r/eventedit?dates=${dayjs(date).format('YYYYMMDDTHHmmss[Z]')}/${dayjs(date).add(props.eventType.length, 'minute').format('YYYYMMDDTHHmmss[Z]')}&text=${props.eventType.title} with ${props.user.name}&details=${props.eventType.description}`}>
<Link href={`https://calendar.google.com/calendar/r/eventedit?dates=${dayjs(date).format('YYYYMMDDTHHmmss[Z]')}/${dayjs(date).add(props.eventType.length, 'minute').format('YYYYMMDDTHHmmss[Z]')}&text=${props.eventType.title} with ${props.user.name}&details=${props.eventType.description}&location=${encodeURIComponent(location)}`}>
<a className="mx-2 btn-wide btn-white">
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>
</a>
</Link>
<Link href={encodeURI("https://outlook.live.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + dayjs(date).add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + dayjs(date).format() + "&subject=" + props.eventType.title + " with " + props.user.name)}>
<Link href={encodeURI("https://outlook.live.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + dayjs(date).add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + dayjs(date).format() + "&subject=" + props.eventType.title + " with " + props.user.name) + "&location=" + location}>
<a className="mx-2 btn-wide btn-white">
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Microsoft Outlook</title><path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z"/></svg>
</a>
</Link>
<Link href={encodeURI("https://outlook.office.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + dayjs(date).add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + dayjs(date).format() + "&subject=" + props.eventType.title + " with " + props.user.name)}>
<Link href={encodeURI("https://outlook.office.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + dayjs(date).add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + dayjs(date).format() + "&subject=" + props.eventType.title + " with " + props.user.name) + "&location=" + location}>
<a className="mx-2 btn-wide btn-white">
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Microsoft Office</title><path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z"/></svg>
</a>

View File

@ -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])

View File

@ -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==