confirming event gives no visual (#803)

This commit is contained in:
Alex Johansson 2021-09-28 10:16:02 +01:00 committed by GitHub
parent dd9f801872
commit 7779c098dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 283 additions and 302 deletions

View File

@ -5,9 +5,11 @@ import { DotsHorizontalIcon } from "@heroicons/react/solid";
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import { Fragment } from "react";
import { useMutation } from "react-query";
import classNames from "@lib/classNames";
import { trpc } from "@lib/trpc";
import { HttpError } from "@lib/core/http/error";
import { inferQueryOutput, trpc } from "@lib/trpc";
import EmptyScreen from "@components/EmptyScreen";
import Loader from "@components/Loader";
@ -15,23 +17,229 @@ import Shell from "@components/Shell";
import { Alert } from "@components/ui/Alert";
import { Button } from "@components/ui/Button";
type BookingItem = inferQueryOutput<"viewer.bookings">[number];
function BookingListItem(booking: BookingItem) {
const utils = trpc.useContext();
const mutation = useMutation(
async (confirm: boolean) => {
const res = await fetch("/api/book/confirm", {
method: "PATCH",
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new HttpError({ statusCode: res.status });
}
},
{
async onSettled() {
await utils.invalidateQuery(["viewer.bookings"]);
},
}
);
return (
<tr>
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
{!booking.confirmed && !booking.rejected && (
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
Unconfirmed
</span>
)}
<div className="text-sm text-neutral-900 font-medium truncate max-w-60 md:max-w-96">
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title}
</div>
<div className="sm:hidden">
<div className="text-sm text-gray-900">
{dayjs(booking.startTime).format("D MMMM YYYY")}:{" "}
<small className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</small>
</div>
</div>
{booking.attendees.length !== 0 && (
<div className="text-sm text-blue-500">
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
</div>
)}
</td>
<td className="hidden sm:table-cell px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{dayjs(booking.startTime).format("D MMMM YYYY")}</div>
<div className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{!booking.confirmed && !booking.rejected && (
<>
<div className="space-x-2 hidden lg:block">
<Button
onClick={() => mutation.mutate(true)}
StartIcon={CheckIcon}
color="secondary"
disabled={mutation.isLoading}>
Confirm
</Button>
<Button
onClick={() => mutation.mutate(false)}
StartIcon={BanIcon}
color="secondary"
disabled={mutation.isLoading}>
Reject
</Button>
</div>
<Menu as="div" className="inline-block lg:hidden text-left ">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<span
onClick={() => mutation.mutate(true)}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<CheckIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Confirm
</span>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<span
onClick={() => mutation.mutate(false)}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm w-full font-medium"
)}>
<BanIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Reject
</span>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</>
)}
{booking.confirmed && !booking.rejected && (
<>
<div className="space-x-2 hidden lg:block">
<Button
data-testid="cancel"
href={"/cancel/" + booking.uid}
StartIcon={XIcon}
color="secondary">
Cancel
</Button>
<Button href={"reschedule/" + booking.uid} StartIcon={ClockIcon} color="secondary">
Reschedule
</Button>
</div>
<Menu as="div" className="inline-block lg:hidden text-left ">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href={process.env.NEXT_PUBLIC_APP_URL + "/../cancel/" + booking.uid}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<XIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Cancel
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href={process.env.NEXT_PUBLIC_APP_URL + "/../reschedule/" + booking.uid}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm w-full font-medium"
)}>
<ClockIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Reschedule
</a>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</>
)}
{!booking.confirmed && booking.rejected && <div className="text-sm text-gray-500">Rejected</div>}
</td>
</tr>
);
}
export default function Bookings() {
const query = trpc.useQuery(["viewer.bookings"]);
const bookings = query.data;
async function confirmBookingHandler(booking: { id: number }, confirm: boolean) {
const res = await fetch("/api/book/confirm", {
method: "PATCH",
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
await query.refetch();
}
}
return (
<Shell heading="Bookings" subtitle="See upcoming and past events booked through your event type links.">
<div className="-mx-4 sm:mx-auto flex flex-col">
@ -55,223 +263,7 @@ export default function Bookings() {
{bookings
.filter((booking) => booking.status !== BookingStatus.CANCELLED)
.map((booking) => (
<tr key={booking.id}>
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
{!booking.confirmed && !booking.rejected && (
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
Unconfirmed
</span>
)}
<div className="text-sm text-neutral-900 font-medium truncate max-w-60 md:max-w-96">
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title}
</div>
<div className="sm:hidden">
<div className="text-sm text-gray-900">
{dayjs(booking.startTime).format("D MMMM YYYY")}:{" "}
<small className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} -{" "}
{dayjs(booking.endTime).format("HH:mm")}
</small>
</div>
</div>
{booking.attendees.length !== 0 && (
<div className="text-sm text-blue-500">
<a href={"mailto:" + booking.attendees[0].email}>
{booking.attendees[0].email}
</a>
</div>
)}
</td>
<td className="hidden sm:table-cell px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{dayjs(booking.startTime).format("D MMMM YYYY")}
</div>
<div className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} -{" "}
{dayjs(booking.endTime).format("HH:mm")}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{!booking.confirmed && !booking.rejected && (
<>
<div className="space-x-2 hidden lg:block">
<Button
onClick={() => confirmBookingHandler(booking, true)}
StartIcon={CheckIcon}
color="secondary">
Confirm
</Button>
<Button
onClick={() => confirmBookingHandler(booking, false)}
StartIcon={BanIcon}
color="secondary">
Reject
</Button>
</div>
<Menu as="div" className="inline-block lg:hidden text-left ">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<span
onClick={() => confirmBookingHandler(booking, true)}
className={classNames(
active
? "bg-neutral-100 text-neutral-900"
: "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<CheckIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Confirm
</span>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<span
onClick={() => confirmBookingHandler(booking, false)}
className={classNames(
active
? "bg-neutral-100 text-neutral-900"
: "text-neutral-700",
"group flex items-center px-4 py-2 text-sm w-full font-medium"
)}>
<BanIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Reject
</span>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</>
)}
{booking.confirmed && !booking.rejected && (
<>
<div className="space-x-2 hidden lg:block">
<Button
data-testid="cancel"
href={"/cancel/" + booking.uid}
StartIcon={XIcon}
color="secondary">
Cancel
</Button>
<Button
href={"reschedule/" + booking.uid}
StartIcon={ClockIcon}
color="secondary">
Reschedule
</Button>
</div>
<Menu as="div" className="inline-block lg:hidden text-left ">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href={
process.env.NEXT_PUBLIC_APP_URL +
"/../cancel/" +
booking.uid
}
className={classNames(
active
? "bg-neutral-100 text-neutral-900"
: "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<XIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Cancel
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href={
process.env.NEXT_PUBLIC_APP_URL +
"/../reschedule/" +
booking.uid
}
className={classNames(
active
? "bg-neutral-100 text-neutral-900"
: "text-neutral-700",
"group flex items-center px-4 py-2 text-sm w-full font-medium"
)}>
<ClockIcon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Reschedule
</a>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</>
)}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">Rejected</div>
)}
</td>
</tr>
<BookingListItem key={booking.id} {...booking} />
))}
</tbody>
</table>

View File

@ -6,57 +6,13 @@ import { hashPassword } from "../lib/auth";
const prisma = new PrismaClient();
async function createBookingForEventType(opts: {
uid: string;
title: string;
slug: string;
startTime: Date | string;
endTime: Date | string;
userEmail: string;
}) {
const eventType = await prisma.eventType.findFirst({
where: {
slug: opts.slug,
},
});
if (!eventType) {
// should not happen
throw new Error("Eventtype missing");
}
const bookingData: Prisma.BookingCreateArgs["data"] = {
uid: opts.uid,
title: opts.title,
startTime: opts.startTime,
endTime: opts.endTime,
user: {
connect: {
email: opts.userEmail,
},
},
attendees: {
create: {
email: opts.userEmail,
name: "Some name",
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id: eventType.id,
},
},
};
await prisma.booking.create({
data: bookingData,
});
}
async function createUserAndEventType(opts: {
user: { email: string; password: string; username: string; plan: UserPlan; name: string };
eventTypes: Array<Prisma.EventTypeCreateArgs["data"]>;
eventTypes: Array<
Prisma.EventTypeCreateInput & {
_bookings?: Prisma.BookingCreateInput[];
}
>;
}) {
const userData: Prisma.UserCreateArgs["data"] = {
...opts.user,
@ -73,8 +29,8 @@ async function createUserAndEventType(opts: {
console.log(
`👤 Upserted '${opts.user.username}' with email "${opts.user.email}" & password "${opts.user.password}". Booking page 👉 http://localhost:3000/${opts.user.username}`
);
for (const rawData of opts.eventTypes) {
const eventTypeData: Prisma.EventTypeCreateArgs["data"] = { ...rawData };
for (const eventTypeInput of opts.eventTypes) {
const { _bookings: bookingInputs = [], ...eventTypeData } = eventTypeInput;
eventTypeData.userId = user.id;
eventTypeData.users = { connect: { id: user.id } };
@ -93,21 +49,48 @@ async function createUserAndEventType(opts: {
});
if (eventType) {
await prisma.eventType.update({
where: {
id: eventType.id,
},
data: eventTypeData,
});
} else {
await prisma.eventType.create({
data: eventTypeData,
});
console.log(
`\t📆 Event type ${eventTypeData.slug} already seems seeded - http://localhost:3000/${user.username}/${eventTypeData.slug}`
);
continue;
}
const { id } = await prisma.eventType.create({
data: eventTypeData,
});
console.log(
`\t📆 Event type ${eventTypeData.slug}, length ${eventTypeData.length}: http://localhost:3000/${user.username}/${eventTypeData.slug}`
`\t📆 Event type ${eventTypeData.slug}, length ${eventTypeData.length}min - http://localhost:3000/${user.username}/${eventTypeData.slug}`
);
for (const bookingInput of bookingInputs) {
await prisma.booking.create({
data: {
...bookingInput,
user: {
connect: {
email: opts.user.email,
},
},
attendees: {
create: {
email: opts.user.email,
name: opts.user.name,
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id,
},
},
confirmed: bookingInput.confirmed,
},
});
console.log(
`\t\t☎ Created booking ${bookingInput.title} at ${new Date(
bookingInput.startTime
).toLocaleDateString()}`
);
}
}
}
@ -170,6 +153,21 @@ async function main() {
title: "30min",
slug: "30min",
length: 30,
_bookings: [
{
uid: uuid(),
title: "30min",
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
},
{
uid: uuid(),
title: "30min",
startTime: dayjs().add(2, "day").toDate(),
endTime: dayjs().add(2, "day").add(30, "minutes").toDate(),
confirmed: false,
},
],
},
{
title: "60min",
@ -179,15 +177,6 @@ async function main() {
],
});
await createBookingForEventType({
title: "30min",
slug: "30min",
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(60, "minutes").toDate(),
uid: uuid(),
userEmail: "pro@example.com",
});
await createUserAndEventType({
user: {
email: "trial@example.com",