From a5b538230625e35c103d6d836179a3b684e3fb61 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 11 Jul 2023 11:11:08 -0400 Subject: [PATCH] fix: seats regression [CAL-2041] ## What does this PR do? - Passes the proper seats data in the new booker component between states and to the backend Fixes #9779 Fixes #9749 Fixes #7967 Fixes #9942 ## Type of change - Bug fix (non-breaking change which fixes an issue) ## How should this be tested? **As the organizer** - Create a seated event type - Book at least 2 seats - Reschedule the booking - All attendees should be moved to the new booking - Cancel the booking - The event should be cancelled for all attendees **As an attendee** - [x] Book a seated event - [x] Reschedule that booking to an empty slot - [x] The attendee should be moved to that new slot - [x] Reschedule onto a booking with occupied seats - [x] The attendees should be merged - [x] On that slot reschedule all attendees to a new slot - [x] The former booking should be deleted - [x] As the attendee cancel the booking - [x] Only that attendee should be removed ## Mandatory Tasks - [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected. ## Checklist --- .../web/components/booking/AvailableTimes.tsx | 31 ++-- apps/web/pages/[user]/[type].tsx | 28 ++-- apps/web/pages/d/[link]/[slug].tsx | 8 +- apps/web/pages/reschedule/[uid].tsx | 19 +-- apps/web/pages/team/[slug]/[type].tsx | 6 +- apps/web/pages/team/[slug]/[type]/embed.tsx | 107 ++++++++++++- apps/web/playwright/booking-pages.e2e.ts | 77 ++++++++- apps/web/playwright/booking-seats.e2e.ts | 64 ++++++-- apps/web/playwright/lib/testUtils.ts | 31 +--- packages/core/getBusyTimes.ts | 133 +++++++++++----- packages/core/getUserAvailability.ts | 3 + packages/features/bookings/Booker/Booker.tsx | 23 ++- .../Booker/components/AvailableTimeSlots.tsx | 30 +++- .../BookEventForm/BookEventForm.tsx | 47 +++--- .../bookings/Booker/components/EventMeta.tsx | 44 +++++- packages/features/bookings/Booker/store.ts | 62 +++++--- packages/features/bookings/Booker/types.ts | 2 +- .../bookings/Booker/utils/query-param.ts | 7 +- .../bookings/components/AvailableTimes.tsx | 46 +++--- .../booking-to-mutation-input-mapper.tsx | 6 + packages/features/bookings/lib/get-booking.ts | 71 ++++++++- .../features/bookings/lib/handleNewBooking.ts | 147 +++++++++--------- .../pages/settings/appearance.tsx | 1 - .../features/ee/teams/components/TeamList.tsx | 2 +- .../filters/components/TeamsFilter.tsx | 4 +- packages/lib/date-ranges.ts | 9 +- packages/prisma/zod-utils.ts | 6 +- .../routers/viewer/bookings/get.handler.ts | 5 + 28 files changed, 723 insertions(+), 296 deletions(-) diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index a86363e840..a861c9a3df 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -66,6 +66,10 @@ const AvailableTimes: FC = ({ ); const reserveSlot = (slot: Slot) => { + // Prevent double clicking + if (reserveSlotMutation.isLoading || reserveSlotMutation.isSuccess) { + return; + } reserveSlotMutation.mutate({ slotUtcStartDate: slot.time, eventTypeId, @@ -132,8 +136,20 @@ const AvailableTimes: FC = ({ let slotFull, notEnoughSeats; if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot; - if (slot.attendees && bookingAttendees && seatsPerTimeSlot) + if (slot.attendees && bookingAttendees && seatsPerTimeSlot) { notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot; + } + + const isHalfFull = + slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5; + const isNearlyFull = + slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83; + + const colorClass = isNearlyFull + ? "text-rose-600" + : isHalfFull + ? "text-yellow-500" + : "text-emerald-400"; return (
@@ -144,7 +160,9 @@ const AvailableTimes: FC = ({ className={classNames( "text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25", brand === "#fff" || brand === "#ffffff" ? "" : "" - )}> + )} + data-testid="time" + data-disabled="true"> {dayjs(slot.time).tz(timeZone()).format(timeFormat)} {notEnoughSeats ? (

{t("not_enough_seats")}

@@ -165,14 +183,7 @@ const AvailableTimes: FC = ({ data-disabled="false"> {dayjs(slot.time).tz(timeZone()).format(timeFormat)} {!!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", { diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index e12997efcb..509ec9a83c 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -3,7 +3,7 @@ import { z } from "zod"; import { Booker } from "@calcom/atoms"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; -import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking"; +import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { classNames } from "@calcom/lib"; @@ -17,20 +17,20 @@ import PageWrapper from "@components/PageWrapper"; export type PageProps = inferSSRProps; -export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) { +export default function Type({ slug, user, booking, away, isBrandingHidden, rescheduleUid }: PageProps) { const isEmbed = typeof window !== "undefined" && window?.isEmbed?.(); return (

@@ -42,7 +42,7 @@ Type.PageWrapper = PageWrapper; async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { const { user: usernames, type: slug } = paramsSchema.parse(context.params); - const { rescheduleUid } = context.query; + const { rescheduleUid, bookingUid } = context.query; const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); @@ -66,7 +66,9 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { let booking: GetBookingType | null = null; if (rescheduleUid) { - booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`); + booking = await getBookingForReschedule(`${rescheduleUid}`); + } else if (bookingUid) { + booking = await getBookingForSeatedEvent(`${bookingUid}`); } // We use this to both prefetch the query on the server, @@ -88,6 +90,8 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { trpcState: ssr.dehydrate(), isBrandingHidden: false, themeBasis: null, + bookingUid: bookingUid ? `${bookingUid}` : null, + rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null, }, }; } @@ -95,7 +99,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { async function getUserPageProps(context: GetServerSidePropsContext) { const { user: usernames, type: slug } = paramsSchema.parse(context.params); const username = usernames[0]; - const { rescheduleUid } = context.query; + const { rescheduleUid, bookingUid } = context.query; const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); const { ssrInit } = await import("@server/lib/ssr"); @@ -105,8 +109,8 @@ async function getUserPageProps(context: GetServerSidePropsContext) { username, organization: isValidOrgDomain ? { - slug: currentOrgDomain, - } + slug: currentOrgDomain, + } : null, }, select: { @@ -123,7 +127,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) { let booking: GetBookingType | null = null; if (rescheduleUid) { - booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`); + booking = await getBookingForReschedule(`${rescheduleUid}`); + } else if (bookingUid) { + booking = await getBookingForSeatedEvent(`${bookingUid}`); } // We use this to both prefetch the query on the server, @@ -145,6 +151,8 @@ async function getUserPageProps(context: GetServerSidePropsContext) { trpcState: ssr.dehydrate(), isBrandingHidden: user?.hideBranding, themeBasis: username, + bookingUid: bookingUid ? `${bookingUid}` : null, + rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null, }, }; } diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 0924235969..707f94d907 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -3,7 +3,7 @@ import { z } from "zod"; import { Booker } from "@calcom/atoms"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; -import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking"; +import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import slugify from "@calcom/lib/slugify"; @@ -13,7 +13,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps"; import PageWrapper from "@components/PageWrapper"; -export type PageProps = inferSSRProps; +type PageProps = inferSSRProps; export default function Type({ slug, user, booking, away, isBrandingHidden, isTeamEvent }: PageProps) { return ( @@ -27,7 +27,7 @@ export default function Type({ slug, user, booking, away, isBrandingHidden, isTe let booking: GetBookingType | null = null; if (rescheduleUid) { - booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`); + booking = await getBookingForReschedule(`${rescheduleUid}`); } // We use this to both prefetch the query on the server, diff --git a/apps/web/pages/team/[slug]/[type]/embed.tsx b/apps/web/pages/team/[slug]/[type]/embed.tsx index 4061755eb5..d9b6590e98 100644 --- a/apps/web/pages/team/[slug]/[type]/embed.tsx +++ b/apps/web/pages/team/[slug]/[type]/embed.tsx @@ -1,9 +1,104 @@ -import withEmbedSsr from "@lib/withEmbedSsr"; +import type { GetServerSidePropsContext } from "next"; +import { z } from "zod"; -import { getServerSideProps as _getServerSideProps } from "../[type]"; +import { Booker } from "@calcom/atoms"; +import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; +import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; +import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; +import { classNames } from "@calcom/lib"; +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; -export { default } from "../[type]"; +import type { inferSSRProps } from "@lib/types/inferSSRProps"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -export const getServerSideProps = withEmbedSsr(_getServerSideProps); +import PageWrapper from "@components/PageWrapper"; + +export type PageProps = inferSSRProps; + +export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) { + const isEmbed = typeof window !== "undefined" && window?.isEmbed?.(); + return ( +
+ + +
+ ); +} + +Type.PageWrapper = PageWrapper; + +const paramsSchema = z.object({ + type: z.string().transform((s) => slugify(s)), + slug: z.string().transform((s) => slugify(s)), +}); + +// Booker page fetches a tiny bit of data server side: +// 1. Check if team exists, to show 404 +// 2. If rescheduling, get the booking details +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params); + const { rescheduleUid } = context.query; + const { ssrInit } = await import("@server/lib/ssr"); + const ssr = await ssrInit(context); + + const team = await prisma.team.findFirst({ + where: { + slug: teamSlug, + }, + select: { + id: true, + hideBranding: true, + }, + }); + + if (!team) { + return { + notFound: true, + }; + } + + let booking: GetBookingType | null = null; + if (rescheduleUid) { + booking = await getBookingForReschedule(`${rescheduleUid}`); + } + + // We use this to both prefetch the query on the server, + // as well as to check if the event exist, so we c an show a 404 otherwise. + const eventData = await ssr.viewer.public.event.fetch({ + username: teamSlug, + eventSlug: meetingSlug, + isTeamEvent: true, + }); + + if (!eventData) { + return { + notFound: true, + }; + } + + return { + props: { + booking, + away: false, + user: teamSlug, + teamId: team.id, + slug: meetingSlug, + trpcState: ssr.dehydrate(), + isBrandingHidden: team?.hideBranding, + themeBasis: null, + }, + }; +}; diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 48fa60c3e6..179cbf88de 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -6,7 +6,6 @@ import { bookOptinEvent, bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, - selectSecondAvailableTimeSlotNextMonth, testEmail, testName, } from "./lib/testUtils"; @@ -26,12 +25,11 @@ test.describe("free user", () => { await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + // save booking url const bookingUrl: string = page.url(); - // book same time spot twice - await bookTimeSlot(page); - // Make sure we're navigated to the success page await expect(page.locator("[data-testid=success-page]")).toBeVisible(); @@ -41,8 +39,7 @@ test.describe("free user", () => { // book same time spot again await bookTimeSlot(page); - // check for error message - await expect(page.locator("[data-testid=booking-fail]")).toBeVisible(); + await expect(page.locator("[data-testid=booking-fail]")).toBeVisible({ timeout: 1000 }); }); }); @@ -75,8 +72,8 @@ test.describe("pro user", () => { const bookingId = url.searchParams.get("rescheduleUid"); return !!bookingId; }); - await selectSecondAvailableTimeSlotNextMonth(page); - // --- fill form + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[data-testid="confirm-reschedule-button"]').click(); await page.waitForURL((url) => { return url.pathname.startsWith("/booking"); @@ -151,4 +148,68 @@ test.describe("pro user", () => { await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email); }); }); + + test("Time slots should be reserved when selected", async ({ context, page }) => { + await page.click('[data-testid="event-type-link"]'); + + const initialUrl = page.url(); + await selectFirstAvailableTimeSlotNextMonth(page); + const pageTwo = await context.newPage(); + await pageTwo.goto(initialUrl); + await pageTwo.waitForURL(initialUrl); + + await pageTwo.waitForSelector('[data-testid="event-type-link"]'); + const eventTypeLink = pageTwo.locator('[data-testid="event-type-link"]').first(); + await eventTypeLink.click(); + + await pageTwo.waitForLoadState("networkidle"); + await pageTwo.locator('[data-testid="incrementMonth"]').waitFor(); + await pageTwo.click('[data-testid="incrementMonth"]'); + await pageTwo.waitForLoadState("networkidle"); + await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor(); + await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); + + // 9:30 should be the first available time slot + await pageTwo.locator('[data-testid="time"]').nth(0).waitFor(); + const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0); + // Find text inside the element + const firstSlotAvailableText = await firstSlotAvailable.innerText(); + expect(firstSlotAvailableText).toContain("9:30"); + }); + + test("Time slots are not reserved when going back via Cancel button on Event Form", async ({ + context, + page, + }) => { + const initialUrl = page.url(); + await page.waitForSelector('[data-testid="event-type-link"]'); + const eventTypeLink = page.locator('[data-testid="event-type-link"]').first(); + await eventTypeLink.click(); + await selectFirstAvailableTimeSlotNextMonth(page); + + const pageTwo = await context.newPage(); + await pageTwo.goto(initialUrl); + await pageTwo.waitForURL(initialUrl); + + await pageTwo.waitForSelector('[data-testid="event-type-link"]'); + const eventTypeLinkTwo = pageTwo.locator('[data-testid="event-type-link"]').first(); + await eventTypeLinkTwo.click(); + + await page.locator('[data-testid="back"]').waitFor(); + await page.click('[data-testid="back"]'); + + await pageTwo.waitForLoadState("networkidle"); + await pageTwo.locator('[data-testid="incrementMonth"]').waitFor(); + await pageTwo.click('[data-testid="incrementMonth"]'); + await pageTwo.waitForLoadState("networkidle"); + await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor(); + await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); + + await pageTwo.locator('[data-testid="time"]').nth(0).waitFor(); + const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0); + + // Find text inside the element + const firstSlotAvailableText = await firstSlotAvailable.innerText(); + expect(firstSlotAvailableText).toContain("9:00"); + }); }); diff --git a/apps/web/playwright/booking-seats.e2e.ts b/apps/web/playwright/booking-seats.e2e.ts index 3e718a3c72..8a93bec558 100644 --- a/apps/web/playwright/booking-seats.e2e.ts +++ b/apps/web/playwright/booking-seats.e2e.ts @@ -82,24 +82,56 @@ test.describe("Booking with Seats", () => { ], }); await page.goto(`/${user.username}/${slug}`); - await selectFirstAvailableTimeSlotNextMonth(page); - const bookingUrl = page.url(); + let bookingUrl = ""; + await test.step("Attendee #1 can book a seated event time slot", async () => { - await page.goto(bookingUrl); + await selectFirstAvailableTimeSlotNextMonth(page); await bookTimeSlot(page); await expect(page.locator("[data-testid=success-page]")).toBeVisible(); }); await test.step("Attendee #2 can book the same seated event time slot", async () => { - await page.goto(bookingUrl); + await page.goto(`/${user.username}/${slug}`); + await selectFirstAvailableTimeSlotNextMonth(page); + + await page.waitForURL(/bookingUid/); + bookingUrl = page.url(); await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" }); await expect(page.locator("[data-testid=success-page]")).toBeVisible(); }); - await test.step("Attendee #3 cannot book the same seated event time slot", async () => { + await test.step("Attendee #3 cannot click on the same seated event time slot", async () => { + await page.goto(`/${user.username}/${slug}`); + + await page.click('[data-testid="incrementMonth"]'); + + // TODO: Find out why the first day is always booked on tests + await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); + await expect(page.locator('[data-testid="time"][data-disabled="true"]')).toBeVisible(); + }); + await test.step("Attendee #3 cannot book the same seated event time slot accessing via url", async () => { await page.goto(bookingUrl); + await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" }); await expect(page.locator("[data-testid=success-page]")).toBeHidden(); }); + + await test.step("User owner should have only 1 booking with 3 attendees", async () => { + // Make sure user owner has only 1 booking with 3 attendees + const bookings = await prisma.booking.findMany({ + where: { eventTypeId: user.eventTypes.find((e) => e.slug === slug)?.id }, + select: { + id: true, + attendees: { + select: { + id: true, + }, + }, + }, + }); + + expect(bookings).toHaveLength(1); + expect(bookings[0].attendees).toHaveLength(2); + }); }); test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => { @@ -134,7 +166,7 @@ test.describe("Booking with Seats", () => { await page.locator('[data-testid="confirm_cancel"]').click(); await page.waitForLoadState("networkidle"); - await expect(page).toHaveURL(/.*booking/); + await expect(page).toHaveURL(/\/booking\/.*/); const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]'); await expect(cancelledHeadline).toBeVisible(); @@ -158,9 +190,8 @@ test.describe("Booking with Seats", () => { await page.locator('[data-testid="cancel"]').click(); await page.fill('[data-testid="cancel_reason"]', "Double booked!"); await page.locator('[data-testid="confirm_cancel"]').click(); - await page.waitForLoadState("networkidle"); - await expect(page).toHaveURL(/.*booking/); + await expect(page).toHaveURL(/\/booking\/.*/); const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]'); await expect(cancelledHeadline).toBeVisible(); @@ -216,9 +247,10 @@ test.describe("Reschedule for booking with seats", () => { await page.locator('[data-testid="confirm-reschedule-button"]').click(); - await page.waitForLoadState("networkidle"); + // should wait for URL but that path starts with booking/ + await page.waitForURL(/\/booking\/.*/); - await expect(page).toHaveURL(/.*booking/); + await expect(page).toHaveURL(/\/booking\/.*/); // Should expect new booking to be created for John Third const newBooking = await prisma.booking.findFirst({ @@ -281,7 +313,7 @@ test.describe("Reschedule for booking with seats", () => { await page.locator('[data-testid="confirm-reschedule-button"]').click(); - await page.waitForURL(/.*booking/); + await page.waitForURL(/\/booking\/.*/); await page.goto(`/reschedule/${references[1].referenceUid}`); @@ -290,7 +322,7 @@ test.describe("Reschedule for booking with seats", () => { await page.locator('[data-testid="confirm-reschedule-button"]').click(); // Using waitForUrl here fails the assertion `expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);` probably because waitForUrl is considered complete before waitForNavigation and till that time the booking is not cancelled - await page.waitForNavigation({ url: /.*booking/ }); + await page.waitForURL(/\/booking\/.*/); // Should expect old booking to be cancelled const oldBooking = await prisma.booking.findFirst({ @@ -340,9 +372,7 @@ test.describe("Reschedule for booking with seats", () => { await page.locator('[data-testid="confirm_cancel"]').click(); - await page.waitForLoadState("networkidle"); - - await expect(page).toHaveURL(/.*booking/); + await expect(page).toHaveURL(/\/booking\/.*/); // Should expect old booking to be cancelled const updatedBooking = await prisma.booking.findFirst({ @@ -446,7 +476,7 @@ test.describe("Reschedule for booking with seats", () => { await page.waitForLoadState("networkidle"); - await expect(page).toHaveURL(/.*booking/); + await expect(page).toHaveURL(/\/booking\/.*/); await page.goto( `/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[1].referenceUid}` @@ -457,7 +487,7 @@ test.describe("Reschedule for booking with seats", () => { await page.waitForLoadState("networkidle"); - await expect(page).toHaveURL(/.*booking/); + await expect(page).toHaveURL(/\/booking\/.*/); }); test("Should book with seats and hide attendees info from showAttendees true", async ({ diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index f9a999880c..ab0e7d92db 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -79,38 +79,21 @@ export async function waitFor(fn: () => Promise | unknown, opts: { time export async function selectFirstAvailableTimeSlotNextMonth(page: Page) { // Let current month dates fully render. - // There is a bug where if we don't let current month fully render and quickly click go to next month, current month get's rendered - // This doesn't seem to be replicable with the speed of a person, only during automation. - // It would also allow correct snapshot to be taken for current month. - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(1000); await page.click('[data-testid="incrementMonth"]'); - // @TODO: Find a better way to make test wait for full month change render to end - // so it can click up on the right day, also when resolve remove other todos + // Waiting for full month increment - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(1000); - // TODO: Find out why the first day is always booked on tests - await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click(); - await page.locator('[data-testid="time"][data-disabled="false"]').nth(0).click(); + await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); + + await page.locator('[data-testid="time"]').nth(0).click(); } export async function selectSecondAvailableTimeSlotNextMonth(page: Page) { // Let current month dates fully render. - // There is a bug where if we don't let current month fully render and quickly click go to next month, current month get's rendered - // This doesn't seem to be replicable with the speed of a person, only during automation. - // It would also allow correct snapshot to be taken for current month. - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(1000); await page.click('[data-testid="incrementMonth"]'); - // @TODO: Find a better way to make test wait for full month change render to end - // so it can click up on the right day, also when resolve remove other todos - // Waiting for full month increment - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(1000); - // TODO: Find out why the first day is always booked on tests + await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click(); - await page.locator('[data-testid="time"][data-disabled="false"]').nth(1).click(); + + await page.locator('[data-testid="time"]').nth(0).click(); } async function bookEventOnThisPage(page: Page) { diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index 8dfcc749ec..e08921413a 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -2,6 +2,7 @@ import type { Credential } from "@prisma/client"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import dayjs from "@calcom/dayjs"; +import { subtract } from "@calcom/lib/date-ranges"; import logger from "@calcom/lib/logger"; import { performance } from "@calcom/lib/server/perfObserver"; import prisma from "@calcom/prisma"; @@ -20,6 +21,7 @@ export async function getBusyTimes(params: { afterEventBuffer?: number; endTime: string; selectedCalendars: SelectedCalendar[]; + seatedEvent?: boolean; }) { const { credentials, @@ -32,6 +34,7 @@ export async function getBusyTimes(params: { beforeEventBuffer, afterEventBuffer, selectedCalendars, + seatedEvent, } = params; logger.silly( `Checking Busy time from Cal Bookings in range ${startTime} to ${endTime} for input ${JSON.stringify({ @@ -74,42 +77,70 @@ export async function getBusyTimes(params: { }, }; // Find bookings that block this user from hosting further bookings. - const busyTimes: EventBusyDetails[] = await prisma.booking - .findMany({ - where: { - OR: [ - // User is primary host (individual events, or primary organizer) - { - ...sharedQuery, - userId, - }, - // The current user has a different booking at this time he/she attends - { - ...sharedQuery, - attendees: { - some: { - email: user.email, - }, + const bookings = await prisma.booking.findMany({ + where: { + OR: [ + // User is primary host (individual events, or primary organizer) + { + ...sharedQuery, + userId, + }, + // The current user has a different booking at this time he/she attends + { + ...sharedQuery, + attendees: { + some: { + email: user.email, }, }, - ], - }, - select: { - id: true, - startTime: true, - endTime: true, - title: true, - eventType: { - select: { - id: true, - afterEventBuffer: true, - beforeEventBuffer: true, - }, + }, + ], + }, + select: { + id: true, + startTime: true, + endTime: true, + title: true, + eventType: { + select: { + id: true, + afterEventBuffer: true, + beforeEventBuffer: true, + seatsPerTimeSlot: true, }, }, - }) - .then((bookings) => - bookings.map(({ startTime, endTime, title, id, eventType }) => ({ + ...(seatedEvent && { + _count: { + select: { + seatsReferences: true, + }, + }, + }), + }, + }); + + const bookingSeatCountMap: { [x: string]: number } = {}; + const busyTimes = bookings.reduce( + (aggregate: EventBusyDetails[], { id, startTime, endTime, eventType, title, ...rest }) => { + if (rest._count?.seatsReferences) { + const bookedAt = dayjs(startTime).utc().format() + "<>" + dayjs(endTime).utc().format(); + bookingSeatCountMap[bookedAt] = bookingSeatCountMap[bookedAt] || 0; + bookingSeatCountMap[bookedAt]++; + // Seat references on the current event are non-blocking until the event is fully booked. + if ( + // there are still seats available. + bookingSeatCountMap[bookedAt] < (eventType?.seatsPerTimeSlot || 1) && + // and this is the seated event, other event types should be blocked. + eventTypeId === eventType?.id + ) { + // then we do not add the booking to the busyTimes. + return aggregate; + } + // if it does get blocked at this point; we remove the bookingSeatCountMap entry + // doing this allows using the map later to remove the ranges from calendar busy times. + delete bookingSeatCountMap[bookedAt]; + } + aggregate.push({ start: dayjs(startTime) .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute") .toDate(), @@ -118,8 +149,12 @@ export async function getBusyTimes(params: { .toDate(), title, source: `eventType-${eventType?.id}-booking-${id}`, - })) - ); + }); + return aggregate; + }, + [] + ); + logger.silly(`Busy Time from Cal Bookings ${JSON.stringify(busyTimes)}`); performance.mark("prismaBookingGetEnd"); performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd"); @@ -139,15 +174,29 @@ export async function getBusyTimes(params: { endConnectedCalendarsGet - startConnectedCalendarsGet } ms for user ${username}` ); - busyTimes.push( - ...calendarBusyTimes.map((value) => ({ + + const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => { + const [start, end] = key.split("<>"); + return { + start: dayjs(start), + end: dayjs(end), + }; + }); + + const result = subtract( + calendarBusyTimes.map((value) => ({ ...value, - end: dayjs(value.end) - .add(beforeEventBuffer || 0, "minute") - .toDate(), - start: dayjs(value.start) - .subtract(afterEventBuffer || 0, "minute") - .toDate(), + end: dayjs(value.end), + start: dayjs(value.start), + })), + openSeatsDateRanges + ); + + busyTimes.push( + ...result.map((busyTime) => ({ + ...busyTime, + start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(), + end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(), })) ); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 574bbe1e37..044e3478b0 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -12,6 +12,7 @@ import { checkBookingLimit } from "@calcom/lib/server"; import { performance } from "@calcom/lib/server/perfObserver"; import { getTotalBookingDuration } from "@calcom/lib/server/queries"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils"; import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar"; @@ -93,6 +94,7 @@ export const getCurrentSeats = (eventTypeId: number, dateFrom: Dayjs, dateTo: Da gte: dateFrom.format(), lte: dateTo.format(), }, + status: BookingStatus.ACCEPTED, }, select: { uid: true, @@ -172,6 +174,7 @@ export async function getUserAvailability( beforeEventBuffer, afterEventBuffer, selectedCalendars: user.selectedCalendars, + seatedEvent: !!eventType?.seatsPerTimeSlot, }); let bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({ diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 4edf8889d3..cec7296583 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -34,7 +34,7 @@ const BookerComponent = ({ username, eventSlug, month, - rescheduleBooking, + bookingData, hideBranding = false, isTeamEvent, }: BookerProps) => { @@ -44,6 +44,8 @@ const BookerComponent = ({ const StickyOnDesktop = isMobile ? "div" : StickyBox; const rescheduleUid = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null; + const bookingUid = + typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null; const event = useEvent(); const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow); @@ -63,6 +65,11 @@ const BookerComponent = ({ (state) => [state.selectedTimeslot, state.setSelectedTimeslot], shallow ); + // const seatedEventData = useBookerStore((state) => state.seatedEventData); + const [seatedEventData, setSeatedEventData] = useBookerStore( + (state) => [state.seatedEventData, state.setSeatedEventData], + shallow + ); const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop; const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings; @@ -85,7 +92,8 @@ const BookerComponent = ({ month, eventId: event?.data?.id, rescheduleUid, - rescheduleBooking, + bookingUid, + bookingData, layout: defaultLayout, isTeamEvent, }); @@ -196,7 +204,14 @@ const BookerComponent = ({ className="border-subtle sticky top-0 ml-[-1px] h-full p-6 md:w-[var(--booker-main-width)] md:border-l" {...fadeInLeft} visible={bookerState === "booking" && !shouldShowFormInDialog}> - setSelectedTimeslot(null)} /> + { + setSelectedTimeslot(null); + if (seatedEventData.bookingUid) { + setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined }); + } + }} + /> diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index 5c22097300..e2144cf4e8 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -11,7 +11,7 @@ import { useEvent, useScheduleForEvent } from "../utils/event"; type AvailableTimeSlotsProps = { extraDays?: number; limitHeight?: boolean; - seatsPerTimeslot?: number | null; + seatsPerTimeSlot?: number | null; }; /** @@ -21,16 +21,34 @@ type AvailableTimeSlotsProps = { * will also fetch the next `extraDays` days and show multiple days * in columns next to each other. */ -export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeslot }: AvailableTimeSlotsProps) => { +export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeSlot }: AvailableTimeSlotsProps) => { const selectedDate = useBookerStore((state) => state.selectedDate); const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); + const setSeatedEventData = useBookerStore((state) => state.setSeatedEventData); const event = useEvent(); const date = selectedDate || dayjs().format("YYYY-MM-DD"); const containerRef = useRef(null); - const onTimeSelect = (time: string) => { + const onTimeSelect = ( + time: string, + attendees: number, + seatsPerTimeSlot?: number | null, + bookingUid?: string + ) => { setSelectedTimeslot(time); + if (seatsPerTimeSlot) { + setSeatedEventData({ + seatsPerTimeSlot, + attendees, + bookingUid, + }); + + if (seatsPerTimeSlot && seatsPerTimeSlot - attendees > 1) { + return; + } + } + if (!event.data) return; }; @@ -80,11 +98,11 @@ export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeslot }: ))}
diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index e3205f9191..4c2084d686 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -43,7 +43,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation({ trpc: { context: { skipBatch: true } }, }); - const releaseSlotMutation = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation({ + const removeSelectedSlot = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation({ trpc: { context: { skipBatch: true } }, }); const router = useRouter(); @@ -51,7 +51,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { const { timezone } = useTimePreferences(); const errorRef = useRef(null); const rescheduleUid = useBookerStore((state) => state.rescheduleUid); - const rescheduleBooking = useBookerStore((state) => state.rescheduleBooking); + const bookingData = useBookerStore((state) => state.bookingData); const eventSlug = useBookerStore((state) => state.eventSlug); const duration = useBookerStore((state) => state.selectedDuration); const timeslot = useBookerStore((state) => state.selectedTimeslot); @@ -59,33 +59,39 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { const username = useBookerStore((state) => state.username); const formValues = useBookerStore((state) => state.formValues); const setFormValues = useBookerStore((state) => state.setFormValues); - const isRescheduling = !!rescheduleUid && !!rescheduleBooking; + const seatedEventData = useBookerStore((state) => state.seatedEventData); + const isRescheduling = !!rescheduleUid && !!bookingData; const event = useEvent(); const eventType = event.data; const reserveSlot = () => { - if (eventType) { + if (eventType?.id && timeslot && (duration || eventType?.length)) { reserveSlotMutation.mutate({ slotUtcStartDate: dayjs(timeslot).utc().format(), - eventTypeId: eventType.id, + eventTypeId: eventType?.id, slotUtcEndDate: dayjs(timeslot) .utc() - .add(duration || eventType.length, "minutes") + .add(duration || eventType?.length, "minutes") .format(), }); } }; + useEffect(() => { reserveSlot(); - const interval = setInterval(reserveSlot, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000); + + const interval = setInterval(() => { + reserveSlot(); + }, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000); + return () => { if (eventType) { - releaseSlotMutation.mutate(); - clearInterval(interval); + removeSelectedSlot.mutate(); } + clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventType]); + }, [eventType?.id, timeslot]); const defaultValues = useMemo(() => { if (Object.keys(formValues).length) return formValues; @@ -108,8 +114,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { }); const defaultUserValues = { - email: rescheduleUid ? rescheduleBooking?.attendees[0].email : parsedQuery["email"] || "", - name: rescheduleUid ? rescheduleBooking?.attendees[0].name : parsedQuery["name"] || "", + email: rescheduleUid ? bookingData?.attendees[0].email : parsedQuery["email"] || "", + name: rescheduleUid ? bookingData?.attendees[0].name : parsedQuery["name"] || "", }; if (!isRescheduling) { @@ -133,10 +139,10 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { return defaults; } - if (!rescheduleBooking || !rescheduleBooking.attendees.length) { + if ((!rescheduleUid && !bookingData) || !bookingData.attendees.length) { return {}; } - const primaryAttendee = rescheduleBooking.attendees[0]; + const primaryAttendee = bookingData.attendees[0]; if (!primaryAttendee) { return {}; } @@ -148,7 +154,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { const responses = eventType.bookingFields.reduce((responses, field) => { return { ...responses, - [field.name]: rescheduleBooking.responses[field.name], + [field.name]: bookingData.responses[field.name], }; }, {}); defaults.responses = { @@ -157,7 +163,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { email: defaultUserValues.email, }; return defaults; - }, [eventType?.bookingFields, formValues, isRescheduling, rescheduleBooking, rescheduleUid]); + }, [eventType?.bookingFields, formValues, isRescheduling, bookingData, rescheduleUid]); const bookingFormSchema = z .object({ @@ -209,7 +215,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { email: bookingForm.getValues("responses.email"), eventTypeSlug: eventSlug, seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null, - formerTime: rescheduleBooking?.startTime ? dayjs(rescheduleBooking.startTime).toString() : undefined, + formerTime: + isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined, }; return bookingSuccessRedirect({ @@ -238,7 +245,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { allRemainingBookings: true, email: bookingForm.getValues("responses.email"), eventTypeSlug: eventSlug, - formerTime: rescheduleBooking?.startTime ? dayjs(rescheduleBooking.startTime).toString() : undefined, + formerTime: + isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined, }; return bookingSuccessRedirect({ @@ -292,6 +300,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { timeZone: timezone, language: i18n.language, rescheduleUid: rescheduleUid || undefined, + bookingUid: (bookingData && bookingData.uid) || seatedEventData?.bookingUid || undefined, username: username || "", metadata: Object.keys(router.query) .filter((key) => key.startsWith("metadata")) @@ -358,7 +367,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { )}
{!!onCancel && ( - )} diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index 21ed7d5c4a..d89044ed0a 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -1,5 +1,6 @@ import { m } from "framer-motion"; import dynamic from "next/dynamic"; +import { shallow } from "zustand/shallow"; import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { EventDetails, EventMembers, EventMetaSkeleton, EventTitle } from "@calcom/features/bookings"; @@ -7,7 +8,7 @@ import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/ import { useTimePreferences } from "@calcom/features/bookings/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { Calendar, Globe } from "@calcom/ui/components/icon"; +import { Calendar, Globe, User } from "@calcom/ui/components/icon"; import { fadeInUp } from "../config"; import { useBookerStore } from "../store"; @@ -23,7 +24,12 @@ export const EventMeta = () => { const selectedDuration = useBookerStore((state) => state.selectedDuration); const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); const bookerState = useBookerStore((state) => state.state); - const rescheduleBooking = useBookerStore((state) => state.rescheduleBooking); + const bookingData = useBookerStore((state) => state.bookingData); + const rescheduleUid = useBookerStore((state) => state.rescheduleUid); + const [seatedEventData, setSeatedEventData] = useBookerStore( + (state) => [state.seatedEventData, state.setSeatedEventData], + shallow + ); const { i18n, t } = useLocale(); const { data: event, isLoading } = useEvent(); const embedUiConfig = useEmbedUiConfig(); @@ -33,6 +39,21 @@ export const EventMeta = () => { if (hideEventTypeDetails) { return null; } + // If we didn't pick a time slot yet, we load bookingData via SSR so bookingData should be set + // Otherwise we load seatedEventData from useBookerStore + const bookingSeatAttendeesQty = seatedEventData?.attendees || bookingData?.attendees.length; + const eventTotalSeats = seatedEventData?.seatsPerTimeSlot || event?.seatsPerTimeSlot; + + const isHalfFull = + bookingSeatAttendeesQty && eventTotalSeats && bookingSeatAttendeesQty / eventTotalSeats >= 0.5; + const isNearlyFull = + bookingSeatAttendeesQty && eventTotalSeats && bookingSeatAttendeesQty / eventTotalSeats >= 0.83; + + const colorClass = isNearlyFull + ? "text-rose-600" + : isHalfFull + ? "text-yellow-500" + : "text-bookinghighlight"; return (
@@ -51,13 +72,13 @@ export const EventMeta = () => { )}
- {rescheduleBooking && ( + {rescheduleUid && bookingData && ( {t("former_time")}
{ )}
+ {bookerState === "booking" && eventTotalSeats && bookingSeatAttendeesQty ? ( + +
+

+ {bookingSeatAttendeesQty ? eventTotalSeats - bookingSeatAttendeesQty : eventTotalSeats} /{" "} + {eventTotalSeats}{" "} + {t("seats_available", { + count: bookingSeatAttendeesQty + ? eventTotalSeats - bookingSeatAttendeesQty + : eventTotalSeats, + })} +

+
+
+ ) : null}
)} diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 7e0ffca01f..0fdeba4e34 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -17,12 +17,20 @@ type StoreInitializeType = { username: string; eventSlug: string; // Month can be undefined if it's not passed in as a prop. - month?: string; eventId: number | undefined; - rescheduleUid: string | null; - rescheduleBooking: GetBookingType | null | undefined; layout: BookerLayout; + month?: string; + bookingUid?: string | null; isTeamEvent?: boolean; + bookingData?: GetBookingType | null | undefined; + rescheduleUid?: string | null; + seatReferenceUid?: string; +}; + +type SeatedEventData = { + seatsPerTimeSlot?: number | null; + attendees?: number; + bookingUid?: string; }; export type BookerStore = { @@ -73,12 +81,13 @@ export type BookerStore = { recurringEventCount: number | null; setRecurringEventCount(count: number | null): void; /** - * If booking is being rescheduled, both the ID as well as - * the current booking details are passed in. The `rescheduleBooking` + * If booking is being rescheduled or it has seats, it receives a rescheduleUid or bookingUid + * the current booking details are passed in. The `bookingData` * object is something that's fetched server side. */ rescheduleUid: string | null; - rescheduleBooking: GetBookingType | null; + bookingUid: string | null; + bookingData: GetBookingType | null; /** * Method called by booker component to set initial data. */ @@ -96,6 +105,8 @@ export type BookerStore = { * both the slug and the event slug. */ isTeamEvent: boolean; + seatedEventData: SeatedEventData; + setSeatedEventData: (seatedEventData: SeatedEventData) => void; }; /** @@ -153,13 +164,23 @@ export const useBookerStore = create((set, get) => ({ get().setSelectedDate(null); }, isTeamEvent: false, + seatedEventData: { + seatsPerTimeSlot: undefined, + attendees: undefined, + bookingUid: undefined, + }, + setSeatedEventData: (seatedEventData: SeatedEventData) => { + set({ seatedEventData }); + updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null"); + }, initialize: ({ username, eventSlug, month, eventId, rescheduleUid = null, - rescheduleBooking = null, + bookingUid = null, + bookingData = null, layout, isTeamEvent, }: StoreInitializeType) => { @@ -171,7 +192,8 @@ export const useBookerStore = create((set, get) => ({ get().month === month && get().eventId === eventId && get().rescheduleUid === rescheduleUid && - get().rescheduleBooking?.responses.email === rescheduleBooking?.responses.email && + get().bookingUid === bookingUid && + get().bookingData?.responses.email === bookingData?.responses.email && get().layout === layout ) return; @@ -180,7 +202,8 @@ export const useBookerStore = create((set, get) => ({ eventSlug, eventId, rescheduleUid, - rescheduleBooking, + bookingUid, + bookingData, layout: layout || BookerLayouts.MONTH_VIEW, isTeamEvent: isTeamEvent || false, // Preselect today's date in week / column view, since they use this to show the week title. @@ -193,7 +216,7 @@ export const useBookerStore = create((set, get) => ({ // if the user reschedules a booking right after the confirmation page. // In that case the time would still be store in the store, this way we // force clear this. - if (rescheduleBooking) set({ selectedTimeslot: null }); + if (rescheduleUid && bookingData) set({ selectedTimeslot: null }); if (month) set({ month }); removeQueryParam("layout"); }, @@ -204,8 +227,9 @@ export const useBookerStore = create((set, get) => ({ }, recurringEventCount: null, setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }), - rescheduleBooking: null, rescheduleUid: null, + bookingData: null, + bookingUid: null, selectedTimeslot: getQueryParam("slot") || null, setSelectedTimeslot: (selectedTimeslot: string | null) => { set({ selectedTimeslot }); @@ -223,7 +247,7 @@ export const useInitializeBookerStore = ({ month, eventId, rescheduleUid = null, - rescheduleBooking = null, + bookingData = null, layout, isTeamEvent, }: StoreInitializeType) => { @@ -235,19 +259,9 @@ export const useInitializeBookerStore = ({ month, eventId, rescheduleUid, - rescheduleBooking, + bookingData, layout, isTeamEvent, }); - }, [ - initializeStore, - username, - eventSlug, - month, - eventId, - rescheduleUid, - rescheduleBooking, - layout, - isTeamEvent, - ]); + }, [initializeStore, username, eventSlug, month, eventId, rescheduleUid, bookingData, layout, isTeamEvent]); }; diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index dc932adfce..7f0ff27516 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -40,7 +40,7 @@ export interface BookerProps { * api to fetch this data. Therefore rescheduling a booking currently is not possible * within the atom (i.e. without a server side component). */ - rescheduleBooking?: GetBookingType; + bookingData?: GetBookingType; /** * If this boolean is passed, we will only check team events with this slug and event slug. * If it's not passed, we will first query a generic user event, and only if that doesn't exist diff --git a/packages/features/bookings/Booker/utils/query-param.ts b/packages/features/bookings/Booker/utils/query-param.ts index e068cc51f9..da098e5f7f 100644 --- a/packages/features/bookings/Booker/utils/query-param.ts +++ b/packages/features/bookings/Booker/utils/query-param.ts @@ -2,7 +2,12 @@ export const updateQueryParam = (param: string, value: string | number) => { if (typeof window === "undefined") return; const url = new URL(window.location.href); - url.searchParams.set(param, `${value}`); + if (value === "" || value === "null") { + url.searchParams.delete(param); + } else { + url.searchParams.set(param, `${value}`); + } + window.history.pushState({}, "", url.href); }; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index a4f97a0513..aca9a8557c 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -17,9 +17,14 @@ import { TimeFormatToggle } from "./TimeFormatToggle"; type AvailableTimesProps = { date: Dayjs; slots: Slots[string]; - onTimeSelect: (time: string) => void; - seatsPerTimeslot?: number | null; - showTimeformatToggle?: boolean; + onTimeSelect: ( + time: string, + attendees: number, + seatsPerTimeSlot?: number | null, + bookingUid?: string + ) => void; + seatsPerTimeSlot?: number | null; + showTimeFormatToggle?: boolean; className?: string; }; @@ -27,13 +32,14 @@ export const AvailableTimes = ({ date, slots, onTimeSelect, - seatsPerTimeslot, - showTimeformatToggle = true, + seatsPerTimeSlot, + showTimeFormatToggle = true, className, }: AvailableTimesProps) => { const { t, i18n } = useLocale(); const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]); - const hasTimeSlots = !!seatsPerTimeslot; + const bookingData = useBookerStore((state) => state.bookingData); + const hasTimeSlots = !!seatsPerTimeSlot; const [layout] = useBookerStore((state) => [state.layout], shallow); const isColumnView = layout === BookerLayouts.COLUMN_VIEW; const isMonthView = layout === BookerLayouts.MONTH_VIEW; @@ -60,7 +66,7 @@ export const AvailableTimes = ({ - {showTimeformatToggle && ( + {showTimeFormatToggle && (
@@ -70,22 +76,27 @@ export const AvailableTimes = ({ {!slots.length && (
-

+

{t("all_booked_today")}

)} {slots.map((slot) => { - const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeslot); + const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot); + const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5; + const isNearlyFull = + slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83; + + const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400"; return (
- diff --git a/packages/lib/date-ranges.ts b/packages/lib/date-ranges.ts index 01de5c478e..5580a6e2fb 100644 --- a/packages/lib/date-ranges.ts +++ b/packages/lib/date-ranges.ts @@ -163,10 +163,13 @@ function getIntersection(range1: DateRange, range2: DateRange) { return null; } -export function subtract(sourceRanges: DateRange[], excludedRanges: DateRange[]) { +export function subtract( + sourceRanges: (DateRange & { [x: string]: unknown })[], + excludedRanges: DateRange[] +) { const result: DateRange[] = []; - for (const { start: sourceStart, end: sourceEnd } of sourceRanges) { + for (const { start: sourceStart, end: sourceEnd, ...passThrough } of sourceRanges) { let currentStart = sourceStart; const overlappingRanges = excludedRanges.filter( @@ -183,7 +186,7 @@ export function subtract(sourceRanges: DateRange[], excludedRanges: DateRange[]) } if (sourceEnd.isAfter(currentStart)) { - result.push({ start: currentStart, end: sourceEnd }); + result.push({ start: currentStart, end: sourceEnd, ...passThrough }); } } diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 6d4de34cb7..e8cef4e3c5 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -582,6 +582,6 @@ export const unlockedManagedEventTypeProps = { // I introduced this refinement(to be used with z.email()) as a short term solution until we upgrade to a zod // version that will include updates in the above PR. export const emailSchemaRefinement = (value: string) => { - const emailRegex = /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i - return emailRegex.test(value) -} + const emailRegex = /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i; + return emailRegex.test(value); +}; diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index a3e90a2a8f..c5c3cbbf2b 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -176,6 +176,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { recurringEvent: true, currency: true, metadata: true, + seatsShowAttendees: true, team: { select: { name: true, @@ -284,6 +285,10 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { ); const bookings = bookingsQuery.map((booking) => { + // If seats are enabled and the event is not set to show attendees, filter out attendees that are not the current user + if (booking.seatsReferences.length && !booking.eventType?.seatsShowAttendees) { + booking.attendees = booking.attendees.filter((attendee) => attendee.email === user.email); + } return { ...booking, eventType: {