feat: event settings booker layout toggle (#9082)

* 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 <zomars@me.com>

* 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 <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Jeroen Reumkens 2023-06-06 17:31:43 +02:00 committed by GitHub
parent 40c5d5871e
commit f31165b442
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 534 additions and 115 deletions

View File

@ -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<EventTypeSetupProps,
}
/>
</div>
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
<div>
<BookerLayoutSelector fallbackToUserSettings />
</div>
<hr className="border-subtle" />
<FormBuilder
title={t("booking_questions_title")}

View File

@ -390,7 +390,7 @@ function EventTypeSingleLayout({
<Button
className="ml-4 lg:ml-0"
type="submit"
loading={formMethods.formState.isSubmitting || isUpdateMutationLoading}
loading={isUpdateMutationLoading}
data-testid="update-eventtype"
form="event-type-form">
{t("save")}

View File

@ -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<typeof eventTypeBookingFields>;
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"));

View File

@ -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 = () => {
<Form
form={formMethods}
handleSubmit={(values) => {
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 = () => {
/>
</div>
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
<BookerLayoutSelector
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
/>
<hr className="border-subtle my-8 border" />
<div className="mb-6 flex items-center text-sm">
<div>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -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</2> or <6>override for this event only</6>.",
"unexpected_error_try_again": "An unexpected error occurred. Try again.",
"sunday_time_error": "Invalid time on Sunday",
"monday_time_error": "Invalid time on Monday",

View File

@ -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"
)}>
<AnimatePresence>
<BookerSection area="header">
<Header extraDays={extraDays} isMobile={isMobile} />
<Header
enabledLayouts={bookerLayouts.enabledLayouts}
extraDays={extraDays}
isMobile={isMobile}
/>
</BookerSection>
<StickyOnDesktop
key="meta"
className={classNames("relative z-10 flex", layout !== "small_calendar" && "sm:min-h-screen")}>
className={classNames(
"relative z-10 flex",
layout !== BookerLayouts.MONTH_VIEW && "sm:min-h-screen"
)}>
<BookerSection
area="meta"
className="max-w-screen flex w-full flex-col md:w-[var(--booker-meta-width)]">
<EventMeta />
{layout !== "small_calendar" && !(layout === "mobile" && bookerState === "booking") && (
<div className=" mt-auto p-5">
<DatePicker />
</div>
)}
{layout !== BookerLayouts.MONTH_VIEW &&
!(layout === "mobile" && bookerState === "booking") && (
<div className=" mt-auto p-5">
<DatePicker />
</div>
)}
</BookerSection>
</StickyOnDesktop>
@ -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}>
<BookEventForm onCancel={() => setSelectedTimeslot(null)} />
</BookerSection>
<BookerSection
key="datepicker"
area="main"
visible={bookerState !== "booking" && layout === "small_calendar"}
visible={bookerState !== "booking" && layout === BookerLayouts.MONTH_VIEW}
{...fadeInLeft}
initial="visible"
className="md:border-subtle ml-[-1px] h-full flex-shrink p-5 md:border-l lg:w-[var(--booker-main-width)]">
@ -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 = ({
<BookerSection
key="timeslots"
area={{ default: "main", small_calendar: "timeslots" }}
area={{ default: "main", month_view: "timeslots" }}
visible={
(layout !== "large_calendar" && bookerState === "selecting_time") ||
layout === "large_timeslots"
(layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
layout === BookerLayouts.COLUMN_VIEW
}
className={classNames(
"border-subtle flex h-full w-full flex-col p-5 pb-0 md:border-l",
layout === "small_calendar" &&
layout === BookerLayouts.MONTH_VIEW &&
"scroll-bar h-full overflow-auto md:w-[var(--booker-timeslots-width)]",
layout !== "small_calendar" && "sticky top-0"
layout !== BookerLayouts.MONTH_VIEW && "sticky top-0"
)}
ref={timeslotsRef}
{...fadeInLeft}>
<AvailableTimeSlots
extraDays={extraDays}
limitHeight={layout === "small_calendar"}
limitHeight={layout === BookerLayouts.MONTH_VIEW}
seatsPerTimeslot={event.data?.seatsPerTimeSlot}
/>
</BookerSection>
@ -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 ? <PoweredBy logoOnly /> : null}
</m.span>
</div>
<BookFormAsModal
visible={layout === "large_timeslots" && bookerState === "booking"}
visible={layout === BookerLayouts.COLUMN_VIEW && bookerState === "booking"}
onCancel={() => setSelectedTimeslot(null)}
/>
</>

View File

@ -310,7 +310,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
<div data-testid="booking-fail">
<Alert
ref={errorRef}
className="mt-2"
className="my-2"
severity="info"
title={rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
message={getError(

View File

@ -1,9 +1,10 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { Button, ButtonGroup, ToggleGroup } from "@calcom/ui";
import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
@ -11,11 +12,19 @@ import { TimeFormatToggle } from "../../components/TimeFormatToggle";
import { useBookerStore } from "../store";
import type { BookerLayout } from "../types";
export function Header({ extraDays, isMobile }: { extraDays: number; isMobile: boolean }) {
export function Header({
extraDays,
isMobile,
enabledLayouts,
}: {
extraDays: number;
isMobile: boolean;
enabledLayouts: BookerLayouts[];
}) {
const [layout, setLayout] = useBookerStore((state) => [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 = () => (
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
);
// In month view we only show the layout toggle.
if (isSmallCalendar) {
if (isMonthView) {
return (
<div className="fixed top-3 right-3 z-10">
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} />
<LayoutToggleWithData />
</div>
);
}
@ -61,7 +76,7 @@ export function Header({ extraDays, isMobile }: { extraDays: number; isMobile: b
<div className="ml-auto flex gap-3">
<TimeFormatToggle />
<div className="fixed top-4 right-4">
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} />
<LayoutToggleWithData />
</div>
{/*
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.
*/}
<div className="pointer-events-none opacity-0" aria-hidden>
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} />
<LayoutToggleWithData />
</div>
</div>
</div>
@ -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: <Calendar width="16" height="16" />,
tooltip: t("switch_monthly"),
},
{
value: BookerLayouts.WEEK_VIEW,
label: <Grid width="16" height="16" />,
tooltip: t("switch_weekly"),
},
{
value: BookerLayouts.COLUMN_VIEW,
label: <Columns width="16" height="16" />,
tooltip: t("switch_multiday"),
},
].filter((layout) => enabledLayouts?.includes(layout.value as BookerLayouts));
}, [t, enabledLayouts]);
return (
<ToggleGroup
onValueChange={onLayoutToggle}
defaultValue={layout}
options={[
{
value: "small_calendar",
label: <Calendar width="16" height="16" />,
tooltip: t("switch_monthly"),
},
{
value: "large_calendar",
label: <Grid width="16" height="16" />,
tooltip: t("switch_weekly"),
},
{
value: "large_timeslots",
label: <Columns width="16" height="16" />,
tooltip: t("switch_multiday"),
},
]}
/>
);
return <ToggleGroup onValueChange={onLayoutToggle} defaultValue={layout} options={layoutOptions} />;
};

View File

@ -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 });

View File

@ -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]",
];
};

View File

@ -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<string, any>) => 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<BookerStore>((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<BookerStore>((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<BookerStore>((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]);
};

View File

@ -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";

View File

@ -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 (
<div className={classNames("text-default", className)}>
<header className="bg-default before:bg-default dark:bg-muted dark:before:bg-muted mb-5 flex w-full flex-row items-center font-medium">
<span className={classNames(isLargeTimeslots && "w-full text-center")}>
<span className={classNames(isColumnView && "w-full text-center")}>
<span className="text-emphasis font-semibold">
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
</span>
<span
className={classNames(
isLargeTimeslots && isToday ? "bg-brand-default text-brand ml-2" : "text-default",
isColumnView && isToday ? "bg-brand-default text-brand ml-2" : "text-default",
"inline-flex items-center justify-center rounded-3xl px-1 pt-0.5 text-sm font-medium"
)}>
{date.format("DD")}

View File

@ -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<Prisma.EventTypeSelect>()({
@ -27,7 +31,6 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
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<Prisma.EventTypeSelect>()({
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)
),
};
}

View File

@ -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;
};

View File

@ -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 (
<>
<Label className="mb-0">{title ? title : t("bookerlayout_title")}</Label>
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">
{description ? description : t("bookerlayout_description")}
</p>
<Controller
// If the event does not have any settings, we don't want to register this field in the form.
// That way the settings won't get saved into the event on save, but remain null. Thus keep using
// the global user's settings.
control={shouldShowUserSettings ? undefined : control}
name={name || defaultFieldName}
render={({ field: { value, onChange } }) => (
<BookerLayoutFields
showUserSettings={shouldShowUserSettings}
settings={value}
onChange={onChange}
/>
)}
/>
</>
);
};
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 (
<div className="my-4 space-y-5">
<div
className={classNames(
"flex flex-col gap-5 transition-opacity sm:flex-row sm:gap-3",
disableFields && "pointer-events-none opacity-40",
disableFields && isUserLoading && "animate-pulse"
)}>
{bookerLayoutOptions.map((layout) => (
<div className="w-full" key={layout}>
<label>
<img
className="mb-3 w-full max-w-none cursor-pointer"
src={`/bookerlayout_${layout}.svg`}
alt="Layout preview"
/>
<Checkbox
value={layout}
name={`bookerlayout_${layout}`}
description={t(`bookerlayout_${layout}`)}
checked={toggleValues[layout]}
onChange={(ev) => onLayoutToggleChange(layout, ev.target.checked)}
/>
</label>
</div>
))}
</div>
<div
className={classNames(
"transition-opacity",
disableFields && "pointer-events-none opacity-40",
disableFields && isUserLoading && "animate-pulse"
)}>
<Label>{t("bookerlayout_default_title")}</Label>
<RadioGroup.Root
key={defaultLayout}
className="border-default flex w-full gap-2 rounded-md border p-1"
defaultValue={defaultLayout}
onValueChange={(layout: BookerLayouts) => onDefaultLayoutChange(layout)}>
{bookerLayoutOptions.map((layout) => (
<RadioGroup.Item
className="aria-checked:bg-emphasis hover:bg-subtle focus:bg-subtle w-full rounded-[4px] p-1 text-sm transition-colors"
key={layout}
value={layout}>
{t(`bookerlayout_${layout}`)}
<RadioGroup.Indicator />
</RadioGroup.Item>
))}
</RadioGroup.Root>
</div>
{disableFields && (
<p className="text-sm">
<Trans i18nKey="bookerlayout_override_global_settings">
You can manage this for all your event types in{" "}
<Link href="/settings/my-account/appearance" className="underline">
settings / appearance
</Link>{" "}
or{" "}
<Button
onClick={onOverrideSettings}
color="minimal"
className="p-0 font-normal underline hover:bg-transparent focus-visible:bg-transparent">
override for this event only
</Button>
.
</Trans>
</p>
)}
</div>
);
};

View File

@ -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 =

View File

@ -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;

View File

@ -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<typeof bookerLayouts>;
export const RequiresConfirmationThresholdUnits: z.ZodType<UnitTypeLongPlural> = 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();

View File

@ -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<S
return null;
}
const userMetaData = userMetadata.parse(user.metadata || {});
const rawAvatar = user.avatar;
// This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url
user.avatar = rawAvatar ? `${WEBAPP_URL}/${user.username}/avatar.png` : defaultAvatarSrc({ email });
@ -92,6 +94,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
email,
username,
locale,
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
};
}

