error handling WIP

This commit is contained in:
Malte Delfs 2021-06-20 20:32:30 +02:00
parent ded27d17ea
commit 931e6b26f1
4 changed files with 157 additions and 71 deletions

View file

@ -1,11 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
export default function Button(props) { export default function Button(props) {
const [loading, setLoading] = useState(false);
return( return(
<button type="submit" className="btn btn-primary" onClick={setLoading}> <button type="submit" className="btn btn-primary">
{!loading && props.children} {!props.loading && props.children}
{loading && {props.loading &&
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>

View file

@ -6,7 +6,14 @@ import { useRouter } from 'next/router';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { Switch } from '@headlessui/react'; import { Switch } from '@headlessui/react';
import TimezoneSelect from 'react-timezone-select'; import TimezoneSelect from 'react-timezone-select';
import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; import {
ClockIcon,
GlobeIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
XCircleIcon, ExclamationIcon
} from '@heroicons/react/solid';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from 'dayjs/plugin/isBetween';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
@ -29,6 +36,7 @@ export default function Type(props) {
const [selectedDate, setSelectedDate] = useState<Dayjs>(); const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [is24h, setIs24h] = useState(false); const [is24h, setIs24h] = useState(false);
const [busy, setBusy] = useState([]); const [busy, setBusy] = useState([]);
@ -72,9 +80,16 @@ export default function Type(props) {
} }
setLoading(true); setLoading(true);
setError(false);
const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`); const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`);
const busyTimes = await res.json(); if (res.ok) {
if (busyTimes.length > 0) setBusy(busyTimes); const busyTimes = await res.json();
if (busyTimes.length > 0) setBusy(busyTimes);
} else {
setError(true);
}
setLoading(false); setLoading(false);
} }
changeDate(); changeDate();
@ -340,7 +355,24 @@ export default function Type(props) {
{dayjs(selectedDate).format("dddd DD MMMM YYYY")} {dayjs(selectedDate).format("dddd DD MMMM YYYY")}
</span> </span>
</div> </div>
{!loading ? availableTimes : <div className="loader"></div>} {!loading && !error && availableTimes}
{loading && <div className="loader"/>}
{error &&
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Could not load the available time slots.{' '}
<a href={"mailto:" + props.user.email} className="font-medium underline text-yellow-700 hover:text-yellow-600">
Contact {props.user.name} via e-mail
</a>
</p>
</div>
</div>
</div>}
</div> </div>
)} )}
</div> </div>

View file

