From c8d6c0dbdd8aec966662b4d0d4b8950501c2d904 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 24 May 2022 09:19:12 -0400 Subject: [PATCH] Add seats to event types (#2485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add seatsPerTimeSlot to event type schema * Add seats per time slot to event type form * Book event and render seats * Pass booking uid for seats * Disable requires confirmation if seats are enabled * Fix type errors * Update submodules * Fix type errors * Fix type errors * Fix duplicate string * Fix duplicate string * Fix schema and migration file * Fix render seats * Fix bookinguid typos * Remove console.log * Fix type error * Fix mobile formatting * Update apps/web/lib/hooks/useSlots.ts Co-authored-by: Omar López * Update apps/web/lib/hooks/useSlots.ts Co-authored-by: Omar López * Added translation for seats available text Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars Co-authored-by: alannnc --- .../web/components/booking/AvailableTimes.tsx | 48 +++++- .../booking/pages/AvailabilityPage.tsx | 1 + .../components/booking/pages/BookingPage.tsx | 16 ++ apps/web/components/ui/form/CheckboxField.tsx | 1 + apps/web/lib/hooks/useSlots.ts | 26 +++- apps/web/lib/slots.ts | 12 +- apps/web/lib/types/booking.ts | 1 + apps/web/lib/types/event-type.ts | 1 + apps/web/lib/types/schedule.ts | 8 + apps/web/pages/[user]/[type].tsx | 1 + apps/web/pages/[user]/book.tsx | 10 +- apps/web/pages/api/availability/[user].ts | 26 ++++ apps/web/pages/api/book/event.ts | 40 +++++ apps/web/pages/d/[link]/[slug].tsx | 1 + apps/web/pages/d/[link]/book.tsx | 1 + apps/web/pages/event-types/[type].tsx | 138 ++++++++++++++++++ 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 + packages/lib/defaultEvents.ts | 1 + .../migration.sql | 3 + packages/prisma/schema.prisma | 1 + 22 files changed, 326 insertions(+), 16 deletions(-) create mode 100644 packages/prisma/migrations/20220413173832_add_seats_to_event_type_model/migration.sql diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index a6c89b335c..855e49a038 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -29,6 +29,7 @@ type AvailableTimesProps = { username: string | null; }[]; schedulingType: SchedulingType | null; + seatsPerTimeSlot?: number | null; }; const AvailableTimes: FC = ({ @@ -44,6 +45,7 @@ const AvailableTimes: FC = ({ schedulingType, beforeBufferTime, afterBufferTime, + seatsPerTimeSlot, }) => { const { t, i18n } = useLocale(); const router = useRouter(); @@ -105,18 +107,48 @@ const AvailableTimes: FC = ({ bookingUrl.query.user = slot.users; } + // If event already has an attendee add booking id + if (slot.bookingUid) { + bookingUrl.query.bookingUid = slot.bookingUid; + } + return ( ); })} diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index ecca5bdeb2..d76aeee407 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -398,6 +398,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 853fcb0da9..72a64a6d1b 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -395,6 +395,7 @@ const BookingPage = ({ timeZone: timeZone(), language: i18n.language, rescheduleUid, + bookingUid: router.query.bookingUid as string, user: router.query.user, location: getLocationValue( booking.locationType ? booking : { ...booking, locationType: selectedLocation } @@ -468,6 +469,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} {t("seats_available")} +

+ )} {eventType?.description && (

diff --git a/apps/web/components/ui/form/CheckboxField.tsx b/apps/web/components/ui/form/CheckboxField.tsx index 9b06d4a925..f55468429a 100644 --- a/apps/web/components/ui/form/CheckboxField.tsx +++ b/apps/web/components/ui/form/CheckboxField.tsx @@ -30,6 +30,7 @@ const CheckboxField = forwardRef(

{ - 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,10 @@ 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())) { + break; + } busy.every((busyTime): boolean => { const startTime = dayjs(busyTime.start); const endTime = dayjs(busyTime.end); @@ -124,19 +132,21 @@ export const useSlots = (props: UseSlotsProps) => { const handleAvailableSlots = async (res: Response) => { const responseBody: AvailabilityUserResponse = await res.json(); + const { workingHours, busy, currentSeats } = responseBody; const times = getSlots({ frequency: slotInterval || eventLength, inviteeDate: date, - workingHours: responseBody.workingHours, + workingHours, minimumBookingNotice, eventLength, }); const filterTimeProps = { times, - busy: responseBody.busy, + busy, eventLength, beforeBufferTime, afterBufferTime, + currentSeats, }; const filteredTimes = getFilteredTimes(filterTimeProps); // temporary @@ -144,6 +154,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, + bookingUid: + currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toISOString())].uid, + }), })); }; diff --git a/apps/web/lib/slots.ts b/apps/web/lib/slots.ts index f7011edf47..fb8ac14bed 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 d4936dfdeb..6f879767c3 100644 --- a/apps/web/lib/types/booking.ts +++ b/apps/web/lib/types/booking.ts @@ -24,6 +24,7 @@ export type BookingCreateBody = { timeZone: string; user?: string | string[]; language: string; + bookingUid?: string; customInputs: { label: string; value: string | boolean }[]; metadata: { [key: string]: string; 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/lib/types/schedule.ts b/apps/web/lib/types/schedule.ts index ba5e74b45e..43537957f3 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 = { + uid: string; + startTime: string; + _count: { + attendees: number; + }; +}; diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 30add1786d..d46db6244e 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -100,6 +100,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 7b5aa8a7be..85eaf7430c 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -126,6 +126,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { price: true, currency: true, disableGuests: true, + seatsPerTimeSlot: true, users: { select: { id: true, @@ -176,8 +177,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { })[0]; let booking: GetBookingType | null = null; - if (context.query.rescheduleUid) { - booking = await getBooking(prisma, context.query.rescheduleUid as string); + if (context.query.rescheduleUid || context.query.bookingUid) { + booking = await getBooking( + prisma, + context.query.rescheduleUid + ? (context.query.rescheduleUid as string) + : (context.query.bookingUid as string) + ); } const dynamicNames = isDynamicGroupBooking diff --git a/apps/web/pages/api/availability/[user].ts b/apps/web/pages/api/availability/[user].ts index 324e5f12cc..e9ecc51068 100644 --- a/apps/web/pages/api/availability/[user].ts +++ b/apps/web/pages/api/availability/[user].ts @@ -50,6 +50,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) prisma.eventType.findUnique({ where: { id }, select: { + seatsPerTimeSlot: true, timeZone: true, schedule: { select: { @@ -107,9 +108,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: { + uid: 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 2de43e3ab9..06f4bc3509 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -188,6 +188,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => { metadata: true, destinationCalendar: true, hideCalendarNotes: true, + seatsPerTimeSlot: true, recurringEvent: true, }, }); @@ -294,6 +295,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.bookingUid) { + if (!eventType.seatsPerTimeSlot) + return res.status(404).json({ message: "Event type does not have seats" }); + + const booking = await prisma.booking.findUnique({ + where: { + uid: reqBody.bookingUid, + }, + 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: { + uid: reqBody.bookingUid, + }, + 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/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 4e77999653..6721a5c8c3 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -41,6 +41,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => periodCountCalendarDays: true, recurringEvent: true, schedulingType: true, + seatsPerTimeSlot: true, userId: true, schedule: { select: { diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx index ed99d6c288..52e8af2800 100644 --- a/apps/web/pages/d/[link]/book.tsx +++ b/apps/web/pages/d/[link]/book.tsx @@ -46,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { periodEndDate: true, metadata: true, periodCountCalendarDays: true, + seatsPerTimeSlot: true, price: true, currency: true, disableGuests: true, diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index ffaf0fd442..b9baf5bb0f 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -126,6 +126,7 @@ export type FormValues = { periodDays: number; periodCountCalendarDays: "1" | "0"; periodDates: { startDate: Date; endDate: Date }; + seatsPerTimeSlot: number | null; minimumBookingNotice: number; beforeBufferTime: number; afterBufferTime: number; @@ -316,6 +317,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"); @@ -1006,6 +1012,7 @@ const EventTypePage = (props: inferSSRProps) => { giphyThankYouPage, beforeBufferTime, afterBufferTime, + seatsPerTimeSlot, recurringEvent, locations, ...input @@ -1021,6 +1028,7 @@ const EventTypePage = (props: inferSSRProps) => { id: eventType.id, beforeEventBuffer: beforeBufferTime, afterEventBuffer: afterBufferTime, + seatsPerTimeSlot, metadata: { ...(smartContractAddress ? { smartContractAddress } : {}), ...(giphyThankYouPage ? { giphyThankYouPage } : {}), @@ -1410,6 +1418,8 @@ const EventTypePage = (props: inferSSRProps) => { label={t("opt_in_booking")} description={t("opt_in_booking_description")} defaultChecked={eventType.requiresConfirmation} + disabled={enableSeats} + checked={formMethods.watch("disableGuests")} onChange={(e) => { formMethods.setValue("requiresConfirmation", e?.target.checked); }} @@ -1436,6 +1446,9 @@ 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} + checked={formMethods.watch("disableGuests")} onChange={(e) => { formMethods.setValue("disableGuests", e?.target.checked); }} @@ -1739,6 +1752,130 @@ 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); + formMethods.setValue("requiresConfirmation", false); + } else { + setEnableSeats(false); + formMethods.setValue("seatsPerTimeSlot", null); + formMethods.setValue( + "requiresConfirmation", + eventType.requiresConfirmation + ); + formMethods.setValue("disableGuests", eventType.disableGuests); + } + }} + /> + )} + /> + + {enableSeats && ( +
+
+
+ { + const selectSeatsPerTimeSlotOptions = [ + { value: 2, label: "2" }, + { value: 3, label: "3" }, + { value: 4, label: "4" }, + { value: 5, label: "5" }, + { + value: -1, + isDisabled: !eventType.users.some((user) => user.plan === "PRO"), + label: ( +
+ 6 + + PRO +
+ ) as unknown as string, + }, + ]; + return ( + <> +
+
+ + +
+ )} +
+ + ); + }} + /> +
+
+
+ )} +
+
+ + formMethods={formMethods} eventType={eventType}> @@ -2219,6 +2356,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => price: true, currency: true, destinationCalendar: true, + seatsPerTimeSlot: true, }, }); diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 1034f403d8..5083749fba 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -76,6 +76,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 e3978abe1b..b3c496eb4c 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -50,6 +50,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 65a17c061c..ce8f29a79e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -767,7 +767,11 @@ "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", + "offer_seats": "Offer seats", + "offer_seats_description": "Offer seats to bookings (This disables guests)", + "seats_available": "Seats available", "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", + "booking_full": "No more seats available", "api_keys": "API Keys", "api_key_modal_subtitle": "API keys allow you to make API calls for your own account.", "api_keys_subtitle": "Generate API keys to use for accessing your own account.", diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index baa5fabe4b..18bb1da7d2 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -49,6 +49,7 @@ const commons = { price: 0, currency: "usd", schedulingType: SchedulingType.COLLECTIVE, + seatsPerTimeSlot: null, id: 0, metadata: { smartContractAddress: "", 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..301021d04c --- /dev/null +++ b/packages/prisma/migrations/20220413173832_add_seats_to_event_type_model/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "seatsPerTimeSlot" INTEGER; + diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index df719788c2..700bb09ab6 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -66,6 +66,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)