Add seats to event types (#2485)

* 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 <zomars@me.com>

* Update apps/web/lib/hooks/useSlots.ts

Co-authored-by: Omar López <zomars@me.com>

* Added translation for seats available text

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
Joe Au-Yeung 2022-05-24 09:19:12 -04:00 committed by GitHub
parent cc0ea19420
commit c8d6c0dbdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 326 additions and 16 deletions

View File

@ -29,6 +29,7 @@ type AvailableTimesProps = {
username: string | null;
}[];
schedulingType: SchedulingType | null;
seatsPerTimeSlot?: number | null;
};
const AvailableTimes: FC<AvailableTimesProps> = ({
@ -44,6 +45,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
schedulingType,
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
}) => {
const { t, i18n } = useLocale();
const router = useRouter();
@ -105,18 +107,48 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
bookingUrl.query.user = slot.users;
}
// If event already has an attendee add booking id
if (slot.bookingUid) {
bookingUrl.query.bookingUid = slot.bookingUid;
}
return (
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<a
{/* Current there is no way to disable Next.js Links */}
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
<div
className={classNames(
"text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
"text-primary-500 mb-2 block rounded-sm border bg-white py-4 font-medium opacity-25 dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 ",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">
{dayjs.tz(slot.time, timeZone()).format(timeFormat)}
</a>
</Link>
)}>
{slot.time.format(timeFormat)}
{seatsPerTimeSlot && <p className={`text-sm`}>{t("booking_full")}</p>}
</div>
) : (
<Link href={bookingUrl}>
<a
className={classNames(
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">
{dayjs.tz(slot.time, timeZone()).format(timeFormat)}
{seatsPerTimeSlot && (
<p
className={`${
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
? "text-rose-600"
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-emerald-400"
} text-sm`}>
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
{seatsPerTimeSlot} {t("seats_available")}
</p>
)}
</a>
</Link>
)}
</div>
);
})}

View File

@ -398,6 +398,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
schedulingType={eventType.schedulingType ?? null}
beforeBufferTime={eventType.beforeEventBuffer}
afterBufferTime={eventType.afterEventBuffer}
seatsPerTimeSlot={eventType.seatsPerTimeSlot}
/>
)}
</div>

View File