@ -1,7 +1,7 @@
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid'; import {CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon} from '@heroicons/react/solid';
import prisma from '../../lib/prisma'; import prisma from '../../lib/prisma';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
@ -23,6 +23,8 @@ export default function Book(props) {
const [ is24h, setIs24h ] = useState(false); const [ is24h, setIs24h ] = useState(false);
const [ preferredTimeZone, setPreferredTimeZone ] = useState(''); const [ preferredTimeZone, setPreferredTimeZone ] = useState('');
const [ loading, setLoading ] = useState(false);
const [ error, setError ] = useState(false);
const locations = props.eventType.locations || []; const locations = props.eventType.locations || [];
@ -47,41 +49,51 @@ export default function Book(props) {
}; };
const bookingHandler = event => { const bookingHandler = event => {
event.preventDefault(); const book = async () => {
setLoading(true);
setError(false);
let payload = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, 'minute').format(),
name: event.target.name.value,
email: event.target.email.value,
notes: event.target.notes.value,
timeZone: preferredTimeZone,
eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid
};
let payload = { if (selectedLocation) {
start: dayjs(date).format(), payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address;
end: dayjs(date).add(props.eventType.length, 'minute').format(),
name: event.target.name.value,
email: event.target.email.value,
notes: event.target.notes.value,
timeZone: preferredTimeZone,
eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid
};
if (selectedLocation) {
payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address;
}
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
const res = fetch(
'/api/book/' + user,
{
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
} }
);
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1&name=${payload.name}`; telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
if (payload['location']) { const res = await fetch(
successUrl += "&location=" + encodeURIComponent(payload['location']); '/api/book/' + user,
{
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}
);
if (res.ok) {
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1&name=${payload.name}`;
if (payload['location']) {
successUrl += "&location=" + encodeURIComponent(payload['location']);
}
await router.push(successUrl);
} else {
setLoading(false);
setError(true);
}
} }
router.push(successUrl); event.preventDefault();
book();
} }
return ( return (
@ -148,12 +160,27 @@ export default function Book(props) {
<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." defaultValue={props.booking ? props.booking.description : ''}></textarea> <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." defaultValue={props.booking ? props.booking.description : ''}></textarea>
</div> </div>
<div className="flex items-start"> <div className="flex items-start">
<Button type="submit" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button> <Button type="submit" loading={loading} className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button>
<Link href={"/" + props.user.username + "/" + props.eventType.slug + (rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")}> <Link href={"/" + props.user.username + "/" + props.eventType.slug + (rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")}>
<a className="ml-2 btn btn-white">Cancel</a> <a className="ml-2 btn btn-white">Cancel</a>
</Link> </Link>
</div> </div>
</form> </form>
{error && <div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mt-2">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Could not {rescheduleUid ? 'reschedule' : 'book'} the meeting. Please try again or{' '}
<a href={"mailto:" + props.user.email} className="font-medium underline text-yellow-700 hover:text-yellow-600">
Contact {props.user.name} via e-mail
</a>
</p>
</div>
</div>
</div>}
</div> </div>
</div> </div>
</div> </div>

View file

@ -98,9 +98,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Use all integrations // Use all integrations
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { results = await async.mapLimit(currentUser.credentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return await updateEvent(credential, bookingRefUid, appendLinksToEvents(evt)) return updateEvent(credential, bookingRefUid, appendLinksToEvents(evt))
.then(response => ({type: credential.type, success: true, response}))
.catch(e => {
console.error("createEvent failed", e)
return {type: credential.type, success: false}
});
}); });
if (results.every(res => !res.success)) {
res.status(500).json({message: "Rescheduling failed"});
return;
}
// Clone elements // Clone elements
referencesToCreate = [...booking.references]; referencesToCreate = [...booking.references];
@ -129,14 +139,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} else { } else {
// Schedule event // Schedule event
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { results = await async.mapLimit(currentUser.credentials, 5, async (credential) => {
const response = await createEvent(credential, appendLinksToEvents(evt)); return createEvent(credential, appendLinksToEvents(evt))
return { .then(response => ({type: credential.type, success: true, response}))
type: credential.type, .catch(e => {
response console.error("createEvent failed", e)
}; return {type: credential.type, success: false}
});
}); });
referencesToCreate = results.map((result => { if (results.every(res => !res.success)) {
res.status(500).json({message: "Booking failed"});
return;
}
referencesToCreate = results.filter(res => res.success).map((result => {
return { return {
type: result.type, type: result.type,
uid: result.response.id uid: result.response.id
@ -144,32 +160,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
})); }));
} }
await prisma.booking.create({ let booking;
data: { try {
uid: hashUID, booking = await prisma.booking.create({
userId: currentUser.id, data: {
references: { uid: hashUID,
create: referencesToCreate userId: currentUser.id,
}, references: {
eventTypeId: eventType.id, create: referencesToCreate
},
eventTypeId: eventType.id,
title: evt.title, title: evt.title,
description: evt.description, description: evt.description,
startTime: evt.startTime, startTime: evt.startTime,
endTime: evt.endTime, endTime: evt.endTime,
attendees: { attendees: {
create: evt.attendees create: evt.attendees
}
} }
} });
}); } catch (e) {
console.error("Error when saving booking to db", e);
// If one of the integrations allows email confirmations or no integrations are added, send it. res.status(500).json({message: "Booking already exists"});
if (currentUser.credentials.length === 0 || !results.every((result) => result.disableConfirmationEmail)) { return;
await createConfirmBookedEmail(
evt, cancelLink, rescheduleLink
);
} }
res.status(200).json(results); try {
// If one of the integrations allows email confirmations or no integrations are added, send it.
// TODO: locally this is really slow (maybe only because the authentication for the mail service fails), so fire and forget would be nice here
if (currentUser.credentials.length === 0 || !results.every((result) => result.response.disableConfirmationEmail)) {
await createConfirmBookedEmail(
evt, cancelLink, rescheduleLink
);
}
} catch (e) {
console.error("createConfirmBookedEmail failed", e)
}
res.status(204).send({});
} }