Merge pull request #176 from emrysal/feature/implement-phone-and-physical-locations
Implemented configurable eventType phone or physical locations.
This commit is contained in:
		
						commit
						dc09fc833b
					
				
					 10 changed files with 349 additions and 119 deletions
				
			
		|  | @ -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
									
								
							
							
						
						
									
										6
									
								
								lib/location.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| 
 | ||||
| export enum LocationType { | ||||
|     InPerson = 'inPerson', | ||||
|     Phone = 'phone', | ||||
| } | ||||
| 
 | ||||
|  | @ -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": { | ||||
|  |  | |||
|  | @ -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, | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 } | ||||
|         ] | ||||
|  |  | |||
|  | @ -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">​</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, | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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]) | ||||
|  |  | |||
							
								
								
									
										37
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								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== | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Bailey Pumfleet
						Bailey Pumfleet