fix: seats regression [CAL-2041]

## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Passes the proper seats data in the new booker component between states and to the backend

Fixes #9779
Fixes #9749
Fixes #7967 
Fixes #9942 

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Type of change

<!-- Please delete bullets that are not relevant. -->

- Bug fix (non-breaking change which fixes an issue)

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->

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

<!-- Please remove all the irrelevant bullets to your PR -->
pull/8818/head^2
Joe Au-Yeung 2023-07-11 11:11:08 -04:00 committed by GitHub
parent c2fceb2e0f
commit a5b5382306
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 723 additions and 296 deletions

View File

@ -66,6 +66,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
);
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<AvailableTimesProps> = ({
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 (
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
@ -144,7 +160,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
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 ? (
<p className="text-sm">{t("not_enough_seats")}</p>
@ -165,14 +183,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
data-disabled="false">
{dayjs(slot.time).tz(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`}>
<p className={`${colorClass} text-sm`}>
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
{seatsPerTimeSlot}{" "}
{t("seats_available", {

View File

@ -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<typeof getServerSideProps>;
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 (
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
rescheduleUid={rescheduleUid ?? undefined}
hideBranding={isBrandingHidden}
/>
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
/>
@ -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,
},
};
}

View File

@ -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<typeof getServerSideProps>;
type PageProps = inferSSRProps<typeof getServerSideProps>;
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
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent={isTeamEvent}
@ -100,7 +100,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
booking = await getBookingForReschedule(`${rescheduleUid}`);
}
const isTeamEvent = !!hashedLink.eventType?.team?.id;

View File

@ -12,14 +12,11 @@ export default function Type() {
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { uid: bookingId } = z
const { uid: bookingId, seatReferenceUid } = z
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
.parse(context.query);
let seatReferenceUid;
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
if (uid) {
seatReferenceUid = bookingId;
}
const booking = await prisma.booking.findUnique({
where: {
uid,
@ -39,6 +36,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: true,
},
},
seatsPerTimeSlot: true,
},
},
dynamicEventSlugRef: true,
@ -74,15 +72,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
"/" +
eventType?.slug;
const destinationUrl = new URLSearchParams();
if (seatReferenceUid) {
destinationUrl.set("rescheduleUid", seatReferenceUid);
} else {
destinationUrl.set("rescheduleUid", bookingId);
}
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
return {
redirect: {
destination: `/${eventPage}?${destinationUrl.toString()}`,
destination: `/${eventPage}?${destinationUrl.toString()}${
eventType.seatsPerTimeSlot ? "&bookingUid=null" : ""
}`,
permanent: false,
},
};

View File

@ -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 { classNames } from "@calcom/lib";
import slugify from "@calcom/lib/slugify";
@ -29,7 +29,7 @@ export default function Type({ slug, user, booking, away, isBrandingHidden }: Pa
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
@ -72,7 +72,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
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,

View File

@ -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<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
return (
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
isTeamEvent
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
/>
</main>
);
}
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,
},
};
};

View File

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

View File

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

View File

@ -79,38 +79,21 @@ export async function waitFor(fn: () => Promise<unknown> | 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) {

View File

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

View File

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

View File

@ -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}>
<BookEventForm onCancel={() => setSelectedTimeslot(null)} />
<BookEventForm
onCancel={() => {
setSelectedTimeslot(null);
if (seatedEventData.bookingUid) {
setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined });
}
}}
/>
</BookerSection>
<BookerSection
@ -236,7 +251,7 @@ const BookerComponent = ({
<AvailableTimeSlots
extraDays={extraDays}
limitHeight={layout === BookerLayouts.MONTH_VIEW}
seatsPerTimeslot={event.data?.seatsPerTimeSlot}
seatsPerTimeSlot={event.data?.seatsPerTimeSlot}
/>
</BookerSection>
</AnimatePresence>

View File

@ -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<HTMLDivElement | null>(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 }:
<AvailableTimes
className="w-full"
key={slots.date}
showTimeformatToggle={!isMultipleDates}
onTimeSelect={onTimeSelect}
date={dayjs(slots.date)}
slots={slots.slots}
seatsPerTimeslot={seatsPerTimeslot}
onTimeSelect={onTimeSelect}
seatsPerTimeSlot={seatsPerTimeSlot}
showTimeFormatToggle={!isMultipleDates}
/>
))}
</div>

View File

@ -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<HTMLDivElement>(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) => {
)}
<div className="modalsticky mt-auto flex justify-end space-x-2 rtl:space-x-reverse">
{!!onCancel && (
<Button color="minimal" type="button" onClick={onCancel}>
<Button color="minimal" type="button" onClick={onCancel} data-testid="back">
{t("back")}
</Button>
)}

View File

@ -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 (
<div className="relative z-10 p-6">
@ -51,13 +72,13 @@ export const EventMeta = () => {
</EventMetaBlock>
)}
<div className="space-y-4 font-medium">
{rescheduleBooking && (
{rescheduleUid && bookingData && (
<EventMetaBlock icon={Calendar}>
{t("former_time")}
<br />
<span className="line-through" data-testid="former_time_p">
<FromToTime
date={rescheduleBooking.startTime.toString()}
date={bookingData.startTime.toString()}
duration={null}
timeFormat={timeFormat}
timeZone={timezone}
@ -101,6 +122,21 @@ export const EventMeta = () => {
</span>
)}
</EventMetaBlock>
{bookerState === "booking" && eventTotalSeats && bookingSeatAttendeesQty ? (
<EventMetaBlock icon={User} className={`${colorClass}`}>
<div className="text-bookinghighlight flex items-start text-sm">
<p>
{bookingSeatAttendeesQty ? eventTotalSeats - bookingSeatAttendeesQty : eventTotalSeats} /{" "}
{eventTotalSeats}{" "}
{t("seats_available", {
count: bookingSeatAttendeesQty
? eventTotalSeats - bookingSeatAttendeesQty
: eventTotalSeats,
})}
</p>
</div>
</EventMetaBlock>
) : null}
</div>
</m.div>
)}

View File

@ -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<BookerStore>((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<BookerStore>((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<BookerStore>((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<BookerStore>((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<BookerStore>((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]);
};

View File

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

View File

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

View File

@ -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 = ({
</span>
</span>
{showTimeformatToggle && (
{showTimeFormatToggle && (
<div className="ml-auto">
<TimeFormatToggle />
</div>
@ -70,22 +76,27 @@ export const AvailableTimes = ({
{!slots.length && (
<div className="bg-subtle border-subtle flex h-full flex-col items-center rounded-md border p-6 dark:bg-transparent">
<CalendarX2 className="text-muted mb-2 h-4 w-4" />
<p className={classNames("text-muted", showTimeformatToggle ? "-mt-1 text-lg" : "text-sm")}>
<p className={classNames("text-muted", showTimeFormatToggle ? "-mt-1 text-lg" : "text-sm")}>
{t("all_booked_today")}
</p>
</div>
)}
{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 (
<Button
key={slot.time}
disabled={bookingFull}
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
data-testid="time"
data-disabled={bookingFull}
data-time={slot.time}
onClick={() => onTimeSelect(slot.time)}
onClick={() => onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)}
className="min-h-9 hover:border-brand-default mb-2 flex h-auto w-full flex-col justify-center py-2"
color="secondary">
{dayjs.utc(slot.time).tz(timezone).format(timeFormat)}
@ -93,19 +104,12 @@ export const AvailableTimes = ({
{hasTimeSlots && !bookingFull && (
<p className="flex items-center text-sm lowercase">
<span
className={classNames(
slot.attendees && slot.attendees / seatsPerTimeslot >= 0.8
? "bg-rose-600"
: slot.attendees && slot.attendees / seatsPerTimeslot >= 0.33
? "bg-yellow-500"
: "bg-emerald-400",
"mr-1 inline-block h-2 w-2 rounded-full"
)}
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
aria-hidden
/>
{slot.attendees ? seatsPerTimeslot - slot.attendees : seatsPerTimeslot}{" "}
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot}{" "}
{t("seats_available", {
count: slot.attendees ? seatsPerTimeslot - slot.attendees : seatsPerTimeslot,
count: slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot,
})}
</p>
)}

View File

@ -16,6 +16,8 @@ type BookingOptions = {
rescheduleUid: string | undefined;
username: string;
metadata?: Record<string, string>;
bookingUid?: string;
seatReferenceUid?: string;
};
export const mapBookingToMutationInput = ({
@ -28,6 +30,8 @@ export const mapBookingToMutationInput = ({
rescheduleUid,
username,
metadata,
bookingUid,
seatReferenceUid,
}: BookingOptions): BookingCreateBody => {
return {
...values,
@ -44,6 +48,8 @@ export const mapBookingToMutationInput = ({
rescheduleUid,
metadata: metadata || {},
hasHashedBookingLink: false,
bookingUid,
seatReferenceUid,
// hasHashedBookingLink,
// hashedLink,
};

View File

@ -58,6 +58,7 @@ async function getBooking(prisma: PrismaClient, uid: string) {
responses: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
attendees: {
select: {
email: true,
@ -107,7 +108,7 @@ export const getBookingWithResponses = <
export default getBooking;
export const getBookingByUidOrRescheduleUid = async (uid: string) => {
export const getBookingForReschedule = async (uid: string) => {
let eventTypeId: number | null = null;
let rescheduleUid: string | null = null;
eventTypeId =
@ -132,7 +133,12 @@ export const getBookingByUidOrRescheduleUid = async (uid: string) => {
},
select: {
id: true,
attendee: true,
attendee: {
select: {
name: true,
email: true,
},
},
booking: {
select: {
uid: true,
@ -161,3 +167,64 @@ export const getBookingByUidOrRescheduleUid = async (uid: string) => {
: booking.attendees,
};
};
/**
* Should only get booking attendees length for seated events
* @param uid
* @returns booking with masked attendee emails
*/
export const getBookingForSeatedEvent = async (uid: string) => {
const booking = await prisma.booking.findFirst({
where: {
uid,
},
select: {
id: true,
uid: true,
startTime: true,
attendees: {
select: {
id: true,
},
},
eventTypeId: true,
user: {
select: {
id: true,
},
},
},
});
if (!booking || booking.eventTypeId === null) return null;
// Validate booking event type has seats enabled
const eventType = await prisma.eventType.findFirst({
where: {
id: booking.eventTypeId,
},
select: {
seatsPerTimeSlot: true,
},
});
if (!eventType || eventType.seatsPerTimeSlot === null) return null;
const result: GetBookingType = {
...booking,
// @NOTE: had to do this because Server side cant return [Object objects]
startTime: booking.startTime.toISOString() as unknown as Date,
description: null,
customInputs: null,
responses: {},
smsReminderNumber: null,
location: null,
// mask attendee emails for seated events
attendees: booking.attendees.map((attendee) => ({
...attendee,
email: "",
name: "",
bookingSeat: null,
})),
};
return result;
};

View File

@ -1141,6 +1141,7 @@ async function handler(
message?: string;
})
| null = null;
const booking = await prisma.booking.findFirst({
where: {
OR: [
@ -1152,6 +1153,7 @@ async function handler(
startTime: evt.startTime,
},
],
status: BookingStatus.ACCEPTED,
},
select: {
uid: true,
@ -1184,6 +1186,7 @@ async function handler(
where: {
startTime: evt.startTime,
eventTypeId: eventType.id,
status: BookingStatus.ACCEPTED,
},
select: {
id: true,
@ -1270,6 +1273,7 @@ async function handler(
},
data: {
startTime: evt.startTime,
endTime: evt.endTime,
cancellationReason: rescheduleReason,
},
include: {
@ -1295,7 +1299,7 @@ async function handler(
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = calendarResult.updatedEvent.iCalUID || undefined;
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
@ -1466,82 +1470,83 @@ async function handler(
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
if (seatAttendee) {
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
}
// If there is no booking then remove the attendee from the old booking and create a new one
if (!newTimeSlotBooking) {
await prisma.attendee.delete({
where: {
id: seatAttendee?.id,
},
});
// Update the original calendar event by removing the attendee that is rescheduling
if (originalBookingEvt && originalRescheduledBooking) {
// Event would probably be deleted so we first check than instead of updating references
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
// If there is no booking then remove the attendee from the old booking and create a new one
if (!newTimeSlotBooking) {
await prisma.attendee.delete({
where: {
id: seatAttendee?.id,
},
});
const deletedReference = await lastAttendeeDeleteBooking(
originalRescheduledBooking,
filteredAttendees,
originalBookingEvt
);
if (!deletedReference) {
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
// Update the original calendar event by removing the attendee that is rescheduling
if (originalBookingEvt && originalRescheduledBooking) {
// Event would probably be deleted so we first check than instead of updating references
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
const deletedReference = await lastAttendeeDeleteBooking(
originalRescheduledBooking,
filteredAttendees,
originalBookingEvt
);
if (!deletedReference) {
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
}
}
// We don't want to trigger rescheduling logic of the original booking
originalRescheduledBooking = null;
return null;
}
// We don't want to trigger rescheduling logic of the original booking
originalRescheduledBooking = null;
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
if (seatAttendee?.id && bookingSeat?.id) {
await Promise.all([
await prisma.attendee.update({
where: {
id: seatAttendee.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
await prisma.bookingSeat.update({
where: {
id: bookingSeat.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
]);
}
return null;
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
}
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
if (seatAttendee?.id && bookingSeat?.id) {
await Promise.all([
await prisma.attendee.update({
where: {
id: seatAttendee.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
await prisma.bookingSeat.update({
where: {
id: bookingSeat.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
]);
}
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
} else {
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
@ -1668,9 +1673,11 @@ async function handler(
resultBooking = { ...foundBooking };
resultBooking["message"] = "Payment required";
resultBooking["paymentUid"] = payment?.uid;
} else {
resultBooking = { ...foundBooking };
}
resultBooking = { ...foundBooking, seatReferenceUid: evt.attendeeSeatId };
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
}
// Here we should handle every after action that needs to be done after booking creation

View File

@ -2,7 +2,6 @@ import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";

View File

@ -43,7 +43,7 @@ export default function TeamList(props: Props) {
}
return (
<ul className="bg-default divide-subtle border-subtle mb-2 divide-y rounded-md border overflow-hidden">
<ul className="bg-default divide-subtle border-subtle mb-2 divide-y overflow-hidden rounded-md border">
{props.teams.map((team) => (
<TeamListItem
key={team?.id as number}

View File

@ -146,7 +146,9 @@ export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label,
{icon}
</div>
<Tooltip content={label}>
<label htmlFor={rest.id} className="text-default me-1 cursor-pointer truncate text-sm font-medium">
<label
htmlFor={rest.id}
className="text-default me-1 cursor-pointer truncate text-sm font-medium">
{label}
</label>
</Tooltip>

View File

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

View File

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

View File

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