- {formatToLocalizedDate(dayjs.tz(date, tz), language, "full", tz)}
+ {formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
- {formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
- {formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
+ {formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
+ {formatToLocalizedTime(
+ dayjs(dateStr).add(duration, "m"),
+ language,
+ undefined,
+ !is24h,
+ tz
+ )}{" "}
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
diff --git a/apps/web/playwright/ab-tests-redirect.e2e.ts b/apps/web/playwright/ab-tests-redirect.e2e.ts
index b3fd79834993e4..24dd82ccd403ce 100644
--- a/apps/web/playwright/ab-tests-redirect.e2e.ts
+++ b/apps/web/playwright/ab-tests-redirect.e2e.ts
@@ -1,3 +1,4 @@
+import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
@@ -5,8 +6,16 @@ import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
test.describe.configure({ mode: "parallel" });
+const ensureAppDir = async (page: Page) => {
+ const dataNextJsRouter = await page.evaluate(() =>
+ window.document.documentElement.getAttribute("data-nextjs-router")
+ );
+
+ expect(dataNextJsRouter).toEqual("app");
+};
+
testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
- test("should render the /apps/installed/[category]", async ({ page, users, context }) => {
+ test("should render the /apps/installed/[category]", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
@@ -15,10 +24,14 @@ testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
const locator = page.getByRole("heading", { name: "Messaging" });
+ if (routeVariant === "future") {
+ await ensureAppDir(page);
+ }
+
await expect(locator).toBeVisible();
});
- test("should render the /apps/[slug]", async ({ page, users, context }) => {
+ test("should render the /apps/[slug]", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
@@ -27,10 +40,14 @@ testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
const locator = page.getByRole("heading", { name: "Telegram" });
+ if (routeVariant === "future") {
+ await ensureAppDir(page);
+ }
+
await expect(locator).toBeVisible();
});
- test("should render the /apps/[slug]/setup", async ({ page, users, context }) => {
+ test("should render the /apps/[slug]/setup", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
@@ -39,11 +56,14 @@ testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
const locator = page.getByRole("heading", { name: "Connect to Apple Server" });
+ if (routeVariant === "future") {
+ await ensureAppDir(page);
+ }
+
await expect(locator).toBeVisible();
});
- test("should render the /apps/categories", async ({ page, users, context }) => {
- test.skip(routeVariant === "future", "Future route not ready yet");
+ test("should render the /apps/categories", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
@@ -52,10 +72,14 @@ testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
const locator = page.getByTestId("app-store-category-messaging");
+ if (routeVariant === "future") {
+ await ensureAppDir(page);
+ }
+
await expect(locator).toBeVisible();
});
- test("should render the /apps/categories/[category]", async ({ page, users, context }) => {
+ test("should render the /apps/categories/[category]", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
@@ -64,10 +88,14 @@ testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
const locator = page.getByText(/messaging apps/i);
+ if (routeVariant === "future") {
+ await ensureAppDir(page);
+ }
+
await expect(locator).toBeVisible();
});
- test("should render the /bookings/[status]", async ({ page, users, context }) => {
+ test("should render the /bookings/[status]", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
@@ -76,11 +104,14 @@ testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
const locator = page.getByTestId("horizontal-tab-upcoming");
+ if (routeVariant === "future") {
+ await ensureAppDir(page);
+ }
+
await expect(locator).toHaveClass(/bg-emphasis/);
});
- test("should render the /getting-started", async ({ page, users, context }) => {
- test.skip(routeVariant === "future", "Future route not ready yet");
+ test("should render the /getting-started", async ({ page, users }) => {
const user = await users.create({ completedOnboarding: false, name: null });
await user.apiLogin();
@@ -89,6 +120,10 @@ testBothFutureAndLegacyRoutes.describe("apps/ A/B tests", (routeVariant) => {
const locator = page.getByText("Apple Calendar");
+ if (routeVariant === "future") {
+ await ensureAppDir(page);
+ }
+
await expect(locator).toBeVisible();
});
});
diff --git a/apps/web/playwright/eventType/limit-tab.e2e.ts b/apps/web/playwright/eventType/limit-tab.e2e.ts
new file mode 100644
index 00000000000000..9ec218e9b4e6ea
--- /dev/null
+++ b/apps/web/playwright/eventType/limit-tab.e2e.ts
@@ -0,0 +1,27 @@
+import { loginUser } from "../fixtures/regularBookings";
+import { test } from "../lib/fixtures";
+
+test.describe("Limits Tab - Event Type", () => {
+ test.beforeEach(async ({ page, users, bookingPage }) => {
+ await loginUser(users);
+ await page.goto("/event-types");
+ await bookingPage.goToEventType("30 min");
+ await bookingPage.goToTab("event_limit_tab_title");
+ });
+
+ test("Check the functionalities of the Limits Tab", async ({ bookingPage }) => {
+ await bookingPage.checkLimitBookingFrequency();
+ await bookingPage.checkLimitBookingDuration();
+ await bookingPage.checkLimitFutureBookings();
+ await bookingPage.checkOffsetTimes();
+ await bookingPage.checkBufferTime();
+
+ await bookingPage.updateEventType();
+ const eventTypePage = await bookingPage.previewEventType();
+
+ await eventTypePage.waitForTimeout(10000);
+
+ const counter = await eventTypePage.getByTestId("time").count();
+ await bookingPage.checkTimeSlotsCount(eventTypePage, counter);
+ });
+});
diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts
index c676a67e339da0..0aae11b9fb99d1 100644
--- a/apps/web/playwright/fixtures/regularBookings.ts
+++ b/apps/web/playwright/fixtures/regularBookings.ts
@@ -396,5 +396,99 @@ export function createBookingPageFixture(page: Page) {
await scheduleSuccessfullyPage.waitFor({ state: "visible" });
await expect(scheduleSuccessfullyPage).toBeVisible();
},
+
+ checkBufferTime: async () => {
+ const minutes = (await localize("en"))("minutes");
+ const fieldPlaceholder = page.getByPlaceholder("0");
+
+ await page
+ .locator("div")
+ .filter({ hasText: /^No buffer time$/ })
+ .nth(1)
+ .click();
+ await page.getByTestId("select-option-15").click();
+ await expect(page.getByText(`15 ${minutes}`, { exact: true })).toBeVisible();
+
+ await page
+ .locator("div")
+ .filter({ hasText: /^No buffer time$/ })
+ .nth(2)
+ .click();
+ await page.getByTestId("select-option-10").click();
+ await expect(page.getByText(`10 ${minutes}`, { exact: true })).toBeVisible();
+
+ await fieldPlaceholder.fill("10");
+ await expect(fieldPlaceholder).toHaveValue("10");
+
+ await page
+ .locator("div")
+ .filter({ hasText: /^Use event length \(default\)$/ })
+ .first()
+ .click();
+
+ // select a large interval to check if the time slots for a day reduce on the preview page
+ await page.getByTestId("select-option-60").click();
+ await expect(page.getByText(`60 ${minutes}`, { exact: true })).toBeVisible();
+ },
+
+ checkLimitBookingFrequency: async () => {
+ const fieldPlaceholder = page.getByPlaceholder("1").nth(1);
+ const limitFrequency = (await localize("en"))("limit_booking_frequency");
+ const addlimit = (await localize("en"))("add_limit");
+ const limitFrequencySwitch = page
+ .locator("fieldset")
+ .filter({ hasText: limitFrequency })
+ .getByRole("switch");
+
+ await limitFrequencySwitch.click();
+ await page.getByRole("button", { name: addlimit }).click();
+ await fieldPlaceholder.fill("12");
+ await expect(fieldPlaceholder).toHaveValue("12");
+ await limitFrequencySwitch.click();
+ },
+
+ checkLimitBookingDuration: async () => {
+ const limitDuration = (await localize("en"))("limit_total_booking_duration");
+ const addlimit = (await localize("en"))("add_limit");
+ const limitDurationSwitch = page
+ .locator("fieldset")
+ .filter({ hasText: limitDuration })
+ .getByRole("switch");
+
+ await limitDurationSwitch.click();
+ await page.getByRole("button", { name: addlimit }).click();
+ await expect(page.getByTestId("add-limit")).toHaveCount(2);
+ await limitDurationSwitch.click();
+ },
+
+ checkLimitFutureBookings: async () => {
+ const limitFutureBookings = (await localize("en"))("limit_future_bookings");
+ const limitBookingsSwitch = page
+ .locator("fieldset")
+ .filter({ hasText: limitFutureBookings })
+ .getByRole("switch");
+
+ await limitBookingsSwitch.click();
+ await page.locator("#RANGE").click();
+ await expect(page.locator("#RANGE")).toBeChecked();
+ await limitBookingsSwitch.click();
+ },
+
+ checkOffsetTimes: async () => {
+ const offsetStart = (await localize("en"))("offset_start");
+ const offsetStartTimes = (await localize("en"))("offset_toggle");
+ const offsetLabel = page.getByLabel(offsetStart);
+
+ await page.locator("fieldset").filter({ hasText: offsetStartTimes }).getByRole("switch").click();
+ await offsetLabel.fill("10");
+ await expect(offsetLabel).toHaveValue("10");
+ await expect(
+ page.getByText("e.g. this will show time slots to your bookers at 9:10 AM instead of 9:00 AM")
+ ).toBeVisible();
+ },
+
+ checkTimeSlotsCount: async (eventTypePage: Page, count: number) => {
+ await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
+ },
};
}
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 732c5c1c8e32d9..8f348e650ffa01 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -2194,6 +2194,7 @@
"uprade_to_create_instant_bookings": "Upgrade to Enterprise and let guests join an instant call that attendees can jump straight into. This is only for team event types",
"dont_want_to_wait": "Don't want to wait?",
"meeting_started": "Meeting Started",
+ "pay_and_book": "Pay to book",
"booking_not_found_error": "Could not find booking",
"booking_seats_full_error": "Booking seats are full",
"missing_payment_credential_error": "Missing payment credentials",
diff --git a/packages/core/event.test.ts b/packages/core/event.test.ts
new file mode 100644
index 00000000000000..4e4195b5708e46
--- /dev/null
+++ b/packages/core/event.test.ts
@@ -0,0 +1,394 @@
+import type { TFunction } from "next-i18next";
+import { describe, expect, it, vi } from "vitest";
+
+import * as event from "./event";
+
+describe("event tests", () => {
+ describe("fn: getEventName", () => {
+ it("should return event_between_users message if no name", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ t: tFunc as TFunction,
+ });
+
+ expect(result).toBe("foo");
+
+ const lastCall = tFunc.mock.lastCall;
+ expect(lastCall).toEqual([
+ "event_between_users",
+ {
+ eventName: "example event type",
+ host: "example host",
+ attendeeName: "example attendee",
+ interpolation: {
+ escapeValue: false,
+ },
+ },
+ ]);
+ });
+
+ it("should return event_between_users message if no name with team set", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ teamName: "example team name",
+ t: tFunc as TFunction,
+ });
+
+ expect(result).toBe("foo");
+
+ const lastCall = tFunc.mock.lastCall;
+ expect(lastCall).toEqual([
+ "event_between_users",
+ {
+ eventName: "example event type",
+ host: "example team name",
+ attendeeName: "example attendee",
+ interpolation: {
+ escapeValue: false,
+ },
+ },
+ ]);
+ });
+
+ it("should return event name if no vars used", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "example event name",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("example event name");
+ });
+
+ it("should support templating of event type", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "event type: {Event type title}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("event type: example event type");
+ });
+
+ it("should support templating of scheduler", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "scheduler: {Scheduler}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("scheduler: example attendee");
+ });
+
+ it("should support templating of organiser", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "organiser: {Organiser}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("organiser: example host");
+ });
+
+ it("should support templating of user", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "user: {USER}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("user: example attendee");
+ });
+
+ it("should support templating of attendee", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "attendee: {ATTENDEE}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("attendee: example attendee");
+ });
+
+ it("should support templating of host", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "host: {HOST}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("host: example host");
+ });
+
+ it("should support templating of attendee with host/attendee", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "host or attendee: {HOST/ATTENDEE}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("host or attendee: example attendee");
+ });
+
+ it("should support templating of host with host/attendee", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName(
+ {
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "host or attendee: {HOST/ATTENDEE}",
+ t: tFunc as TFunction,
+ },
+ true
+ );
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("host or attendee: example host");
+ });
+
+ it("should support templating of custom booking fields", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "custom field: {customField}",
+ bookingFields: {
+ customField: "example custom field",
+ },
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("custom field: example custom field");
+ });
+
+ it("should support templating of custom booking fields with values", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "custom field: {customField}",
+ bookingFields: {
+ customField: {
+ value: "example custom field",
+ },
+ },
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("custom field: example custom field");
+ });
+
+ it("should support templating of custom booking fields with non-string values", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "custom field: {customField}",
+ bookingFields: {
+ customField: {
+ value: 808,
+ },
+ },
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("custom field: 808");
+ });
+
+ it("should support templating of custom booking fields with no value", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "custom field: {customField}",
+ bookingFields: {
+ customField: {
+ value: undefined,
+ },
+ },
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("custom field: ");
+ });
+
+ it("should support templating of location via {Location}", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ location: "attendeeInPerson",
+ eventName: "location: {Location}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("location: in_person_attendee_address");
+ });
+
+ it("should support templating of location via {LOCATION}", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ location: "attendeeInPerson",
+ eventName: "location: {LOCATION}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("location: in_person_attendee_address");
+ });
+
+ it("should strip location template if none set", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ eventName: "location: {Location}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("location: ");
+ });
+
+ it("should strip location template if empty", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ location: "",
+ eventName: "location: {Location}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("location: ");
+ });
+
+ it("should template {Location} as passed location if unknown type", () => {
+ const tFunc = vi.fn(() => "foo");
+
+ const result = event.getEventName({
+ attendeeName: "example attendee",
+ eventType: "example event type",
+ host: "example host",
+ location: "unknownNonsense",
+ eventName: "location: {Location}",
+ t: tFunc as TFunction,
+ });
+
+ expect(tFunc).not.toHaveBeenCalled();
+ expect(result).toBe("location: unknownNonsense");
+ });
+ });
+
+ describe("fn: validateCustomEventName", () => {
+ it("should be valid when no variables used", () => {
+ expect(event.validateCustomEventName("foo", "error message")).toBe(true);
+ });
+
+ [
+ "Event type title",
+ "Organiser",
+ "Scheduler",
+ "Location",
+ "LOCATION",
+ "HOST/ATTENDEE",
+ "HOST",
+ "ATTENDEE",
+ "USER",
+ ].forEach((value) => {
+ it(`should support {${value}} variable`, () => {
+ expect(event.validateCustomEventName(`foo {${value}} bar`, "error message")).toBe(true);
+
+ expect(event.validateCustomEventName(`{${value}} bar`, "error message")).toBe(true);
+
+ expect(event.validateCustomEventName(`foo {${value}}`, "error message")).toBe(true);
+ });
+ });
+
+ it("should support booking field variables", () => {
+ expect(
+ event.validateCustomEventName("foo{customField}bar", "error message", {
+ customField: true,
+ })
+ ).toBe(true);
+ });
+
+ it("should return error when invalid variable used", () => {
+ expect(event.validateCustomEventName("foo{nonsenseField}bar", "error message")).toBe("error message");
+ });
+ });
+});
diff --git a/packages/core/getAggregateWorkingHours.test.ts b/packages/core/getAggregateWorkingHours.test.ts
new file mode 100644
index 00000000000000..f775139437093f
--- /dev/null
+++ b/packages/core/getAggregateWorkingHours.test.ts
@@ -0,0 +1,326 @@
+import { describe, it, expect } from "vitest";
+
+import type { WorkingHours } from "@calcom/types/schedule";
+
+import { getAggregateWorkingHours } from "./getAggregateWorkingHours";
+
+describe("getAggregateWorkingHours", () => {
+ it("should return all schedules if no scheduling type", () => {
+ const workingHours: WorkingHours[] = [
+ {
+ days: [1, 2, 3],
+ startTime: 0,
+ endTime: 720,
+ },
+ ];
+ const result = getAggregateWorkingHours(
+ [
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours,
+ dateOverrides: [],
+ },
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours,
+ dateOverrides: [],
+ },
+ ],
+ null
+ );
+
+ expect(result).toEqual([...workingHours, ...workingHours]);
+ });
+
+ it("should return all schedules if no fixed users exist", () => {
+ const workingHours: WorkingHours[] = [
+ {
+ days: [1, 2, 3],
+ startTime: 0,
+ endTime: 720,
+ },
+ ];
+ const result = getAggregateWorkingHours(
+ [
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours,
+ dateOverrides: [],
+ },
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours,
+ dateOverrides: [],
+ },
+ ],
+ "MANAGED"
+ );
+
+ expect(result).toEqual([...workingHours, ...workingHours]);
+ });
+
+ it("should consider all schedules fixed if collective", () => {
+ const workingHoursA: WorkingHours[] = [
+ {
+ days: [1, 2],
+ startTime: 0,
+ endTime: 200,
+ },
+ ];
+ const workingHoursB: WorkingHours[] = [
+ {
+ days: [2, 3],
+ startTime: 100,
+ endTime: 300,
+ },
+ ];
+ const result = getAggregateWorkingHours(
+ [
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursA,
+ dateOverrides: [],
+ user: {
+ isFixed: false,
+ },
+ },
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursB,
+ dateOverrides: [],
+ user: {
+ isFixed: false,
+ },
+ },
+ ],
+ "COLLECTIVE"
+ );
+
+ expect(result).toEqual([
+ {
+ days: [2],
+ startTime: 100,
+ endTime: 200,
+ },
+ ]);
+ });
+
+ it("should include loose host hours", () => {
+ const workingHoursA: WorkingHours[] = [
+ {
+ days: [1, 2],
+ startTime: 0,
+ endTime: 200,
+ },
+ ];
+ const workingHoursB: WorkingHours[] = [
+ {
+ days: [2, 3],
+ startTime: 100,
+ endTime: 300,
+ },
+ ];
+ const result = getAggregateWorkingHours(
+ [
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursA,
+ dateOverrides: [],
+ user: {
+ isFixed: false,
+ },
+ },
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursB,
+ dateOverrides: [],
+ user: {
+ isFixed: true,
+ },
+ },
+ ],
+ "GROUP"
+ );
+
+ expect(result).toEqual([
+ {
+ days: [2],
+ startTime: 100,
+ endTime: 200,
+ userId: undefined,
+ },
+ ]);
+ });
+
+ it("should return last user's hours if no intersection", () => {
+ const workingHoursA: WorkingHours[] = [
+ {
+ days: [1],
+ startTime: 0,
+ endTime: 200,
+ },
+ ];
+ const workingHoursB: WorkingHours[] = [
+ {
+ days: [2],
+ startTime: 100,
+ endTime: 300,
+ },
+ ];
+ const result = getAggregateWorkingHours(
+ [
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursA,
+ dateOverrides: [],
+ user: {
+ isFixed: true,
+ },
+ },
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursB,
+ dateOverrides: [],
+ user: {
+ isFixed: true,
+ },
+ },
+ ],
+ "GROUP"
+ );
+
+ expect(result).toEqual([...workingHoursB]);
+ });
+
+ it("should include user IDs when not collective", () => {
+ const workingHoursA: WorkingHours[] = [
+ {
+ days: [1, 2],
+ startTime: 0,
+ endTime: 200,
+ userId: "userA",
+ },
+ ];
+ const workingHoursB: WorkingHours[] = [
+ {
+ days: [2, 3],
+ startTime: 100,
+ endTime: 300,
+ userId: "userB",
+ },
+ ];
+ const result = getAggregateWorkingHours(
+ [
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursA,
+ dateOverrides: [],
+ user: {
+ isFixed: true,
+ },
+ },
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursB,
+ dateOverrides: [],
+ user: {
+ isFixed: true,
+ },
+ },
+ ],
+ "GROUP"
+ );
+
+ expect(result).toEqual([
+ {
+ userId: "userA",
+ days: [2],
+ startTime: 100,
+ endTime: 200,
+ },
+ ]);
+ });
+
+ it("should handle multiple intersections", () => {
+ const workingHoursA: WorkingHours[] = [
+ {
+ days: [1, 2],
+ startTime: 0,
+ endTime: 200,
+ },
+ {
+ days: [3, 4],
+ startTime: 100,
+ endTime: 300,
+ },
+ ];
+ const workingHoursB: WorkingHours[] = [
+ {
+ days: [2, 3],
+ startTime: 100,
+ endTime: 300,
+ },
+ {
+ days: [4, 5],
+ startTime: 0,
+ endTime: 200,
+ },
+ ];
+ const result = getAggregateWorkingHours(
+ [
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursA,
+ dateOverrides: [],
+ user: {
+ isFixed: true,
+ },
+ },
+ {
+ busy: [],
+ timeZone: "Europe/London",
+ workingHours: workingHoursB,
+ dateOverrides: [],
+ user: {
+ isFixed: true,
+ },
+ },
+ ],
+ "GROUP"
+ );
+
+ expect(result).toEqual([
+ {
+ days: [2],
+ startTime: 100,
+ endTime: 200,
+ userId: undefined,
+ },
+ {
+ days: [3],
+ startTime: 100,
+ endTime: 300,
+ userId: undefined,
+ },
+ {
+ days: [4],
+ startTime: 100,
+ endTime: 200,
+ userId: undefined,
+ },
+ ]);
+ });
+});
diff --git a/packages/core/getCalendarsEvents.test.ts b/packages/core/getCalendarsEvents.test.ts
new file mode 100644
index 00000000000000..5179baab80adc9
--- /dev/null
+++ b/packages/core/getCalendarsEvents.test.ts
@@ -0,0 +1,210 @@
+import type { SelectedCalendar } from "@prisma/client";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+
+import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
+import OfficeCalendarService from "@calcom/app-store/office365calendar/lib/CalendarService";
+import logger from "@calcom/lib/logger";
+import type { EventBusyDate } from "@calcom/types/Calendar";
+import type { CredentialPayload } from "@calcom/types/Credential";
+
+import getCalendarsEvents from "./getCalendarsEvents";
+
+describe("getCalendarsEvents", () => {
+ let credential: CredentialPayload;
+
+ beforeEach(() => {
+ vi.spyOn(logger.constructor.prototype, "debug");
+
+ credential = {
+ id: 303,
+ type: "google_calendar",
+ key: {
+ scope: "example scope",
+ token_type: "Bearer",
+ expiry_date: Date.now() + 84000,
+ access_token: "access token",
+ refresh_token: "refresh token",
+ },
+ userId: 808,
+ teamId: null,
+ appId: "exampleApp",
+ subscriptionId: null,
+ paymentStatus: null,
+ billingCycleStart: null,
+ invalid: false,
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should return empty array if no calendar credentials", async () => {
+ const result = await getCalendarsEvents(
+ [
+ {
+ ...credential,
+ type: "totally_unrelated",
+ },
+ ],
+ "2010-12-01",
+ "2010-12-02",
+ []
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it("should return unknown calendars as empty", async () => {
+ const result = await getCalendarsEvents(
+ [
+ {
+ ...credential,
+ type: "unknown_calendar",
+ },
+ ],
+ "2010-12-01",
+ "2010-12-02",
+ []
+ );
+
+ expect(result).toEqual([[]]);
+ });
+
+ it("should return unmatched calendars as empty", async () => {
+ const selectedCalendar: SelectedCalendar = {
+ credentialId: 100,
+ externalId: "externalId",
+ integration: "office365_calendar",
+ userId: 200,
+ };
+ const result = await getCalendarsEvents(
+ [
+ {
+ ...credential,
+ type: "google_calendar",
+ },
+ ],
+ "2010-12-01",
+ "2010-12-02",
+ [selectedCalendar]
+ );
+
+ expect(result).toEqual([[]]);
+ });
+
+ it("should return availability from selected calendar", async () => {
+ const availability: EventBusyDate[] = [
+ {
+ start: new Date(2010, 11, 2),
+ end: new Date(2010, 11, 3),
+ },
+ {
+ start: new Date(2010, 11, 2, 4),
+ end: new Date(2010, 11, 2, 16),
+ },
+ ];
+
+ const getAvailabilitySpy = vi
+ .spyOn(GoogleCalendarService.prototype, "getAvailability")
+ .mockReturnValue(Promise.resolve(availability));
+
+ const selectedCalendar: SelectedCalendar = {
+ credentialId: 100,
+ externalId: "externalId",
+ integration: "google_calendar",
+ userId: 200,
+ };
+ const result = await getCalendarsEvents(
+ [
+ {
+ ...credential,
+ type: "google_calendar",
+ },
+ ],
+ "2010-12-01",
+ "2010-12-04",
+ [selectedCalendar]
+ );
+
+ expect(getAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [selectedCalendar]);
+ expect(result).toEqual([
+ availability.map((av) => ({
+ ...av,
+ source: "exampleApp",
+ })),
+ ]);
+ });
+
+ it("should return availability from multiple calendars", async () => {
+ const googleAvailability: EventBusyDate[] = [
+ {
+ start: new Date(2010, 11, 2),
+ end: new Date(2010, 11, 3),
+ },
+ ];
+ const officeAvailability: EventBusyDate[] = [
+ {
+ start: new Date(2010, 11, 2, 4),
+ end: new Date(2010, 11, 2, 16),
+ },
+ ];
+
+ const getGoogleAvailabilitySpy = vi
+ .spyOn(GoogleCalendarService.prototype, "getAvailability")
+ .mockReturnValue(Promise.resolve(googleAvailability));
+ const getOfficeAvailabilitySpy = vi
+ .spyOn(OfficeCalendarService.prototype, "getAvailability")
+ .mockReturnValue(Promise.resolve(officeAvailability));
+
+ const selectedGoogleCalendar: SelectedCalendar = {
+ credentialId: 100,
+ externalId: "externalId",
+ integration: "google_calendar",
+ userId: 200,
+ };
+ const selectedOfficeCalendar: SelectedCalendar = {
+ credentialId: 100,
+ externalId: "externalId",
+ integration: "office365_calendar",
+ userId: 200,
+ };
+ const result = await getCalendarsEvents(
+ [
+ {
+ ...credential,
+ type: "google_calendar",
+ },
+ {
+ ...credential,
+ type: "office365_calendar",
+ key: {
+ access_token: "access",
+ refresh_token: "refresh",
+ expires_in: Date.now() + 86400,
+ },
+ },
+ ],
+ "2010-12-01",
+ "2010-12-04",
+ [selectedGoogleCalendar, selectedOfficeCalendar]
+ );
+
+ expect(getGoogleAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [
+ selectedGoogleCalendar,
+ ]);
+ expect(getOfficeAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [
+ selectedOfficeCalendar,
+ ]);
+ expect(result).toEqual([
+ googleAvailability.map((av) => ({
+ ...av,
+ source: "exampleApp",
+ })),
+ officeAvailability.map((av) => ({
+ ...av,
+ source: "exampleApp",
+ })),
+ ]);
+ });
+});
diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx
index 6d38db073ab2e4..e59a0e26ee5605 100644
--- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx
+++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx
@@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import type { TFunction } from "next-i18next";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, useMemo } from "react";
import type { FieldError } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -28,6 +28,7 @@ import getBookingResponsesSchema, {
import { getFullName } from "@calcom/features/form-builder/utils";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
+import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -174,6 +175,12 @@ export const BookEventFormChild = ({
const username = useBookerStore((state) => state.username);
const [expiryTime, setExpiryTime] = useState
();
+ const isPaidEvent = useMemo(() => {
+ if (!eventType?.price) return false;
+ const paymentAppData = getPaymentAppData(eventType);
+ return eventType?.price > 0 || paymentAppData.price > 0;
+ }, [eventType]);
+
type BookingFormValues = {
locationType?: EventLocationType["type"];
responses: z.infer["responses"] | null;
@@ -429,7 +436,7 @@ export const BookEventFormChild = ({
{isInstantMeeting ? (
) : (
<>
@@ -455,7 +462,9 @@ export const BookEventFormChild = ({
{rescheduleUid && bookingData
? t("reschedule")
: renderConfirmNotVerifyEmailButtonCond
- ? t("confirm")
+ ? isPaidEvent
+ ? t("pay_and_book")
+ : t("confirm")
: t("verify_email_email_button")}
>
@@ -543,8 +552,8 @@ const RedirectToInstantMeetingModal = ({ expiryTime }: { expiryTime?: Date }) =>
*/}
{t("please_do_not_close_this_tab")}
-
)}
diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx
index ba33adc2a67a2a..8041aef6d562d2 100644
--- a/packages/features/calendars/weeklyview/components/Calendar.tsx
+++ b/packages/features/calendars/weeklyview/components/Calendar.tsx
@@ -66,7 +66,7 @@ export function Calendar(props: CalendarComponentProps) {
style={{ width: "165%" }}
className="flex h-full max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
-
+
(null);
const utils = trpc.useContext();
const [searchTerm, setSearchTerm] = useState("");
+ const [showImpersonateModal, setShowImpersonateModal] = useState(false);
+ const [selectedUser, setSelectedUser] = useState(null);
const debouncedSearchTerm = useDebounce(searchTerm, 500);
+ const router = useRouter();
const mutation = trpc.viewer.users.delete.useMutation({
onSuccess: async () => {
@@ -203,6 +214,15 @@ function UsersTableBare() {
onClick: () => lockUserAccount.mutate({ userId: user.id, locked: !user.locked }),
icon: Lock,
},
+ {
+ id: "impersonation",
+ label: "Impersonate",
+ onClick: () => {
+ setSelectedUser(user.username);
+ setShowImpersonateModal(true);
+ },
+ icon: VenetianMask,
+ },
{
id: "delete",
label: "Delete",
@@ -227,6 +247,26 @@ function UsersTableBare() {
}}
/>
+ {showImpersonateModal && selectedUser && (
+
+ )}
>
);
}
diff --git a/packages/features/ee/users/pages/users-listing-view.tsx b/packages/features/ee/users/pages/users-listing-view.tsx
index 915530b4bffa79..51b8dfa229fa39 100644
--- a/packages/features/ee/users/pages/users-listing-view.tsx
+++ b/packages/features/ee/users/pages/users-listing-view.tsx
@@ -4,12 +4,11 @@ import NoSSR from "@calcom/core/components/NoSSR";
import { Button, Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
-import LicenseRequired from "../../common/components/LicenseRequired";
import { UsersTable } from "../components/UsersTable";
const DeploymentUsersListPage = () => {
return (
-
+ <>
{
-
+ >
);
};
diff --git a/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx
index 3940079ab5384b..901296a50947fe 100644
--- a/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx
+++ b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx
@@ -29,7 +29,7 @@ export const TroubleshooterSidebar = () => {
const { t } = useLocale();
return (
-
+
diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts
index 55c569fcc341a9..ec1dbe7cc34d88 100644
--- a/packages/lib/CalEventParser.ts
+++ b/packages/lib/CalEventParser.ts
@@ -14,7 +14,7 @@ const translator = short();
export const getWhat = (calEvent: CalendarEvent, t: TFunction) => {
return `
${t("what")}:
-${calEvent.type}
+${calEvent.title}
`;
};
@@ -52,7 +52,7 @@ ${calEvent.organizer.email}
const teamMembers = calEvent.team?.members
? calEvent.team.members.map((member) => {
return `
-${member.name} - ${t("team_member")}
+${member.name} - ${t("team_member")}
${member.email}
`;
})