From fcf51af1e5a156d908162f78cf96879fa8d8a862 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 13 Apr 2022 13:40:14 -0400 Subject: [PATCH 1/3] Add seatsPerTimeSlot to event type schema --- .../migration.sql | 6 ++++++ packages/prisma/schema.prisma | 7 +++++++ 2 files changed, 13 insertions(+) create mode 100644 packages/prisma/migrations/20220413173832_add_seats_to_event_type_model/migration.sql diff --git a/packages/prisma/migrations/20220413173832_add_seats_to_event_type_model/migration.sql b/packages/prisma/migrations/20220413173832_add_seats_to_event_type_model/migration.sql new file mode 100644 index 0000000000..020f7399f0 --- /dev/null +++ b/packages/prisma/migrations/20220413173832_add_seats_to_event_type_model/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "BookingLimitType" AS ENUM ('month', 'day', 'week'); + +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "seatsPerTimeSlot" INTEGER; + diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3153a2b249..196d9f38bc 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -29,6 +29,12 @@ enum PeriodType { RANGE @map("range") } +enum BookingLimitType { + MONTH @map("month") + DAY @map("day") + WEEK @map("week") +} + model EventType { id Int @id @default(autoincrement()) /// @zod.nonempty() @@ -63,6 +69,7 @@ model EventType { minimumBookingNotice Int @default(120) beforeEventBuffer Int @default(0) afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? schedulingType SchedulingType? schedule Schedule? price Int @default(0) From 2011b455df3a3499bdfa96f47bbc7fafffba1dda Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 13 Apr 2022 14:23:05 -0400 Subject: [PATCH 2/3] Add seats per time slot to event type form --- apps/web/components/ui/form/CheckboxField.tsx | 3 +- apps/web/lib/types/event-type.ts | 1 + apps/web/pages/event-types/[type].tsx | 129 ++++++++++++++++++ apps/web/public/static/locales/en/common.json | 4 +- 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/apps/web/components/ui/form/CheckboxField.tsx b/apps/web/components/ui/form/CheckboxField.tsx index a16799575b..e8d5177fc9 100644 --- a/apps/web/components/ui/form/CheckboxField.tsx +++ b/apps/web/components/ui/form/CheckboxField.tsx @@ -19,8 +19,9 @@ const CheckboxField = forwardRef(({ label, description,
diff --git a/apps/web/lib/types/event-type.ts b/apps/web/lib/types/event-type.ts index ec8442efb4..a7186812cf 100644 --- a/apps/web/lib/types/event-type.ts +++ b/apps/web/lib/types/event-type.ts @@ -24,6 +24,7 @@ export type AdvancedOptions = { availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] }; customInputs?: EventTypeCustomInput[]; timeZone?: string; + seatsPerTimeSlot: number; destinationCalendar?: { userId?: number; eventTypeId?: number; diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index e7cab3a7eb..bd6b15d98f 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -260,6 +260,11 @@ const EventTypePage = (props: inferSSRProps) => { ); const [tokensList, setTokensList] = useState>([]); + const defaultSeats = 2; + const defaultSeatsInput = 6; + const [enableSeats, setEnableSeats] = useState(!!eventType.seatsPerTimeSlot); + const [inputSeatNumber, setInputSeatNumber] = useState(eventType.seatsPerTimeSlot! >= defaultSeatsInput); + const periodType = PERIOD_TYPES.find((s) => s.type === eventType.periodType) || PERIOD_TYPES.find((s) => s.type === "UNLIMITED"); @@ -486,6 +491,7 @@ const EventTypePage = (props: inferSSRProps) => { minimumBookingNotice: number; beforeBufferTime: number; afterBufferTime: number; + seatsPerTimeSlot: number | null; slotInterval: number | null; destinationCalendar: { integration: string; @@ -913,6 +919,7 @@ const EventTypePage = (props: inferSSRProps) => { smartContractAddress, beforeBufferTime, afterBufferTime, + seatsPerTimeSlot, locations, ...input } = values; @@ -928,6 +935,7 @@ const EventTypePage = (props: inferSSRProps) => { id: eventType.id, beforeEventBuffer: beforeBufferTime, afterEventBuffer: afterBufferTime, + seatsPerTimeSlot, metadata: smartContractAddress ? { smartContractAddress, @@ -1330,6 +1338,8 @@ const EventTypePage = (props: inferSSRProps) => { label={t("disable_guests")} description={t("disable_guests_description")} defaultChecked={eventType.disableGuests} + // If we have seats per booking then we need to disable guests + disabled={enableSeats} onChange={(e) => { formMethods.setValue("disableGuests", e?.target.checked); }} @@ -1571,6 +1581,124 @@ const EventTypePage = (props: inferSSRProps) => {
+ + <> +
+
+ ( + { + if (e?.target.checked) { + setEnableSeats(true); + // Want to disable individuals from taking multiple seats + formMethods.setValue("seatsPerTimeSlot", defaultSeats); + formMethods.setValue("disableGuests", true); + } else { + formMethods.setValue("seatsPerTimeSlot", null); + setEnableSeats(false); + } + }} + /> + )} + /> + + {enableSeats && ( +
+
+
+ { + const selectSeatsPerTimeSlotOptions = [ + { value: 2, label: "2" }, + { value: 3, label: "3" }, + { value: 4, label: "4" }, + { value: 5, label: "5" }, + { + value: -1, + isDisabled: false, + label: ( +
+ 6 + + PRO +
+ ), + }, + ]; + return ( + <> +
+
+ + +
+ )} +
+ + ); + }} + /> +
+
+
+ )} +
+
+ + formMethods={formMethods} eventType={eventType}> @@ -1986,6 +2114,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => price: true, currency: true, destinationCalendar: true, + seatsPerTimeSlot: true, }, }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 71dc697c85..80029cc17d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -720,5 +720,7 @@ "external_redirect_url": "https://example.com/redirect-to-my-success-page", "redirect_url_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.", "duplicate": "Duplicate", - "you_can_manage_your_schedules": "You can manage your schedules on the Availability page." + "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", + "offer_seats": "Offer seats", + "offer_seats_description": "Offer seats to bookings (This disables guests)" } From a70f01b3cb0fbddae96b3cb3a4139e8f5451d757 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 13 Apr 2022 15:42:23 -0400 Subject: [PATCH 3/3] Book event and render seats --- .../web/components/booking/AvailableTimes.tsx | 46 ++++++++++++++++--- .../booking/pages/AvailabilityPage.tsx | 1 + .../components/booking/pages/BookingPage.tsx | 16 +++++++ apps/web/lib/hooks/useSlots.ts | 27 +++++++++-- apps/web/lib/slots.ts | 12 ++++- apps/web/lib/types/booking.ts | 1 + apps/web/lib/types/schedule.ts | 8 ++++ apps/web/pages/[user]/[type].tsx | 1 + apps/web/pages/[user]/book.tsx | 7 ++- apps/web/pages/api/availability/[user].ts | 26 +++++++++++ apps/web/pages/api/book/event.ts | 40 ++++++++++++++++ apps/web/pages/team/[slug]/[type].tsx | 1 + apps/web/pages/team/[slug]/book.tsx | 1 + apps/web/public/static/locales/en/common.json | 4 +- 14 files changed, 175 insertions(+), 16 deletions(-) diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index bedb05a633..d420cf3877 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -27,6 +27,7 @@ type AvailableTimesProps = { username: string | null; }[]; schedulingType: SchedulingType | null; + seatsPerTimeSlot?: number | null; }; const AvailableTimes: FC = ({ @@ -41,6 +42,7 @@ const AvailableTimes: FC = ({ schedulingType, beforeBufferTime, afterBufferTime, + seatsPerTimeSlot, }) => { const { t, i18n } = useLocale(); const router = useRouter(); @@ -100,18 +102,48 @@ const AvailableTimes: FC = ({ bookingUrl.query.user = slot.users; } + // If event already has an attendee add booking id + if (slot.bookingId) { + bookingUrl.query.bookingId = slot.bookingId; + } + return ( ); })} diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 9a29f2793f..807410a82f 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -282,6 +282,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage schedulingType={eventType.schedulingType ?? null} beforeBufferTime={eventType.beforeEventBuffer} afterBufferTime={eventType.afterEventBuffer} + seatsPerTimeSlot={eventType.seatsPerTimeSlot} /> )} diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 3dd4a421d1..04c2ecac1a 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -258,6 +258,7 @@ const BookingPage = ({ timeZone: timeZone(), language: i18n.language, rescheduleUid, + bookingId: parseInt(router.query.bookingId as string), user: router.query.user, location: getLocationValue( booking.locationType ? booking : { ...booking, locationType: selectedLocation } @@ -321,6 +322,21 @@ const BookingPage = ({

{eventType.title}

+ {eventType.seatsPerTimeSlot && ( +

= 0.5 + ? "text-rose-600" + : booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33 + ? "text-yellow-500" + : "text-emerald-400" + } mb-2`}> + {booking + ? eventType.seatsPerTimeSlot - booking.attendees.length + : eventType.seatsPerTimeSlot}{" "} + / {eventType.seatsPerTimeSlot} Seats available +

+ )}

{eventType.length} {t("minutes")} diff --git a/apps/web/lib/hooks/useSlots.ts b/apps/web/lib/hooks/useSlots.ts index a54e31e599..12f0312d99 100644 --- a/apps/web/lib/hooks/useSlots.ts +++ b/apps/web/lib/hooks/useSlots.ts @@ -6,7 +6,7 @@ import { stringify } from "querystring"; import { useEffect, useState } from "react"; import getSlots from "@lib/slots"; -import { TimeRange, WorkingHours } from "@lib/types/schedule"; +import { TimeRange, WorkingHours, CurrentSeats } from "@lib/types/schedule"; dayjs.extend(isBetween); dayjs.extend(utc); @@ -15,11 +15,14 @@ type AvailabilityUserResponse = { busy: TimeRange[]; timeZone: string; workingHours: WorkingHours[]; + currentSeats?: CurrentSeats[]; }; type Slot = { time: Dayjs; users?: string[]; + bookingId?: number; + attendees?: number; }; type UseSlotsProps = { @@ -40,10 +43,11 @@ type getFilteredTimesProps = { eventLength: number; beforeBufferTime: number; afterBufferTime: number; + currentSeats?: CurrentSeats[]; }; export const getFilteredTimes = (props: getFilteredTimesProps) => { - const { times, busy, eventLength, beforeBufferTime, afterBufferTime } = props; + const { times, busy, eventLength, beforeBufferTime, afterBufferTime, currentSeats } = props; const finalizationTime = times[times.length - 1]?.add(eventLength, "minutes"); // Check for conflicts for (let i = times.length - 1; i >= 0; i -= 1) { @@ -56,6 +60,11 @@ export const getFilteredTimes = (props: getFilteredTimesProps) => { const slotStartTime = times[i]; const slotEndTime = times[i].add(eventLength, "minutes"); const slotStartTimeWithBeforeBuffer = times[i].subtract(beforeBufferTime, "minutes"); + // If the event has seats then see if there is already a booking (want to show full bookings as well) + if (currentSeats?.some((booking) => booking.startTime === slotStartTime.toISOString())) { + console.log("This triggered"); + break; + } busy.every((busyTime): boolean => { const startTime = dayjs(busyTime.start); const endTime = dayjs(busyTime.end); @@ -177,19 +186,21 @@ export const useSlots = (props: UseSlotsProps) => { const handleAvailableSlots = async (res: Response) => { const responseBody: AvailabilityUserResponse = await res.json(); + const { workingHours, currentSeats, busy } = responseBody; const times = getSlots({ frequency: slotInterval || eventLength, inviteeDate: date, - workingHours: responseBody.workingHours, + workingHours: workingHours, minimumBookingNotice, eventLength, }); const filterTimeProps = { times, - busy: responseBody.busy, + busy: busy, eventLength, beforeBufferTime, afterBufferTime, + currentSeats: currentSeats, }; const filteredTimes = getFilteredTimes(filterTimeProps); // temporary @@ -197,6 +208,14 @@ export const useSlots = (props: UseSlotsProps) => { return filteredTimes.map((time) => ({ time, users: [user], + // Conditionally add the attendees and booking id to slots object if there is already a booking during that time + ...(currentSeats?.some((booking) => booking.startTime === time.toISOString()) && { + attendees: + currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toISOString())]._count + .attendees, + bookingId: + currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toISOString())].id, + }), })); }; diff --git a/apps/web/lib/slots.ts b/apps/web/lib/slots.ts index 2ed82d762a..8ee68ca2ed 100644 --- a/apps/web/lib/slots.ts +++ b/apps/web/lib/slots.ts @@ -4,7 +4,7 @@ import isToday from "dayjs/plugin/isToday"; import utc from "dayjs/plugin/utc"; import { getWorkingHours } from "./availability"; -import { WorkingHours } from "./types/schedule"; +import { WorkingHours, CurrentSeats } from "./types/schedule"; dayjs.extend(isToday); dayjs.extend(utc); @@ -16,6 +16,7 @@ export type GetSlots = { workingHours: WorkingHours[]; minimumBookingNotice: number; eventLength: number; + currentSeats?: CurrentSeats[]; }; export type WorkingHoursTimeFrame = { startTime: number; endTime: number }; @@ -42,7 +43,14 @@ const splitAvailableTime = ( return result; }; -const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours, eventLength }: GetSlots) => { +const getSlots = ({ + inviteeDate, + frequency, + minimumBookingNotice, + workingHours, + eventLength, + currentSeats, +}: GetSlots) => { // current date in invitee tz const startDate = dayjs().add(minimumBookingNotice, "minute"); const startOfDay = dayjs.utc().startOf("day"); diff --git a/apps/web/lib/types/booking.ts b/apps/web/lib/types/booking.ts index 061226ea2c..96e0f28eeb 100644 --- a/apps/web/lib/types/booking.ts +++ b/apps/web/lib/types/booking.ts @@ -24,6 +24,7 @@ export type BookingCreateBody = { user?: string | string[]; language: string; customInputs: { label: string; value: string }[]; + bookingId?: number; metadata: { [key: string]: string; }; diff --git a/apps/web/lib/types/schedule.ts b/apps/web/lib/types/schedule.ts index ba5e74b45e..1abdb58d4a 100644 --- a/apps/web/lib/types/schedule.ts +++ b/apps/web/lib/types/schedule.ts @@ -16,3 +16,11 @@ export type WorkingHours = { startTime: number; endTime: number; }; + +export type CurrentSeats = { + id: number; + startTime: string; + _count: { + attendees: number; + }; +}; diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 557b5139d8..5b6db8693f 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -81,6 +81,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => timeZone: true, metadata: true, slotInterval: true, + seatsPerTimeSlot: true, users: { select: { avatar: true, diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index 3966bcd984..1cf9b32b3b 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -100,6 +100,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { price: true, currency: true, disableGuests: true, + seatsPerTimeSlot: true, users: { select: { username: true, @@ -150,7 +151,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { async function getBooking() { return prisma.booking.findFirst({ where: { - uid: asStringOrThrow(context.query.rescheduleUid), + // Find booking for reschedule or taking a seat + ...(context.query.rescheduleUid && { uid: asStringOrThrow(context.query.rescheduleUid) }), + ...(context.query.bookingId && { id: parseInt(context.query.bookingId as string) }), }, select: { description: true, @@ -167,7 +170,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { type Booking = Prisma.PromiseReturnType; let booking: Booking | null = null; - if (context.query.rescheduleUid) { + if (context.query.rescheduleUid || context.query.bookingId) { booking = await getBooking(); } diff --git a/apps/web/pages/api/availability/[user].ts b/apps/web/pages/api/availability/[user].ts index 96ce33f5ef..4f5af42455 100644 --- a/apps/web/pages/api/availability/[user].ts +++ b/apps/web/pages/api/availability/[user].ts @@ -52,6 +52,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) prisma.eventType.findUnique({ where: { id }, select: { + seatsPerTimeSlot: true, timeZone: true, schedule: { select: { @@ -109,9 +110,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) (eventType?.availability.length ? eventType.availability : currentUser.availability) ); + /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab + current bookings with a seats event type and display them on the calendar, even if they are full */ + let currentSeats; + if (eventType?.seatsPerTimeSlot) { + currentSeats = await prisma.booking.findMany({ + where: { + eventTypeId: eventTypeId, + startTime: { + gte: dateFrom.format(), + lte: dateTo.format(), + }, + }, + select: { + id: true, + startTime: true, + _count: { + select: { + attendees: true, + }, + }, + }, + }); + } + res.status(200).json({ busy: bufferedBusyTimes, timeZone, workingHours, + currentSeats, }); } diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index ec99dfcac2..6164086281 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -213,6 +213,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => { metadata: true, destinationCalendar: true, hideCalendarNotes: true, + seatsPerTimeSlot: true, }, }); }; @@ -308,6 +309,45 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return g; }); + // For seats, if the booking already exists then we want to add the new attendee to the existing booking + if (reqBody.bookingId) { + if (!eventType.seatsPerTimeSlot) + return res.status(404).json({ message: "Event type does not have seats" }); + + const booking = await prisma.booking.findUnique({ + where: { + id: reqBody.bookingId, + }, + include: { + attendees: true, + }, + }); + if (!booking) return res.status(404).json({ message: "Booking not found" }); + + if (eventType.seatsPerTimeSlot <= booking.attendees.length) + return res.status(409).json({ message: "Booking seats are full" }); + + if (booking.attendees.some((attendee) => attendee.email === invitee[0].email)) + return res.status(409).json({ message: "Already signed up for time slot" }); + + await prisma.booking.update({ + where: { + id: reqBody.bookingId, + }, + data: { + attendees: { + create: { + email: invitee[0].email, + name: invitee[0].name, + timeZone: invitee[0].timeZone, + locale: invitee[0].language.locale, + }, + }, + }, + }); + return res.status(201).json(booking); + } + const teamMemberPromises = eventType.schedulingType === SchedulingType.COLLECTIVE ? users.slice(1).map(async function (user) { diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index e8bf00398e..4e979c52b5 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -72,6 +72,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => timeZone: true, slotInterval: true, metadata: true, + seatsPerTimeSlot: true, schedule: { select: { timeZone: true, diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index 442c8c9e0b..63404f2ba0 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -47,6 +47,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { price: true, currency: true, metadata: true, + seatsPerTimeSlot: true, team: { select: { slug: true, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 80029cc17d..8a0ec6eb05 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -722,5 +722,7 @@ "duplicate": "Duplicate", "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", "offer_seats": "Offer seats", - "offer_seats_description": "Offer seats to bookings (This disables guests)" + "offer_seats_description": "Offer seats to bookings (This disables guests)", + "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", + "booking_full": "No more seats available" }