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
parent
c2fceb2e0f
commit
a5b5382306
|
@ -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", {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 ({
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(),
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Reference in New Issue