@ -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 = ({
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
{eventType.title}
</h1>
{eventType.seatsPerTimeSlot && (
<p
className={`${
booking && booking.attendees.length / 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")}
</p>
)}
{eventType?.description && (
<p className="text-bookinglight mb-2 dark:text-white">
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />

View File

@ -30,6 +30,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
<div className="flex h-5 items-center">
<input
{...rest}
disabled={rest.disabled}
ref={ref}
type="checkbox"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300"

View File

@ -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 { CurrentSeats, TimeRange, WorkingHours } 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[];
bookingUid?: string;
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,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,
}),
}));
};

View File

@ -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");

View File

@ -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;

View File

@ -24,6 +24,7 @@ export type AdvancedOptions = {
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
customInputs?: EventTypeCustomInput[];
timeZone?: string;
seatsPerTimeSlot: number;
destinationCalendar?: {
userId?: number;
eventTypeId?: number;

View File

@ -16,3 +16,11 @@ export type WorkingHours = {
startTime: number;
endTime: number;
};
export type CurrentSeats = {
uid: string;
startTime: string;
_count: {
attendees: number;
};
};

View File

@ -100,6 +100,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
timeZone: true,
metadata: true,
slotInterval: true,
seatsPerTimeSlot: true,
users: {
select: {
avatar: true,

View File

@ -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

View File

@ -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,
});
}

View File

@ -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) {

View File

@ -41,6 +41,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodCountCalendarDays: true,
recurringEvent: true,
schedulingType: true,
seatsPerTimeSlot: true,
userId: true,
schedule: {
select: {

View File

@ -46,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodEndDate: true,
metadata: true,
periodCountCalendarDays: true,
seatsPerTimeSlot: true,
price: true,
currency: true,
disableGuests: true,

View File

@ -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<typeof getServerSideProps>) => {
);
const [tokensList, setTokensList] = useState<Array<Token>>([]);
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<typeof getServerSideProps>) => {
giphyThankYouPage,
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
recurringEvent,
locations,
...input
@ -1021,6 +1028,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id: eventType.id,
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
seatsPerTimeSlot,
metadata: {
...(smartContractAddress ? { smartContractAddress } : {}),
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
@ -1410,6 +1418,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
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<typeof getServerSideProps>) => {
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<typeof getServerSideProps>) => {
</div>
</div>
</div>
<>
<hr className="border-neutral-200" />
<div className="block flex-col sm:flex">
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
render={() => (
<CheckboxField
id="seats"
name="seats"
label={t("offer_seats")}
description={t("offer_seats_description")}
defaultChecked={!!eventType.seatsPerTimeSlot}
onChange={(e) => {
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 && (
<div className="block sm:flex">
<div className="mt-2 inline-flex w-full space-x-2 md:ml-48">
<div className="w-full">
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
render={() => {
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: (
<div className="flex flex-row justify-between">
<span>6 +</span>
<Badge variant="default">PRO</Badge>
</div>
) as unknown as string,
},
];
return (
<>
<div className="block sm:flex">
<div className="flex-auto">
<label
htmlFor="beforeBufferTime"
className="mb-2 flex text-sm font-medium text-neutral-700">
Number of seats per booking
</label>
<Select
isSearchable={false}
classNamePrefix="react-select"
className="react-select-container focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-auto rounded-sm border border-gray-300 sm:text-sm "
onChange={(val) => {
if (val!.value === -1) {
formMethods.setValue(
"seatsPerTimeSlot",
defaultSeatsInput
);
setInputSeatNumber(true);
} else {
setInputSeatNumber(false);
formMethods.setValue("seatsPerTimeSlot", val!.value);
}
}}
defaultValue={{
value: eventType.seatsPerTimeSlot || defaultSeats,
label: `${eventType.seatsPerTimeSlot || defaultSeats}`,
}}
options={selectSeatsPerTimeSlotOptions}
/>
</div>
{inputSeatNumber && (
<div className="flex-auto md:ml-5">
<label
htmlFor="beforeBufferTime"
className="mb-2 flex text-sm font-medium text-neutral-700">
Enter number of seats
</label>
<input
type="number"
className="focus:border-primary-500 focus:ring-primary-500 py- block w-20 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
placeholder={`${defaultSeatsInput}`}
{...formMethods.register("seatsPerTimeSlot", {
valueAsNumber: true,
min: defaultSeatsInput,
})}
defaultValue={defaultSeatsInput}
/>
</div>
)}
</div>
</>
);
}}
/>
</div>
</div>
</div>
)}
</div>
<hr className="border-neutral-200" />
</>
<SuccessRedirectEdit<typeof formMethods>
formMethods={formMethods}
eventType={eventType}></SuccessRedirectEdit>
@ -2219,6 +2356,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
price: true,
currency: true,
destinationCalendar: true,
seatsPerTimeSlot: true,
},
});

View File

@ -76,6 +76,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
timeZone: true,
slotInterval: true,
metadata: true,
seatsPerTimeSlot: true,
schedule: {
select: {
timeZone: true,

View File

@ -50,6 +50,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
price: true,
currency: true,
metadata: true,
seatsPerTimeSlot: true,
team: {
select: {
slug: true,

View File

@ -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.",

View File

@ -49,6 +49,7 @@ const commons = {
price: 0,
currency: "usd",
schedulingType: SchedulingType.COLLECTIVE,
seatsPerTimeSlot: null,
id: 0,
metadata: {
smartContractAddress: "",

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "seatsPerTimeSlot" INTEGER;

View File

@ -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)