From cb567a91c562af64f80ae15c2fcfc46305c4a296 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:39:36 +0200 Subject: [PATCH] feat: add option to hide duration selector in booking page for multiple durations (#27033) * feat: add option to hide duration selector in booking page for multiple durations - Add 'hideDurationSelectorInBookingPage' field to event type metadata schema - Add checkbox under Default Duration select in event type settings - Modify Duration component to hide selector when setting is enabled - URL params can still set duration when selector is hidden - Add i18n translation for the new checkbox label - Add e2e tests for the new functionality Co-Authored-By: ali@cal.com * fix: rename checkbox label to 'Hide duration selector' Co-Authored-By: ali@cal.com * fix: replace text locator with data-testid in E2E test Replace `text=Multiple duration` locator with `page.getByTestId("event-types").locator('a[title="Multiple duration"]')` to follow E2E test best practices. Co-Authored-By: unknown <> * fix: add data-testid to hide duration selector checkbox for E2E tests Co-Authored-By: ali@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ali@cal.com Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> --- .../components/event-meta/Duration.tsx | 7 + .../components/tabs/setup/EventSetupTab.tsx | 17 ++- .../playwright/hide-duration-selector.e2e.ts | 120 ++++++++++++++++++ apps/web/public/static/locales/en/common.json | 1 + packages/prisma/zod-utils.ts | 1 + 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 apps/web/playwright/hide-duration-selector.e2e.ts diff --git a/apps/web/modules/bookings/components/event-meta/Duration.tsx b/apps/web/modules/bookings/components/event-meta/Duration.tsx index 024e1b764d..0e174cc303 100644 --- a/apps/web/modules/bookings/components/event-meta/Duration.tsx +++ b/apps/web/modules/bookings/components/event-meta/Duration.tsx @@ -93,6 +93,13 @@ export const EventDuration = ({ return <>{getDurationFormatted(event.length, t)}; const durations = event?.metadata?.multipleDuration || [15, 30, 60, 90]; + const hideDurationSelector = event?.metadata?.hideDurationSelectorInBookingPage; + + // When duration selector is hidden, show only the selected/default duration as text + // URL params can still set the duration, but the user cannot change it via UI + if (hideDurationSelector) { + return <>{getDurationFormatted(selectedDuration || event.length, t)}; + } return selectedDuration ? (
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 c06675105e..f9e1924e69 100644 --- a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx +++ b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx @@ -15,7 +15,7 @@ import { slugify } from "@calcom/lib/slugify"; import turndown from "@calcom/lib/turndownService"; import classNames from "@calcom/ui/classNames"; import { Editor } from "@calcom/ui/components/editor"; -import { Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form"; +import { CheckboxField, Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form"; import { Skeleton } from "@calcom/ui/components/skeleton"; import Locations from "@calcom/web/modules/event-types/components/locations/Locations"; import { useState } from "react"; @@ -276,6 +276,21 @@ export const EventSetupTab = ( }} />
+
+ ( + onChange(e.target.checked)} + description={t("hide_duration_selector_in_booking_page")} + disabled={lengthLockedProps.disabled} + /> + )} + /> +
) : ( users.deleteAll()); + +test.describe("Hide duration selector in booking page", () => { + test("duration selector is hidden when setting is enabled but URL params still work", async ({ + page, + users, + }) => { + const user = await users.create(); + await user.apiLogin(); + + // Get the multiple duration event type + const eventType = user.eventTypes.find((e) => e.slug === "multiple-duration"); + expect(eventType).toBeDefined(); + + await test.step("enable hide duration selector setting", async () => { + await page.goto("/event-types"); + await page.waitForSelector('[data-testid="event-types"]'); + await page.getByTestId("event-types").locator('a[title="Multiple duration"]').click(); + await page.waitForSelector('[data-testid="event-title"]'); + + // Find and check the hide duration selector checkbox + const checkbox = page.getByTestId("hide-duration-selector-checkbox"); + await checkbox.check(); + + // Save the event type + await page.locator('[data-testid="update-eventtype"]').click(); + await page.waitForResponse("/api/trpc/eventTypesHeavy/update?batch=1"); + }); + + await test.step("verify duration selector is hidden on booking page", async () => { + await page.goto(`/${user.username}/multiple-duration`); + + // Wait for the page to load + await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" }); + + // The duration selector buttons should NOT be visible + await expect(page.getByTestId("multiple-choice-30mins")).not.toBeVisible(); + await expect(page.getByTestId("multiple-choice-60mins")).not.toBeVisible(); + await expect(page.getByTestId("multiple-choice-90mins")).not.toBeVisible(); + }); + + await test.step("verify URL param can still set duration when selector is hidden", async () => { + // Navigate with duration param + await page.goto(`/${user.username}/multiple-duration?duration=60`); + + // Wait for the page to load + await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" }); + + // The duration selector should still be hidden + await expect(page.getByTestId("multiple-choice-60mins")).not.toBeVisible(); + + // Select a time slot and book + await selectFirstAvailableTimeSlotNextMonth(page); + + await page.fill('[name="name"]', "Test User"); + await page.fill('[name="email"]', "test@example.com"); + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + // Verify the booking was made with the correct duration (60 mins = 1 hour) + // The booking title or details should reflect the 60 min duration + }); + }); + + test("duration selector is visible when setting is disabled", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + + // The default multiple-duration event type should have the selector visible + await page.goto(`/${user.username}/multiple-duration`); + + // Wait for the page to load + await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" }); + + // The duration selector buttons should be visible + await expect(page.getByTestId("multiple-choice-30mins")).toBeVisible(); + await expect(page.getByTestId("multiple-choice-60mins")).toBeVisible(); + await expect(page.getByTestId("multiple-choice-90mins")).toBeVisible(); + }); + + test("default duration is shown when selector is hidden and no URL param", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + + // Get the multiple duration event type + const eventType = user.eventTypes.find((e) => e.slug === "multiple-duration"); + expect(eventType).toBeDefined(); + + await test.step("enable hide duration selector setting", async () => { + await page.goto("/event-types"); + await page.waitForSelector('[data-testid="event-types"]'); + await page.getByTestId("event-types").locator('a[title="Multiple duration"]').click(); + await page.waitForSelector('[data-testid="event-title"]'); + + // Find and check the hide duration selector checkbox + const checkbox = page.getByTestId("hide-duration-selector-checkbox"); + await checkbox.check(); + + // Save the event type + await page.locator('[data-testid="update-eventtype"]').click(); + await page.waitForResponse("/api/trpc/eventTypesHeavy/update?batch=1"); + }); + + await test.step("verify default duration is displayed", async () => { + await page.goto(`/${user.username}/multiple-duration`); + + // Wait for the page to load + await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" }); + + // The duration text should be visible (default is 30 mins) + // Since the selector is hidden, we should see the duration as text + await expect(page.locator('[data-testid="event-meta"]')).toContainText("30"); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c700e413d6..6f9dd92a8d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -881,6 +881,7 @@ "available_durations": "Available durations", "default_duration": "Default duration", "default_duration_no_options": "Please choose available durations first", + "hide_duration_selector_in_booking_page": "Hide duration selector", "multiple_duration_mins": "{{count}} $t(minute_timeUnit)", "multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)", "multiple_duration_timeUnit_short": "{{count}}$t({{unit}}_short)", diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 9dd03b80d5..60268124ed 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -218,6 +218,7 @@ const _eventTypeMetaDataSchemaWithoutApps = z.object({ smartContractAddress: z.string().optional(), blockchainId: z.number().optional(), multipleDuration: z.number().array().optional(), + hideDurationSelectorInBookingPage: z.boolean().optional(), giphyThankYouPage: z.string().optional(), additionalNotesRequired: z.boolean().optional(), disableSuccessPage: z.boolean().optional(),