View File

@ -37,6 +37,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
theme: user.theme,
hideBranding: user.hideBranding,
metadata: user.metadata,
defaultBookerLayouts: user.defaultBookerLayouts,
allowDynamicBooking: user.allowDynamicBooking,
};
};

View File

@ -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);

View File

@ -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({

View File

@ -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<string | undefined>(props.defaultValue);
const activeRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (value && onValueChange) onValueChange(value);
}, [value, onValueChange]);
return (
<>
<RadixToggleGroup.Root
type="single"
{...props}
onValueChange={setValue}
onValueChange={onValueChange}
className={classNames(
"min-h-9 bg-muted border-default relative inline-flex gap-0.5 rounded-md border p-1",
props.className,
isFullWidth && "w-full"
)}>
{/* Active toggle. It's a separate element so we can animate it nicely. */}
<span
ref={activeRef}
aria-hidden
className={classNames(
"bg-emphasis absolute top-[4px] bottom-[4px] left-0 z-[0] rounded-[4px]",
// Disable the animation until after initial render, that way when the component would
// rerender the styles are immediately set and we don't see a flash moving the element
// into position because of the animation.
activeRef?.current && "transition-all"
)}
/>
{options.map((option) => (
<OptionalTooltipWrapper key={option.value} tooltipText={option.tooltip}>
<RadixToggleGroup.Item
disabled={option.disabled}
value={option.value}
className={classNames(
"relative rounded-[4px] px-3 py-1 text-sm leading-tight",
"aria-checked:bg-subtle relative rounded-[4px] px-3 py-1 text-sm leading-tight transition-colors",
option.disabled
? "text-gray-400 hover:cursor-not-allowed"
: "text-default [&[aria-checked='false']]:hover:bg-subtle",
: "text-default [&[aria-checked='false']]:hover:bg-emphasis",
isFullWidth && "w-full"
)}
ref={(node) => {
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;
}}>
)}>
<div className="item-center flex justify-center ">
{option.iconLeft && <span className="mr-2 flex h-4 w-4 items-center">{option.iconLeft}</span>}
{option.label}