diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 0bdf52e309..f854c92442 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -16,6 +16,7 @@ import { allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { FormBuilder } from "@calcom/features/form-builder/FormBuilder"; +import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector"; import { classNames } from "@calcom/lib"; import { APP_NAME, CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -159,6 +160,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick +
+
+ +

{t("save")} diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index ae2c399055..649fa67a81 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -17,9 +17,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { HttpError } from "@calcom/lib/http-error"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; +import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { Prisma } from "@calcom/prisma/client"; import type { PeriodType, SchedulingType } from "@calcom/prisma/enums"; -import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import type { + BookerLayoutSettings, + customInputSchema, + EventTypeMetaDataSchema, +} from "@calcom/prisma/zod-utils"; import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -96,6 +101,7 @@ export type FormValues = { hosts: { userId: number; isFixed: boolean }[]; bookingFields: z.infer; availability?: AvailabilityOption; + bookerLayouts: BookerLayoutSettings; }; export type CustomInputParsed = typeof customInputSchema._output; @@ -335,6 +341,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { seatsPerTimeSlotEnabled, // eslint-disable-next-line @typescript-eslint/no-unused-vars minimumBookingNoticeInDurationType, + bookerLayouts, ...input } = values; @@ -348,6 +355,9 @@ const EventTypePage = (props: EventTypeSetupProps) => { if (!isValid) throw new Error(t("event_setup_duration_limits_error")); } + const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null); + if (layoutError) throw new Error(t(layoutError)); + if (metadata?.multipleDuration !== undefined) { if (metadata?.multipleDuration.length < 1) { throw new Error(t("event_setup_multiple_duration_error")); @@ -431,6 +441,9 @@ const EventTypePage = (props: EventTypeSetupProps) => { if (!isValid) throw new Error(t("event_setup_duration_limits_error")); } + const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null); + if (layoutError) throw new Error(t(layoutError)); + if (metadata?.multipleDuration !== undefined) { if (metadata?.multipleDuration.length < 1) { throw new Error(t("event_setup_multiple_duration_error")); diff --git a/apps/web/pages/settings/my-account/appearance.tsx b/apps/web/pages/settings/my-account/appearance.tsx index deadaaaaea..54a0bb0d21 100644 --- a/apps/web/pages/settings/my-account/appearance.tsx +++ b/apps/web/pages/settings/my-account/appearance.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector"; import ThemeLabel from "@calcom/features/settings/ThemeLabel"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { APP_NAME } from "@calcom/lib/constants"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import { trpc } from "@calcom/trpc/react"; import { Alert, @@ -62,6 +64,7 @@ const AppearanceView = () => { brandColor: user?.brandColor || "#292929", darkBrandColor: user?.darkBrandColor || "#fafafa", hideBranding: user?.hideBranding, + defaultBookerLayouts: user?.defaultBookerLayouts, }, }); @@ -76,8 +79,12 @@ const AppearanceView = () => { showToast(t("settings_updated_successfully"), "success"); reset(data); }, - onError: () => { - showToast(t("error_updating_settings"), "error"); + onError: (error) => { + if (error.message) { + showToast(error.message, "error"); + } else { + showToast(t("error_updating_settings"), "error"); + } }, }); @@ -92,6 +99,9 @@ const AppearanceView = () => {
{ + const layoutError = validateBookerLayouts(values.defaultBookerLayouts || null); + if (layoutError) throw new Error(t(layoutError)); + mutation.mutate({ ...values, // Radio values don't support null as values, therefore we convert an empty string @@ -130,6 +140,13 @@ const AppearanceView = () => { /> +
+ +
diff --git a/apps/web/public/bookerlayout_column_view.svg b/apps/web/public/bookerlayout_column_view.svg new file mode 100644 index 0000000000..7b3c11af04 --- /dev/null +++ b/apps/web/public/bookerlayout_column_view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/bookerlayout_month_view.svg b/apps/web/public/bookerlayout_month_view.svg new file mode 100644 index 0000000000..382718e939 --- /dev/null +++ b/apps/web/public/bookerlayout_month_view.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/bookerlayout_week_view.svg b/apps/web/public/bookerlayout_week_view.svg new file mode 100644 index 0000000000..bb80ab2e12 --- /dev/null +++ b/apps/web/public/bookerlayout_week_view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 047dfd8ad9..3a1ea09f1a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -294,6 +294,18 @@ "success": "Success", "failed": "Failed", "password_has_been_reset_login": "Your password has been reset. You can now login with your newly created password.", + "bookerlayout_title": "Layout", + "bookerlayout_default_title": "Default view", + "bookerlayout_description": "You can select multiple and your bookers can switch views.", + "bookerlayout_user_settings_title": "Booking layout", + "bookerlayout_user_settings_description": "You can select multiple and bookers can switch views. This can be overridden on a per event basis.", + "bookerlayout_month_view": "Month", + "bookerlayout_week_view": "Weekly", + "bookerlayout_column_view": "Column", + "bookerlayout_error_min_one_enabled": "At least one layout has to be enabled.", + "bookerlayout_error_default_not_enabled": "The layout you selected as the default view is not part of the enabled layouts.", + "bookerlayout_error_unknown_layout": "The layout you selected is not a valid layout.", + "bookerlayout_override_global_settings": "You can manage this for all your event types in <2>settings / appearance or <6>override for this event only.", "unexpected_error_try_again": "An unexpected error occurred. Try again.", "sunday_time_error": "Invalid time on Sunday", "monday_time_error": "Invalid time on Monday", diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 703012ebbc..e3036c388c 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -7,6 +7,7 @@ import { shallow } from "zustand/shallow"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; +import { BookerLayouts, bookerLayoutOptions } from "@calcom/prisma/zod-utils"; import { AvailableTimeSlots } from "./components/AvailableTimeSlots"; import { BookEventForm } from "./components/BookEventForm"; @@ -49,7 +50,11 @@ const BookerComponent = ({ (state) => [state.selectedTimeslot, state.setSelectedTimeslot], shallow ); - const extraDays = layout === "large_timeslots" ? (isTablet ? 2 : 4) : 0; + const extraDays = layout === BookerLayouts.COLUMN_VIEW ? (isTablet ? 2 : 4) : 0; + const bookerLayouts = event.data?.profile?.bookerLayouts || { + defaultLayout: BookerLayouts.MONTH_VIEW, + enabledLayouts: bookerLayoutOptions, + }; const animationScope = useBookerResizeAnimation(layout, bookerState); @@ -66,13 +71,14 @@ const BookerComponent = ({ eventId: event?.data?.id, rescheduleUid, rescheduleBooking, + layout: bookerLayouts.defaultLayout, }); useEffect(() => { if (isMobile && layout !== "mobile") { setLayout("mobile"); } else if (!isMobile && layout === "mobile") { - setLayout("small_calendar"); + setLayout(BookerLayouts.MONTH_VIEW); } }, [isMobile, setLayout, layout]); @@ -102,24 +108,32 @@ const BookerComponent = ({ // Sets booker size css variables for the size of all the columns. ...getBookerSizeClassNames(layout, bookerState), "bg-default dark:bg-muted grid max-w-full items-start overflow-clip dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row", - layout === "small_calendar" && "border-subtle rounded-md border" + layout === BookerLayouts.MONTH_VIEW && "border-subtle rounded-md border" )}> -
+
+ className={classNames( + "relative z-10 flex", + layout !== BookerLayouts.MONTH_VIEW && "sm:min-h-screen" + )}> - {layout !== "small_calendar" && !(layout === "mobile" && bookerState === "booking") && ( -
- -
- )} + {layout !== BookerLayouts.MONTH_VIEW && + !(layout === "mobile" && bookerState === "booking") && ( +
+ +
+ )}
@@ -128,14 +142,14 @@ const BookerComponent = ({ area="main" className="border-subtle sticky top-0 ml-[-1px] h-full p-5 md:w-[var(--booker-main-width)] md:border-l" {...fadeInLeft} - visible={bookerState === "booking" && layout !== "large_timeslots"}> + visible={bookerState === "booking" && layout !== BookerLayouts.COLUMN_VIEW}> setSelectedTimeslot(null)} /> @@ -146,7 +160,7 @@ const BookerComponent = ({ key="large-calendar" area="main" visible={ - layout === "large_calendar" && + layout === BookerLayouts.WEEK_VIEW && (bookerState === "selecting_date" || bookerState === "selecting_time") } className="border-muted sticky top-0 ml-[-1px] h-full md:border-l" @@ -156,22 +170,22 @@ const BookerComponent = ({ @@ -182,14 +196,14 @@ const BookerComponent = ({ key="logo" className={classNames( "mt-auto mb-6 pt-6 [&_img]:h-[15px]", - layout === "small_calendar" ? "block" : "hidden" + layout === BookerLayouts.MONTH_VIEW ? "block" : "hidden" )}> {!hideBranding ? : null}
setSelectedTimeslot(null)} /> diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index 8c5f00a71d..95e0ce33c7 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -310,7 +310,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
[state.layout, state.setLayout], shallow); const selectedDateString = useBookerStore((state) => state.selectedDate); const addToSelectedDate = useBookerStore((state) => state.addToSelectedDate); - const isSmallCalendar = layout === "small_calendar"; + const isMonthView = layout === BookerLayouts.MONTH_VIEW; const selectedDate = dayjs(selectedDateString); const onLayoutToggle = useCallback( @@ -23,13 +32,19 @@ export function Header({ extraDays, isMobile }: { extraDays: number; isMobile: b [setLayout] ); - if (isMobile) return null; + if (isMobile || !enabledLayouts || enabledLayouts.length <= 1) return null; + + // Only reason we create this component, is because it is used 3 times in this component, + // and this way we can't forget to update one of the props in all places :) + const LayoutToggleWithData = () => ( + + ); // In month view we only show the layout toggle. - if (isSmallCalendar) { + if (isMonthView) { return (
- +
); } @@ -61,7 +76,7 @@ export function Header({ extraDays, isMobile }: { extraDays: number; isMobile: b
- +
{/* This second layout toggle is hidden, but needed to reserve the correct spot in the DIV @@ -71,7 +86,7 @@ export function Header({ extraDays, isMobile }: { extraDays: number; isMobile: b while it actuall already was on place. That's why we have this element twice. */}
- +
@@ -81,33 +96,32 @@ export function Header({ extraDays, isMobile }: { extraDays: number; isMobile: b const LayoutToggle = ({ onLayoutToggle, layout, + enabledLayouts, }: { onLayoutToggle: (layout: string) => void; layout: string; + enabledLayouts?: BookerLayouts[]; }) => { const { t } = useLocale(); + const layoutOptions = useMemo(() => { + return [ + { + value: BookerLayouts.MONTH_VIEW, + label: , + tooltip: t("switch_monthly"), + }, + { + value: BookerLayouts.WEEK_VIEW, + label: , + tooltip: t("switch_weekly"), + }, + { + value: BookerLayouts.COLUMN_VIEW, + label: , + tooltip: t("switch_multiday"), + }, + ].filter((layout) => enabledLayouts?.includes(layout.value as BookerLayouts)); + }, [t, enabledLayouts]); - return ( - , - tooltip: t("switch_monthly"), - }, - { - value: "large_calendar", - label: , - tooltip: t("switch_weekly"), - }, - { - value: "large_timeslots", - label: , - tooltip: t("switch_multiday"), - }, - ]} - /> - ); + return ; }; diff --git a/packages/features/bookings/Booker/components/Section.tsx b/packages/features/bookings/Booker/components/Section.tsx index 34069f29b3..abfcbc76ea 100644 --- a/packages/features/bookings/Booker/components/Section.tsx +++ b/packages/features/bookings/Booker/components/Section.tsx @@ -15,7 +15,7 @@ import type { BookerAreas, BookerLayout } from "../types"; * // Where default is the required default area. * default: "calendar", * // Any optional overrides for different layouts by their layout name. - * large_calendar: "main", + * week_view: "main", * } */ type GridArea = BookerAreas | ({ [key in BookerLayout]?: BookerAreas } & { default: BookerAreas }); diff --git a/packages/features/bookings/Booker/config.ts b/packages/features/bookings/Booker/config.ts index 2465ba4ae5..7c0a9834d1 100644 --- a/packages/features/bookings/Booker/config.ts +++ b/packages/features/bookings/Booker/config.ts @@ -2,6 +2,8 @@ import { cubicBezier, useAnimate } from "framer-motion"; import { useReducedMotion } from "framer-motion"; import { useEffect } from "react"; +import { BookerLayouts } from "@calcom/prisma/zod-utils"; + import type { BookerLayout, BookerState } from "./types"; // Framer motion fade in animation configs. @@ -37,7 +39,7 @@ type ResizeAnimationConfig = { * The object is structured as following: * * The root property of the object: is the name of the layout - * (mobile, small_calendar, large_calendar, large_timeslots) + * (mobile, month_view, week_view, column_view) * * The values of these properties are objects that define the animation for each state of the booker. * The animation have the same properties as you could pass to the animate prop of framer-motion: @@ -58,7 +60,7 @@ export const resizeAnimationConfig: ResizeAnimationConfig = { gridTemplateRows: "auto auto auto auto", }, }, - small_calendar: { + month_view: { default: { width: "calc(var(--booker-meta-width) + var(--booker-main-width))", minHeight: "450px", @@ -82,7 +84,7 @@ export const resizeAnimationConfig: ResizeAnimationConfig = { gridTemplateRows: "auto", }, }, - large_calendar: { + week_view: { default: { width: "100vw", minHeight: "450px", @@ -95,7 +97,7 @@ export const resizeAnimationConfig: ResizeAnimationConfig = { gridTemplateRows: "70px auto", }, }, - large_timeslots: { + column_view: { default: { width: "100vw", minHeight: "450px", @@ -116,18 +118,18 @@ export const getBookerSizeClassNames = (layout: BookerLayout, bookerState: Booke // General sizes, used always "[--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px]", // Small calendar defaults - layout === "small_calendar" && "[--booker-meta-width:240px]", + layout === BookerLayouts.MONTH_VIEW && "[--booker-meta-width:240px]", // Meta column get's wider in booking view to fit the full date on a single row in case // of a multi occurance event. Also makes form less wide, which also looks better. - layout === "small_calendar" && + layout === BookerLayouts.MONTH_VIEW && bookerState === "booking" && "[--booker-main-width:420px] lg:[--booker-meta-width:340px]", // Smaller meta when not in booking view. - layout === "small_calendar" && + layout === BookerLayouts.MONTH_VIEW && bookerState !== "booking" && "[--booker-main-width:480px] lg:[--booker-meta-width:280px]", // Fullscreen view settings. - layout !== "small_calendar" && + layout !== BookerLayouts.MONTH_VIEW && "[--booker-main-width:480px] [--booker-meta-width:340px] lg:[--booker-meta-width:424px]", ]; }; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 44143327db..c05a1d25c9 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { create } from "zustand"; import dayjs from "@calcom/dayjs"; +import { BookerLayouts, bookerLayoutOptions } from "@calcom/prisma/zod-utils"; import type { GetBookingType } from "../lib/get-booking"; import type { BookerState, BookerLayout } from "./types"; @@ -19,6 +20,7 @@ type StoreInitializeType = { eventId: number | undefined; rescheduleUid: string | null; rescheduleBooking: GetBookingType | null | undefined; + layout: BookerLayout; }; type BookerStore = { @@ -88,10 +90,8 @@ type BookerStore = { setFormValues: (values: Record) => void; }; -const validLayouts: BookerLayout[] = ["large_calendar", "large_timeslots", "small_calendar"]; - const checkLayout = (layout: BookerLayout) => { - return validLayouts.find((validLayout) => validLayout === layout); + return bookerLayoutOptions.find((validLayout) => validLayout === layout); }; /** @@ -104,11 +104,11 @@ const checkLayout = (layout: BookerLayout) => { export const useBookerStore = create((set, get) => ({ state: "loading", setState: (state: BookerState) => set({ state }), - layout: checkLayout(getQueryParam("layout") as BookerLayout) || "small_calendar", + layout: checkLayout(getQueryParam("layout") as BookerLayout) || BookerLayouts.MONTH_VIEW, setLayout: (layout: BookerLayout) => { // If we switch to a large layout and don't have a date selected yet, // we selected it here, so week title is rendered properly. - if (["large_calendar", "large_timeslots"].includes(layout) && !get().selectedDate) { + if (["week_view", "column_view"].includes(layout) && !get().selectedDate) { set({ selectedDate: dayjs().format("YYYY-MM-DD") }); } return set({ layout }); @@ -147,14 +147,18 @@ export const useBookerStore = create((set, get) => ({ eventId, rescheduleUid = null, rescheduleBooking = null, + layout, }: StoreInitializeType) => { + const selectedDateInStore = get().selectedDate; + if ( get().username === username && get().eventSlug === eventSlug && get().month === month && get().eventId === eventId && get().rescheduleUid === rescheduleUid && - get().rescheduleBooking?.responses.email === rescheduleBooking?.responses.email + get().rescheduleBooking?.responses.email === rescheduleBooking?.responses.email && + get().layout === layout ) return; set({ @@ -163,7 +167,14 @@ export const useBookerStore = create((set, get) => ({ eventId, rescheduleUid, rescheduleBooking, + layout: layout || BookerLayouts.MONTH_VIEW, + // Preselect today's date in week / column view, since they use this to show the week title. + selectedDate: + selectedDateInStore || ["week_view", "column_view"].includes(layout) + ? dayjs().format("YYYY-MM-DD") + : null, }); + // Unset selected timeslot if user is rescheduling. This could happen // 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 @@ -199,9 +210,10 @@ export const useInitializeBookerStore = ({ eventId, rescheduleUid = null, rescheduleBooking = null, + layout, }: StoreInitializeType) => { const initializeStore = useBookerStore((state) => state.initialize); useEffect(() => { - initializeStore({ username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking }); - }, [initializeStore, username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking]); + initializeStore({ username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking, layout }); + }, [initializeStore, username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking, layout]); }; diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index 221b920460..f02fbe84f2 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -1,3 +1,5 @@ +import type { BookerLayouts } from "@calcom/prisma/zod-utils"; + import type { GetBookingType } from "../lib/get-booking"; export interface BookerProps { @@ -42,5 +44,5 @@ export interface BookerProps { } export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking"; -export type BookerLayout = "small_calendar" | "large_timeslots" | "large_calendar" | "mobile"; +export type BookerLayout = BookerLayouts | "mobile"; export type BookerAreas = "calendar" | "timeslots" | "main" | "meta" | "header"; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 046cc62936..01fc438a80 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -7,6 +7,7 @@ import type { Slots } from "@calcom/features/schedules"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { nameOfDay } from "@calcom/lib/weekday"; +import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { Button, SkeletonText } from "@calcom/ui"; import { useBookerStore } from "../Booker/store"; @@ -34,19 +35,19 @@ export const AvailableTimes = ({ const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]); const hasTimeSlots = !!seatsPerTimeslot; const [layout] = useBookerStore((state) => [state.layout], shallow); - const isLargeTimeslots = layout === "large_timeslots"; + const isColumnView = layout === BookerLayouts.COLUMN_VIEW; const isToday = dayjs().isSame(date, "day"); return (
- + {nameOfDay(i18n.language, Number(date.format("d")), "short")} {date.format("DD")} diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index e7f9694bc9..273fed6487 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -10,10 +10,14 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import type { PrismaClient } from "@calcom/prisma/client"; +import type { BookerLayoutSettings } from "@calcom/prisma/zod-utils"; import { + bookerLayoutOptions, EventTypeMetaDataSchema, customInputSchema, userMetadata as userMetadataSchema, + bookerLayouts, + BookerLayouts, } from "@calcom/prisma/zod-utils"; const publicEventSelect = Prisma.validator()({ @@ -27,7 +31,6 @@ const publicEventSelect = Prisma.validator()({ locations: true, customInputs: true, disableGuests: true, - // @TODO: Could this contain sensitive data? metadata: true, requiresConfirmation: true, recurringEvent: true, @@ -55,6 +58,7 @@ const publicEventSelect = Prisma.validator()({ brandColor: true, darkBrandColor: true, theme: true, + metadata: true, }, }, }, @@ -102,6 +106,11 @@ export const getPublicEvent = async (username: string, eventSlug: string, prisma } } + const defaultEventBookerLayouts = { + enabledLayouts: [...bookerLayoutOptions], + defaultLayout: BookerLayouts.MONTH_VIEW, + } as BookerLayoutSettings; + return { ...defaultEvent, bookingFields: getBookingFieldsWithSystemFields(defaultEvent), @@ -116,6 +125,9 @@ export const getPublicEvent = async (username: string, eventSlug: string, prisma brandColor: users[0].brandColor, darkBrandColor: users[0].darkBrandColor, theme: null, + bookerLayouts: bookerLayouts.parse( + firstUsersMetadata?.defaultBookerLayouts || defaultEventBookerLayouts + ), }, }; } @@ -144,10 +156,13 @@ export const getPublicEvent = async (username: string, eventSlug: string, prisma if (!event) return null; + const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {}); + return { ...event, + bookerLayouts: bookerLayouts.parse(eventMetaData?.bookerLayouts || null), description: markdownToSafeHTML(event.description), - metadata: EventTypeMetaDataSchema.parse(event.metadata || {}), + metadata: eventMetaData, customInputs: customInputSchema.array().parse(event.customInputs || []), locations: privacyFilteredLocations((event.locations || []) as LocationObject[]), bookingFields: getBookingFieldsWithSystemFields(event), @@ -173,6 +188,8 @@ function getProfileFromEvent(event: Event) { if (!username) throw new Error("Event has no username/team slug"); const weekStart = hosts?.[0]?.user?.weekStart || owner?.weekStart || "Monday"; const basePath = team ? `/team/${username}` : `/${username}`; + const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {}); + const userMetaData = userMetadataSchema.parse(profile.metadata || {}); return { username, @@ -183,6 +200,10 @@ function getProfileFromEvent(event: Event) { brandColor: profile.brandColor, darkBrandColor: profile.darkBrandColor, theme: profile.theme, + bookerLayouts: bookerLayouts.parse( + eventMetaData?.bookerLayouts || + (userMetaData && "defaultBookerLayouts" in userMetaData ? userMetaData.defaultBookerLayouts : null) + ), }; } diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 1baee4b82b..da75d8a6f5 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -8,8 +8,8 @@ export type AppFlags = { teams: boolean; webhooks: boolean; workflows: boolean; - "v2-booking-page": boolean; "managed-event-types": boolean; + "booker-layouts": boolean; "google-workspace-directory": boolean; "disable-signup": boolean; }; diff --git a/packages/features/settings/BookerLayoutSelector.tsx b/packages/features/settings/BookerLayoutSelector.tsx new file mode 100644 index 0000000000..f2ccbaf531 --- /dev/null +++ b/packages/features/settings/BookerLayoutSelector.tsx @@ -0,0 +1,194 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { Trans } from "next-i18next"; +import Link from "next/link"; +import { useCallback, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { useFlagMap } from "@calcom/features/flags/context/provider"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { BookerLayouts } from "@calcom/prisma/zod-utils"; +import { bookerLayoutOptions, type BookerLayoutSettings } from "@calcom/prisma/zod-utils"; +import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; +import { Label, Checkbox, Button } from "@calcom/ui"; + +type BookerLayoutSelectorProps = { + title?: string; + description?: string; + name?: string; + /** + * If this boolean is set, it will show the user settings if the event does not have any settings (is null). + * In that case it also will NOT register itself in the form, so that way when submitting the form, the + * values won't be overridden. Because as long as the event's value is null, it will fallback to the user's + * settings. + */ + fallbackToUserSettings?: boolean; +}; + +const defaultFieldName = "metadata.bookerLayouts"; + +export const BookerLayoutSelector = ({ + title, + description, + name, + fallbackToUserSettings, +}: BookerLayoutSelectorProps) => { + const { control, getValues } = useFormContext(); + const { t } = useLocale(); + // Only fallback if event current does not have any settings, and the fallbackToUserSettings boolean is set. + const shouldShowUserSettings = (fallbackToUserSettings && !getValues(name || defaultFieldName)) || false; + + const flags = useFlagMap(); + if (flags["booker-layouts"] !== true) return null; + + return ( + <> + +

+ {description ? description : t("bookerlayout_description")} +

+ ( + + )} + /> + + ); +}; + +type BookerLayoutFieldsProps = { + settings: BookerLayoutSettings; + onChange: (settings: BookerLayoutSettings) => void; + showUserSettings: boolean; +}; + +type BookerLayoutState = { [key in BookerLayouts]: boolean }; + +const BookerLayoutFields = ({ settings, onChange, showUserSettings }: BookerLayoutFieldsProps) => { + const { t } = useLocale(); + const { isLoading: isUserLoading, data: user } = useMeQuery(); + const [isOverridingSettings, setIsOverridingSettings] = useState(false); + + const disableFields = showUserSettings && !isOverridingSettings; + const shownSettings = disableFields ? user?.defaultBookerLayouts : settings; + const defaultLayout = shownSettings?.defaultLayout || BookerLayouts.MONTH_VIEW; + + // Converts the settings array into a boolean object, which can be used as form values. + const toggleValues: BookerLayoutState = bookerLayoutOptions.reduce((layouts, layout) => { + layouts[layout] = !shownSettings?.enabledLayouts + ? true + : shownSettings.enabledLayouts.indexOf(layout) > -1; + return layouts; + }, {} as BookerLayoutState); + + const onLayoutToggleChange = useCallback( + (changedLayout: BookerLayouts, checked: boolean) => { + onChange({ + enabledLayouts: Object.keys(toggleValues).filter((layout) => { + if (changedLayout === layout) return checked === true; + return toggleValues[layout as BookerLayouts] === true; + }) as BookerLayouts[], + defaultLayout, + }); + }, + [defaultLayout, onChange, toggleValues] + ); + + const onDefaultLayoutChange = useCallback( + (newDefaultLayout: BookerLayouts) => { + onChange({ + enabledLayouts: Object.keys(toggleValues).filter( + (layout) => toggleValues[layout as BookerLayouts] === true + ) as BookerLayouts[], + defaultLayout: newDefaultLayout, + }); + }, + [toggleValues, onChange] + ); + + const onOverrideSettings = () => { + setIsOverridingSettings(true); + // Sent default layout settings to form, otherwise it would still have 'null' as it's value. + if (user?.defaultBookerLayouts) onChange(user.defaultBookerLayouts); + }; + + return ( +
+
+ {bookerLayoutOptions.map((layout) => ( +
+ +
+ ))} +
+
+ + onDefaultLayoutChange(layout)}> + {bookerLayoutOptions.map((layout) => ( + + {t(`bookerlayout_${layout}`)} + + + ))} + +
+ {disableFields && ( +

+ + You can manage this for all your event types in{" "} + + settings / appearance + {" "} + or{" "} + + . + +

+ )} +
+ ); +}; diff --git a/packages/lib/validateBookerLayouts.ts b/packages/lib/validateBookerLayouts.ts new file mode 100644 index 0000000000..ef8fdc62e5 --- /dev/null +++ b/packages/lib/validateBookerLayouts.ts @@ -0,0 +1,29 @@ +import { bookerLayoutOptions, type BookerLayoutSettings } from "@calcom/prisma/zod-utils"; + +export const validateBookerLayouts = (settings: BookerLayoutSettings) => { + // Allow layouts to be null, as per database defaults. + if (settings === null) return; + + // At least one layout should be enabled. + const atLeastOneLayoutIsEnabled = settings?.enabledLayouts.length > 0; + if (!atLeastOneLayoutIsEnabled) return "bookerlayout_error_min_one_enabled"; + + // Default layout should also be enabled. + const defaultLayoutIsInEnabledLayouts = settings?.enabledLayouts.find( + (layout) => layout === settings.defaultLayout + ); + + if (!defaultLayoutIsInEnabledLayouts) return "bookerlayout_error_default_not_enabled"; + + // Validates that users don't try to insert an unknown layout into DB. + const enabledLayoutsDoesntContainUnknownLayout = settings?.enabledLayouts.every((layout) => + bookerLayoutOptions.includes(layout) + ); + const defaultLayoutIsKnown = bookerLayoutOptions.includes(settings.defaultLayout); + + if (!enabledLayoutsDoesntContainUnknownLayout || !defaultLayoutIsKnown) { + return "bookerlayout_error_unknown_layout"; + } +}; + +// export const getEnabledLayouts = diff --git a/packages/prisma/migrations/20230524105015_added_newbooker_feature_flag/migration.sql b/packages/prisma/migrations/20230524105015_added_newbooker_feature_flag/migration.sql new file mode 100644 index 0000000000..f1f3f50944 --- /dev/null +++ b/packages/prisma/migrations/20230524105015_added_newbooker_feature_flag/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'booker-layouts', + false, + 'Enable new booker configuration settings for all users', + 'EXPERIMENT' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 6e36a71516..e655655f13 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -30,6 +30,33 @@ export enum Frequency { SECONDLY = 6, } +export enum BookerLayouts { + MONTH_VIEW = "month_view", + WEEK_VIEW = "week_view", + COLUMN_VIEW = "column_view", +} + +export const bookerLayoutOptions = [ + BookerLayouts.MONTH_VIEW, + BookerLayouts.WEEK_VIEW, + BookerLayouts.COLUMN_VIEW, +]; + +const layoutOptions = z.union([ + z.literal(bookerLayoutOptions[0]), + z.literal(bookerLayoutOptions[1]), + z.literal(bookerLayoutOptions[2]), +]); + +export const bookerLayouts = z + .object({ + enabledLayouts: z.array(layoutOptions), + defaultLayout: layoutOptions, + }) + .nullable(); + +export type BookerLayoutSettings = z.infer; + export const RequiresConfirmationThresholdUnits: z.ZodType = z.enum(["hours", "minutes"]); export const EventTypeMetaDataSchema = z @@ -67,6 +94,7 @@ export const EventTypeMetaDataSchema = z useHostSchedulesForTeamEvent: z.boolean().optional(), }) .optional(), + bookerLayouts: bookerLayouts.optional(), }) .nullable(); @@ -268,6 +296,7 @@ export const userMetadata = z appLink: z.string().optional(), }) .optional(), + defaultBookerLayouts: bookerLayouts.optional(), }) .nullable(); diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index eccd8fe4dc..663c2900fd 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -2,6 +2,7 @@ import type { Session } from "next-auth"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; +import { userMetadata } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; import type { Maybe } from "@trpc/server"; @@ -81,6 +82,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe { theme: user.theme, hideBranding: user.hideBranding, metadata: user.metadata, + defaultBookerLayouts: user.defaultBookerLayouts, allowDynamicBooking: user.allowDynamicBooking, }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 67983b3b15..5b4348d6fb 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -30,6 +30,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) ...input, metadata: input.metadata as Prisma.InputJsonValue, }; + let isPremiumUsername = false; if (input.username) { const username = slugify(input.username); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 2c3cdd69d5..898b4bea67 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -6,6 +6,8 @@ import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server"; import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; import { validateIntervalLimitOrder } from "@calcom/lib"; import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server"; +import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -111,6 +113,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { data.offsetStart = offsetStart; } + const bookerLayoutsError = validateBookerLayouts(input.metadata?.bookerLayouts || null); + if (bookerLayoutsError) { + const t = await getTranslation("en", "common"); + throw new TRPCError({ code: "BAD_REQUEST", message: t(bookerLayoutsError) }); + } + if (schedule) { // Check that the schedule belongs to the user const userScheduleQuery = await ctx.prisma.schedule.findFirst({ diff --git a/packages/ui/components/form/toggleGroup/ToggleGroup.tsx b/packages/ui/components/form/toggleGroup/ToggleGroup.tsx index dc5833302d..2ddf749cef 100644 --- a/packages/ui/components/form/toggleGroup/ToggleGroup.tsx +++ b/packages/ui/components/form/toggleGroup/ToggleGroup.tsx @@ -1,6 +1,5 @@ import * as RadixToggleGroup from "@radix-ui/react-toggle-group"; import type { ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; import { classNames } from "@calcom/lib"; import { Tooltip } from "@calcom/ui"; @@ -34,58 +33,29 @@ const OptionalTooltipWrapper = ({ }; export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: ToggleGroupProps) => { - const [value, setValue] = useState(props.defaultValue); - const activeRef = useRef(null); - - useEffect(() => { - if (value && onValueChange) onValueChange(value); - }, [value, onValueChange]); - return ( <> - {/* Active toggle. It's a separate element so we can animate it nicely. */} - {options.map((option) => ( { - if (node && value === option.value) { - // Sets position of active toggle element with inline styles. - // This way we trigger as little rerenders as possible. - if (!activeRef.current || activeRef?.current.style.left === `${node.offsetLeft}px`) return; - activeRef.current.style.left = `${node.offsetLeft}px`; - activeRef.current.style.width = `${node.offsetWidth}px`; - } - return node; - }}> + )}>
{option.iconLeft && {option.iconLeft}} {option.label}