From f31165b44299f690c123ff7d0872a618b595a5a1 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Tue, 6 Jun 2023 17:31:43 +0200 Subject: [PATCH] feat: event settings booker layout toggle (#9082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP for adding booker layout toggle in event settings pages * Prevent form error from getting form stuck in loading state * Fixed types for bookerlayouts settings and preselect correct layout in booker * Added defaultlayout settings to profile too, and use that in booker plus as default for events. * Made layout settings responsive * Added feature toggle for new layout settings * Fixed user builder for tests by adding defaultlyotu * Show toggles on booker for layout switch based on selected layouts. Also added a small fix for the settings toggles to preselect the correct toggle for defaultlayout when user profile settings are used. * Used zod parse to fix type errors. * Fix unit test * Set selected date to today in datepicker when week or column view is default layout. It uses that date to show in the title bar. * Moved booker layout settings to event and user meta data instead of new db column. * Converted booker layout strings into an enum. * Renamed booker layouts feature flag and deleted unused v2 booker feature flag. * Update packages/trpc/server/routers/viewer/eventTypes/update.handler.ts Co-authored-by: Omar López * Fix import * Fix lint warnings in EventTypeSingleLayout * Fixed bug where when selected date was passed via query param page booking form wouldn't automatically show up. It would still serve you the date selection. This should fix e2e tests. * Fixed layout header. * Enabled booking layout toggle feature flag. --------- Co-authored-by: Peer Richelsen Co-authored-by: Omar López Co-authored-by: Alex van Andel --- .../components/eventtype/EventAdvancedTab.tsx | 5 + .../eventtype/EventTypeSingleLayout.tsx | 2 +- apps/web/pages/event-types/[type]/index.tsx | 15 +- .../pages/settings/my-account/appearance.tsx | 21 +- apps/web/public/bookerlayout_column_view.svg | 1 + apps/web/public/bookerlayout_month_view.svg | 60 ++++++ apps/web/public/bookerlayout_week_view.svg | 1 + apps/web/public/static/locales/en/common.json | 12 ++ packages/features/bookings/Booker/Booker.tsx | 56 +++-- .../BookEventForm/BookEventForm.tsx | 2 +- .../bookings/Booker/components/Header.tsx | 76 ++++--- .../bookings/Booker/components/Section.tsx | 2 +- packages/features/bookings/Booker/config.ts | 18 +- packages/features/bookings/Booker/store.ts | 28 ++- packages/features/bookings/Booker/types.ts | 4 +- .../bookings/components/AvailableTimes.tsx | 7 +- .../features/eventtypes/lib/getPublicEvent.ts | 25 ++- packages/features/flags/config.ts | 2 +- .../settings/BookerLayoutSelector.tsx | 194 ++++++++++++++++++ packages/lib/validateBookerLayouts.ts | 29 +++ .../migration.sql | 9 + packages/prisma/zod-utils.ts | 29 +++ .../server/middlewares/sessionMiddleware.ts | 3 + .../routers/loggedInViewer/me.handler.ts | 1 + .../loggedInViewer/updateProfile.handler.ts | 1 + .../viewer/eventTypes/update.handler.ts | 8 + .../form/toggleGroup/ToggleGroup.tsx | 38 +--- 27 files changed, 534 insertions(+), 115 deletions(-) create mode 100644 apps/web/public/bookerlayout_column_view.svg create mode 100644 apps/web/public/bookerlayout_month_view.svg create mode 100644 apps/web/public/bookerlayout_week_view.svg create mode 100644 packages/features/settings/BookerLayoutSelector.tsx create mode 100644 packages/lib/validateBookerLayouts.ts create mode 100644 packages/prisma/migrations/20230524105015_added_newbooker_feature_flag/migration.sql 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}