diff --git a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx
index 1ce751939d5814..2d15f09f473c6f 100644
--- a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx
+++ b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx
@@ -13,7 +13,11 @@ import type {
SettingsToggleClassNames,
} from "@calcom/features/eventtypes/lib/types";
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
-import { MAX_EVENT_DURATION_MINUTES, MIN_EVENT_DURATION_MINUTES } from "@calcom/lib/constants";
+import {
+ MAX_EVENT_DURATION_MINUTES,
+ MAX_MULTI_DAY_EVENT_DURATION_MINUTES,
+ MIN_EVENT_DURATION_MINUTES,
+} from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { slugify } from "@calcom/lib/slugify";
@@ -77,7 +81,13 @@ export const EventSetupTab = (
);
const [firstRender, setFirstRender] = useState(true);
+ // Watch form values for multi-day configuration - use watched values as source of truth
+ const multiDayConfig = formMethods.watch("metadata")?.multiDayConfig;
+ const multiDayEnabled = multiDayConfig?.enabled ?? false;
+ const multiDayNumberOfDays = multiDayConfig?.numberOfDays ?? 1;
+
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
+ const recurringEventEnabled = !!formMethods.watch("recurringEvent");
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
@@ -190,7 +200,19 @@ export const EventSetupTab = (
"border-subtle rounded-lg border p-6",
customClassNames?.durationSection?.container
)}>
- {multipleDuration ? (
+ {multiDayEnabled ? (
+
+
+
+
+ {t("multi_day_duration_calculated", {
+ days: multiDayNumberOfDays,
+ hours: multiDayNumberOfDays * 24,
+ })}
+
+
+
+ ) : multipleDuration ? (
)}
{!lengthLockedProps.disabled && (
-
-
{
- if (multipleDuration !== undefined) {
- setMultipleDuration(undefined);
- setSelectedMultipleDuration([]);
- setDefaultDuration(null);
- formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true });
- formMethods.setValue("length", eventType.length, { shouldDirty: true });
- } else {
- setMultipleDuration([]);
- formMethods.setValue("metadata.multipleDuration", [], { shouldDirty: true });
- formMethods.setValue("length", 0, { shouldDirty: true });
+ <>
+
+
-
+ labelClassName={customClassNames?.durationSection?.selectDurationToggle?.label}
+ descriptionClassName={customClassNames?.durationSection?.selectDurationToggle?.description}
+ switchContainerClassName={customClassNames?.durationSection?.selectDurationToggle?.container}
+ childrenClassName={customClassNames?.durationSection?.selectDurationToggle?.children}
+ onCheckedChange={() => {
+ if (multipleDuration !== undefined) {
+ setMultipleDuration(undefined);
+ setSelectedMultipleDuration([]);
+ setDefaultDuration(null);
+ formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true });
+ formMethods.setValue("length", eventType.length, { shouldDirty: true });
+ } else {
+ setMultipleDuration([]);
+ formMethods.setValue("metadata.multipleDuration", [], { shouldDirty: true });
+ formMethods.setValue("length", 0, { shouldDirty: true });
+ }
+ }}
+ />
+
+
+
{
+ if (checked) {
+ formMethods.setValue(
+ "metadata.multiDayConfig",
+ {
+ enabled: true,
+ numberOfDays: multiDayNumberOfDays,
+ },
+ { shouldDirty: true }
+ );
+ // Set duration to number of days * 24 hours * 60 minutes
+ formMethods.setValue("length", multiDayNumberOfDays * 24 * 60, { shouldDirty: true });
+ } else {
+ formMethods.setValue("metadata.multiDayConfig", undefined, { shouldDirty: true });
+ formMethods.setValue("length", eventType.length, { shouldDirty: true });
+ }
+ }}>
+ {multiDayEnabled && (
+
+ {
+ const days = parseInt(e.target.value) || 1;
+ const clampedDays = Math.min(Math.max(days, 1), 30);
+ formMethods.setValue(
+ "metadata.multiDayConfig",
+ {
+ enabled: true,
+ numberOfDays: clampedDays,
+ },
+ { shouldDirty: true }
+ );
+ // Update duration to reflect number of days
+ formMethods.setValue("length", clampedDays * 24 * 60, { shouldDirty: true });
+ }}
+ min={1}
+ max={30}
+ addOnSuffix={<>{t("days")}>}
+ />
+
+ )}
+
+
+ >
)}
{
+ const mockLogger = {
+ warn: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ } as any;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("Single Duration Mode", () => {
+ it("should accept booking with correct single duration", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(30, "minutes").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeLength: 30,
+ logger: mockLogger,
+ });
+ }).not.toThrow();
+ });
+
+ it("should reject booking with incorrect duration", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(45, "minutes").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeLength: 30,
+ logger: mockLogger,
+ });
+ }).toThrow(HttpError);
+ });
+ });
+
+ describe("Multiple Duration Mode", () => {
+ it("should accept booking with one of the multiple duration options", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(45, "minutes").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeMultipleDuration: [15, 30, 45, 60],
+ eventTypeLength: 30,
+ logger: mockLogger,
+ });
+ }).not.toThrow();
+ });
+
+ it("should reject booking with duration not in multiple duration options", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(90, "minutes").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeMultipleDuration: [15, 30, 45, 60],
+ eventTypeLength: 30,
+ logger: mockLogger,
+ });
+ }).toThrow(HttpError);
+ });
+ });
+
+ describe("Multi-Day Mode", () => {
+ it("should accept booking with correct multi-day duration (1 day)", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(1, "day").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeLength: 1440,
+ eventTypeMultiDayConfig: {
+ enabled: true,
+ numberOfDays: 1,
+ },
+ logger: mockLogger,
+ });
+ }).not.toThrow();
+ });
+
+ it("should accept booking with correct multi-day duration (3 days)", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(3, "days").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeLength: 4320,
+ eventTypeMultiDayConfig: {
+ enabled: true,
+ numberOfDays: 3,
+ },
+ logger: mockLogger,
+ });
+ }).not.toThrow();
+ });
+
+ it("should reject booking with incorrect multi-day duration", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(2, "days").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeLength: 4320,
+ eventTypeMultiDayConfig: {
+ enabled: true,
+ numberOfDays: 3,
+ },
+ logger: mockLogger,
+ });
+ }).toThrow(HttpError);
+ });
+
+ it("should accept booking with maximum multi-day duration (30 days)", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(30, "days").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeLength: 43200,
+ eventTypeMultiDayConfig: {
+ enabled: true,
+ numberOfDays: 30,
+ },
+ logger: mockLogger,
+ });
+ }).not.toThrow();
+ });
+
+ it("should prioritize multi-day config over multiple durations", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(2, "days").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeMultipleDuration: [30, 60, 90],
+ eventTypeLength: 30,
+ eventTypeMultiDayConfig: {
+ enabled: true,
+ numberOfDays: 2,
+ },
+ logger: mockLogger,
+ });
+ }).not.toThrow();
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("should log warning when validation fails", () => {
+ const start = dayjs().toISOString();
+ const end = dayjs().add(45, "minutes").toISOString();
+
+ expect(() => {
+ validateEventLength({
+ reqBodyStart: start,
+ reqBodyEnd: end,
+ eventTypeLength: 30,
+ logger: mockLogger,
+ });
+ }).toThrow();
+
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ message: "NewBooking: Invalid event length",
+ });
+ });
+ });
+});
diff --git a/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts b/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts
index 9568ed27321555..cf4ee82c0559e1 100644
--- a/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts
+++ b/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts
@@ -9,6 +9,10 @@ type Props = {
reqBodyEnd: string;
eventTypeMultipleDuration?: number[];
eventTypeLength: number;
+ eventTypeMultiDayConfig?: {
+ enabled: boolean;
+ numberOfDays: number;
+ };
logger: Logger;
};
@@ -18,10 +22,26 @@ const _validateEventLength = ({
reqBodyEnd,
eventTypeMultipleDuration,
eventTypeLength,
+ eventTypeMultiDayConfig,
logger,
}: Props) => {
const reqEventLength = dayjs(reqBodyEnd).diff(dayjs(reqBodyStart), "minutes");
- const validEventLengths = eventTypeMultipleDuration?.length ? eventTypeMultipleDuration : [eventTypeLength];
+
+ // Calculate valid event lengths based on configuration
+ let validEventLengths: number[];
+
+ if (eventTypeMultiDayConfig?.enabled) {
+ // For multi-day bookings, the valid duration is numberOfDays * 24 hours * 60 minutes
+ const multiDayDurationInMinutes = eventTypeMultiDayConfig.numberOfDays * 24 * 60;
+ validEventLengths = [multiDayDurationInMinutes];
+ } else if (eventTypeMultipleDuration?.length) {
+ // For multiple duration options
+ validEventLengths = eventTypeMultipleDuration;
+ } else {
+ // For single duration
+ validEventLengths = [eventTypeLength];
+ }
+
if (!validEventLengths.includes(reqEventLength)) {
logger.warn({ message: "NewBooking: Invalid event length" });
throw new HttpError({ statusCode: 400, message: "Invalid event length" });
diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts
index 79f608adb14159..a75bbfc81e9c9f 100644
--- a/packages/features/bookings/lib/service/RegularBookingService.ts
+++ b/packages/features/bookings/lib/service/RegularBookingService.ts
@@ -840,6 +840,7 @@ async function handler(
reqBodyEnd: reqBody.end,
eventTypeMultipleDuration: eventType.metadata?.multipleDuration,
eventTypeLength: eventType.length,
+ eventTypeMultiDayConfig: eventType.metadata?.multiDayConfig,
logger: tracingLogger,
});
diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts
index 7e9917562a7c2f..722ef8b0ddd073 100644
--- a/packages/features/eventtypes/lib/types.ts
+++ b/packages/features/eventtypes/lib/types.ts
@@ -162,6 +162,8 @@ export type FormValues = {
availability?: AvailabilityOption;
bookerLayouts: BookerLayoutSettings;
multipleDurationEnabled: boolean;
+ multiDayEnabled: boolean;
+ multiDayNumberOfDays: number;
users: EventTypeSetup["users"];
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index aa4ae30d95a5f1..cd45b79e391ca6 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -65,6 +65,9 @@ export const MAX_SEATS_PER_TIME_SLOT = 1000;
/** Maximum duration allowed for an event in minutes (24 hours) */
export const MAX_EVENT_DURATION_MINUTES = 1440;
+/** Maximum duration allowed for a multi-day event in minutes (30 days) */
+export const MAX_MULTI_DAY_EVENT_DURATION_MINUTES = 30 * 24 * 60;
+
/** Minimum duration allowed for an event in minutes */
export const MIN_EVENT_DURATION_MINUTES = 1;
diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
index 698570b77299be..808ebce526dcbe 100644
--- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
+++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
@@ -100,6 +100,9 @@ export const useEventTypeForm = ({
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
hideOrganizerEmail: eventType.hideOrganizerEmail,
metadata: eventType.metadata,
+ multipleDurationEnabled: !!eventType.metadata?.multipleDuration,
+ multiDayEnabled: eventType.metadata?.multiDayConfig?.enabled ?? false,
+ multiDayNumberOfDays: eventType.metadata?.multiDayConfig?.numberOfDays ?? 1,
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
hostGroups: eventType.hostGroups || [],
successRedirectUrl: eventType.successRedirectUrl || "",
@@ -329,6 +332,10 @@ export const useEventTypeForm = ({
bookerLayouts,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiDayEnabled,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiDayNumberOfDays,
length,
...input
} = dirtyValues;
diff --git a/packages/prisma/zod-utils.multiday.test.ts b/packages/prisma/zod-utils.multiday.test.ts
new file mode 100644
index 00000000000000..3efb75302424db
--- /dev/null
+++ b/packages/prisma/zod-utils.multiday.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+import { EventTypeMetaDataSchema } from "./zod-utils";
+
+describe("EventTypeMetaDataSchema - Multi-Day Config", () => {
+ describe("Valid multi-day configurations", () => {
+ it("should accept valid multi-day config with 1 day", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: 1,
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept valid multi-day config with 30 days", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: 30,
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept multi-day config with mid-range value", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: false,
+ numberOfDays: 15,
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept null metadata", () => {
+ const result = EventTypeMetaDataSchema.safeParse(null);
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept metadata without multiDayConfig", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multipleDuration: [30, 60, 90],
+ });
+
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe("Invalid multi-day configurations", () => {
+ it("should reject numberOfDays less than 1", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: 0,
+ },
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0].path).toContain("numberOfDays");
+ }
+ });
+
+ it("should reject numberOfDays greater than 30", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: 31,
+ },
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0].path).toContain("numberOfDays");
+ }
+ });
+
+ it("should reject negative numberOfDays", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: -5,
+ },
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0].path).toContain("numberOfDays");
+ }
+ });
+
+ it("should reject decimal numberOfDays due to .int() constraint", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: 2.5,
+ },
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0].path).toContain("numberOfDays");
+ expect(result.error.issues[0].message).toContain("integer");
+ }
+ });
+
+ it("should reject missing enabled field", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ numberOfDays: 5,
+ },
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject missing numberOfDays field", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ },
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject non-boolean enabled field", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: "true",
+ numberOfDays: 5,
+ },
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject non-number numberOfDays field", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: "5",
+ },
+ });
+
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe("Compatibility with other metadata fields", () => {
+ it("should accept multi-day config alongside other metadata fields", () => {
+ const result = EventTypeMetaDataSchema.safeParse({
+ multiDayConfig: {
+ enabled: true,
+ numberOfDays: 3,
+ },
+ additionalNotesRequired: true,
+ disableSuccessPage: false,
+ apps: {
+ stripe: {
+ enabled: true,
+ },
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+ });
+});
diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts
index 9dd03b80d581d3..27374c8b1757b1 100644
--- a/packages/prisma/zod-utils.ts
+++ b/packages/prisma/zod-utils.ts
@@ -218,6 +218,12 @@ const _eventTypeMetaDataSchemaWithoutApps = z.object({
smartContractAddress: z.string().optional(),
blockchainId: z.number().optional(),
multipleDuration: z.number().array().optional(),
+ multiDayConfig: z
+ .object({
+ enabled: z.boolean(),
+ numberOfDays: z.number().int().min(1).max(30),
+ })
+ .optional(),
giphyThankYouPage: z.string().optional(),
additionalNotesRequired: z.boolean().optional(),
disableSuccessPage: z.boolean().optional(),
diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts
index cef6bd12538532..11b086905f8d06 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts
@@ -212,6 +212,31 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
});
}
+ // Validate multi-day booking conflicts
+ const multiDayConfig = input.metadata?.multiDayConfig;
+ const multipleDuration = input.metadata?.multipleDuration;
+
+ if (multiDayConfig?.enabled && finalSeatsPerTimeSlot) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Multi-day bookings and Offer Seats cannot be active at the same time.",
+ });
+ }
+
+ if (multiDayConfig?.enabled && multipleDuration?.length) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Multi-day bookings and multiple durations cannot be active at the same time.",
+ });
+ }
+
+ if (multiDayConfig?.enabled && finalRecurringEvent) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Multi-day bookings and recurring events cannot be active at the same time.",
+ });
+ }
+
const teamId = input.teamId || eventType.team?.id;
const guestsField = bookingFields?.find((field) => field.name === "guests");