Merge branch 'main' into org-subTeams-delete-members
commit
da4dabc7cd
|
@ -0,0 +1,39 @@
|
|||
export {};
|
||||
// TODO: @sean - I can't run E2E locally - causing me a lot of pain to try and debug.
|
||||
// Will tackle in follow up once i reset my system.
|
||||
// test.describe("User can overlay their calendar", async () => {
|
||||
// test.afterAll(async ({ users }) => {
|
||||
// await users.deleteAll();
|
||||
// });
|
||||
// test("Continue with Cal.com flow", async ({ page, users }) => {
|
||||
// await users.create({
|
||||
// username: "overflow-user-test",
|
||||
// });
|
||||
// await test.step("toggles overlay without a session", async () => {
|
||||
// await page.goto("/overflow-user-test/30-min");
|
||||
// const switchLocator = page.locator(`[data-testid=overlay-calendar-switch]`);
|
||||
// await switchLocator.click();
|
||||
// const continueWithCalCom = page.locator(`[data-testid=overlay-calendar-continue-button]`);
|
||||
// await expect(continueWithCalCom).toBeVisible();
|
||||
// await continueWithCalCom.click();
|
||||
// });
|
||||
// // log in trail user
|
||||
// await test.step("Log in and return to booking page", async () => {
|
||||
// const user = await users.create();
|
||||
// await user.login();
|
||||
// // Expect page to be redirected to the test users booking page
|
||||
// await page.waitForURL("/overflow-user-test/30-min");
|
||||
// });
|
||||
// await test.step("Expect settings cog to be visible when session exists", async () => {
|
||||
// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`);
|
||||
// await expect(settingsCog).toBeVisible();
|
||||
// });
|
||||
// await test.step("Settings should so no calendars connected", async () => {
|
||||
// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`);
|
||||
// await settingsCog.click();
|
||||
// await page.waitForLoadState("networkidle");
|
||||
// const emptyScreenLocator = page.locator(`[data-testid=empty-screen]`);
|
||||
// await expect(emptyScreenLocator).toBeVisible();
|
||||
// });
|
||||
// });
|
||||
// });
|
|
@ -268,6 +268,7 @@
|
|||
"set_availability": "Set your availability",
|
||||
"availability_settings": "Availability Settings",
|
||||
"continue_without_calendar": "Continue without calendar",
|
||||
"continue_with": "Continue with {{appName}}",
|
||||
"connect_your_calendar": "Connect your calendar",
|
||||
"connect_your_video_app": "Connect your video apps",
|
||||
"connect_your_video_app_instructions": "Connect your video apps to use them on your event types.",
|
||||
|
@ -2085,5 +2086,8 @@
|
|||
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
|
||||
"add_new_client": "Add new Client",
|
||||
"this_app_is_not_setup_already": "This app has not been setup yet",
|
||||
"overlay_my_calendar":"Overlay my calendar",
|
||||
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -133,7 +133,7 @@ export const AvailableTimeSlots = ({
|
|||
: slotsPerDay.length > 0 &&
|
||||
slotsPerDay.map((slots) => (
|
||||
<AvailableTimes
|
||||
className="scroll-bar w-full overflow-auto"
|
||||
className="scroll-bar w-full overflow-y-auto overflow-x-hidden"
|
||||
key={slots.date}
|
||||
showTimeFormatToggle={!isColumnView}
|
||||
onTimeSelect={onTimeSelect}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { m } from "framer-motion";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
|
@ -22,6 +23,7 @@ const TimezoneSelect = dynamic(() => import("@calcom/ui").then((mod) => mod.Time
|
|||
export const EventMeta = () => {
|
||||
const { setTimezone, timeFormat, timezone } = useTimePreferences();
|
||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration);
|
||||
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
const bookerState = useBookerStore((state) => state.state);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
|
@ -36,6 +38,13 @@ export const EventMeta = () => {
|
|||
const isEmbed = useIsEmbed();
|
||||
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDuration && event?.length) {
|
||||
setSelectedDuration(event.length);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [event?.length, selectedDuration]);
|
||||
|
||||
if (hideEventTypeDetails) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
|
|||
import { TimeFormatToggle } from "../../components/TimeFormatToggle";
|
||||
import { useBookerStore } from "../store";
|
||||
import type { BookerLayout } from "../types";
|
||||
import { OverlayCalendarContainer } from "./OverlayCalendar/OverlayCalendarContainer";
|
||||
|
||||
export function Header({
|
||||
extraDays,
|
||||
|
@ -56,7 +57,12 @@ export function Header({
|
|||
|
||||
// In month view we only show the layout toggle.
|
||||
if (isMonthView) {
|
||||
return <LayoutToggleWithData />;
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<OverlayCalendarContainer />
|
||||
<LayoutToggleWithData />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days");
|
||||
|
||||
|
@ -113,6 +119,7 @@ export function Header({
|
|||
</ButtonGroup>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<OverlayCalendarContainer />
|
||||
<TimeFormatToggle />
|
||||
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
||||
<LayoutToggleWithData />
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
|
||||
|
||||
import { useBookerStore } from "../store";
|
||||
import { useEvent, useScheduleForEvent } from "../utils/event";
|
||||
import { getQueryParam } from "../utils/query-param";
|
||||
import { useOverlayCalendarStore } from "./OverlayCalendar/store";
|
||||
|
||||
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
const date = selectedDate || dayjs().format("YYYY-MM-DD");
|
||||
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
|
||||
const selectedEventDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
|
||||
const schedule = useScheduleForEvent({
|
||||
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
|
||||
});
|
||||
const displayOverlay = getQueryParam("overlayCalendar") === "true";
|
||||
|
||||
const event = useEvent();
|
||||
const eventDuration = selectedEventDuration || event?.data?.length || 30;
|
||||
|
@ -39,6 +44,24 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
|||
.add(extraDays - 1, "day")
|
||||
.toDate();
|
||||
|
||||
// HACK: force rerender when overlay events change
|
||||
// Sine we dont use react router here we need to force rerender (ATOM SUPPORT)
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
useEffect(() => {}, [displayOverlay]);
|
||||
|
||||
const overlayEventsForDate = useMemo(() => {
|
||||
if (!overlayEvents || !displayOverlay) return [];
|
||||
return overlayEvents.map((event, id) => {
|
||||
return {
|
||||
id,
|
||||
start: dayjs(event.start).toDate(),
|
||||
end: dayjs(event.end).toDate(),
|
||||
title: "Busy",
|
||||
status: "ACCEPTED",
|
||||
} as CalendarEvent;
|
||||
});
|
||||
}, [overlayEvents, displayOverlay]);
|
||||
|
||||
return (
|
||||
<div className="h-full [--calendar-dates-sticky-offset:66px]">
|
||||
<Calendar
|
||||
|
@ -46,7 +69,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
|||
availableTimeslots={availableSlots}
|
||||
startHour={0}
|
||||
endHour={23}
|
||||
events={[]}
|
||||
events={overlayEventsForDate}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())}
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Switch } from "@calcom/ui";
|
||||
import { Settings } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useBookerStore } from "../../store";
|
||||
import { OverlayCalendarContinueModal } from "../OverlayCalendar/OverlayCalendarContinueModal";
|
||||
import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendarSettingsModal";
|
||||
import { useLocalSet } from "../hooks/useLocalSet";
|
||||
import { useOverlayCalendarStore } from "./store";
|
||||
|
||||
export function OverlayCalendarContainer() {
|
||||
const { t } = useLocale();
|
||||
const [continueWithProvider, setContinueWithProvider] = useState(false);
|
||||
const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
|
||||
|
||||
const layout = useBookerStore((state) => state.layout);
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { timezone } = useTimePreferences();
|
||||
|
||||
// Move this to a hook
|
||||
const { set, clearSet } = useLocalSet<{
|
||||
credentialId: number;
|
||||
externalId: string;
|
||||
}>("toggledConnectedCalendars", []);
|
||||
const overlayCalendarQueryParam = searchParams.get("overlayCalendar");
|
||||
|
||||
const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery(
|
||||
{
|
||||
loggedInUsersTz: timezone || "Europe/London",
|
||||
dateFrom: selectedDate,
|
||||
dateTo: selectedDate,
|
||||
calendarsToLoad: Array.from(set).map((item) => ({
|
||||
credentialId: item.credentialId,
|
||||
externalId: item.externalId,
|
||||
})),
|
||||
},
|
||||
{
|
||||
enabled: !!session && set.size > 0 && overlayCalendarQueryParam === "true",
|
||||
onError: () => {
|
||||
clearSet();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (overlayBusyDates) {
|
||||
const nowDate = dayjs();
|
||||
const usersTimezoneDate = nowDate.tz(timezone);
|
||||
|
||||
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
|
||||
|
||||
const offsettedArray = overlayBusyDates.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
start: dayjs(item.start).add(offset, "hours").toDate(),
|
||||
end: dayjs(item.end).add(offset, "hours").toDate(),
|
||||
};
|
||||
});
|
||||
setOverlayBusyDates(offsettedArray);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overlayBusyDates]);
|
||||
|
||||
// Toggle query param for overlay calendar
|
||||
const toggleOverlayCalendarQueryParam = useCallback(
|
||||
(state: boolean) => {
|
||||
const current = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
if (state) {
|
||||
current.set("overlayCalendar", "true");
|
||||
} else {
|
||||
current.delete("overlayCalendar");
|
||||
}
|
||||
// cast to string
|
||||
const value = current.toString();
|
||||
const query = value ? `?${value}` : "";
|
||||
router.push(`${pathname}${query}`);
|
||||
},
|
||||
[searchParams, pathname, router]
|
||||
);
|
||||
|
||||
/**
|
||||
* If a user is not logged in and the overlay calendar query param is true,
|
||||
* show the continue modal so they can login / create an account
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!session && overlayCalendarQueryParam === "true") {
|
||||
toggleOverlayCalendarQueryParam(false);
|
||||
setContinueWithProvider(true);
|
||||
}
|
||||
}, [session, overlayCalendarQueryParam, toggleOverlayCalendarQueryParam]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("hidden gap-2", layout === "week_view" ? "lg:flex" : "md:flex")}>
|
||||
<div className="flex items-center gap-2 pr-2">
|
||||
<Switch
|
||||
data-testid="overlay-calendar-switch"
|
||||
checked={overlayCalendarQueryParam === "true"}
|
||||
id="overlayCalendar"
|
||||
onCheckedChange={(state) => {
|
||||
if (!session) {
|
||||
setContinueWithProvider(state);
|
||||
} else {
|
||||
toggleOverlayCalendarQueryParam(state);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="overlayCalendar"
|
||||
className="text-emphasis text-sm font-medium leading-none hover:cursor-pointer">
|
||||
{t("overlay_my_calendar")}
|
||||
</label>
|
||||
</div>
|
||||
{session && (
|
||||
<Button
|
||||
size="base"
|
||||
data-testid="overlay-calendar-settings-button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon={Settings}
|
||||
onClick={() => {
|
||||
setCalendarSettingsOverlay(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<OverlayCalendarContinueModal
|
||||
open={continueWithProvider}
|
||||
onClose={(val) => {
|
||||
setContinueWithProvider(val);
|
||||
}}
|
||||
/>
|
||||
<OverlayCalendarSettingsModal
|
||||
open={calendarSettingsOverlay}
|
||||
onClose={(val) => {
|
||||
setCalendarSettingsOverlay(val);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { CalendarSearch } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter } from "@calcom/ui";
|
||||
|
||||
interface IOverlayCalendarContinueModalProps {
|
||||
open?: boolean;
|
||||
onClose?: (state: boolean) => void;
|
||||
}
|
||||
|
||||
export function OverlayCalendarContinueModal(props: IOverlayCalendarContinueModalProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Dialog open={props.open} onOpenChange={props.onClose}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
title={t("overlay_my_calendar")}
|
||||
description={t("overlay_my_calendar_toc")}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
data-testid="overlay-calendar-continue-button"
|
||||
onClick={() => {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.pathname = "/login/";
|
||||
currentUrl.searchParams.set("callbackUrl", window.location.pathname);
|
||||
currentUrl.searchParams.set("overlayCalendar", "true");
|
||||
|
||||
router.push(currentUrl.toString());
|
||||
}}
|
||||
className="gap w-full items-center justify-center font-semibold"
|
||||
StartIcon={CalendarSearch}>
|
||||
{t("continue_with", { appName: APP_NAME })}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{/* Agh modal hacks */}
|
||||
<></>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
EmptyScreen,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemTitle,
|
||||
Switch,
|
||||
DialogClose,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useLocalSet } from "../hooks/useLocalSet";
|
||||
import { useOverlayCalendarStore } from "./store";
|
||||
|
||||
interface IOverlayCalendarContinueModalProps {
|
||||
open?: boolean;
|
||||
onClose?: (state: boolean) => void;
|
||||
}
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle mt-3 space-y-4 rounded-xl border px-4 py-4 ">
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) {
|
||||
const utils = trpc.useContext();
|
||||
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
|
||||
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, {
|
||||
enabled: !!props.open,
|
||||
});
|
||||
const { toggleValue, hasItem } = useLocalSet<{
|
||||
credentialId: number;
|
||||
externalId: string;
|
||||
}>("toggledConnectedCalendars", []);
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Dialog open={props.open} onOpenChange={props.onClose}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
type="creation"
|
||||
title="Calendar Settings"
|
||||
className="pb-4"
|
||||
description={t("view_overlay_calendar_events")}>
|
||||
<div className="no-scrollbar max-h-full overflow-y-scroll ">
|
||||
{isLoading ? (
|
||||
<SkeletonLoader />
|
||||
) : (
|
||||
<>
|
||||
{data?.connectedCalendars.length === 0 ? (
|
||||
<EmptyScreen
|
||||
Icon={Calendar}
|
||||
headline={t("no_calendar_installed")}
|
||||
description={t("no_calendar_installed_description")}
|
||||
buttonText={t("add_a_calendar")}
|
||||
buttonOnClick={() => router.push("/apps/categories/calendar")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{data?.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.error && !item.calendars && (
|
||||
<Alert severity="error" title={item.error.message} />
|
||||
)}
|
||||
{item?.error === undefined && item.calendars && (
|
||||
<ListItem className="flex-col rounded-md">
|
||||
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
item.integration.logo && (
|
||||
<img
|
||||
className={classNames(
|
||||
"h-10 w-10",
|
||||
item.integration.logo.includes("-dark") && "dark:invert"
|
||||
)}
|
||||
src={item.integration.logo}
|
||||
alt={item.integration.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
|
||||
<Link href={`/apps/${item.integration.slug}`}>
|
||||
{item.integration.name || item.integration.title}
|
||||
</Link>
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{item.primary.email}</ListItemText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle w-full border-t pt-4">
|
||||
<ul className="space-y-4">
|
||||
{item.calendars.map((cal, index) => {
|
||||
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
|
||||
return (
|
||||
<li className="flex gap-3" key={id}>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={hasItem({
|
||||
credentialId: item.credentialId,
|
||||
externalId: cal.externalId,
|
||||
})}
|
||||
onCheckedChange={() => {
|
||||
toggleValue({
|
||||
credentialId: item.credentialId,
|
||||
externalId: cal.externalId,
|
||||
});
|
||||
setOverlayBusyDates([]);
|
||||
utils.viewer.availability.calendarOverlay.reset();
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={id}>{cal.name}</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</ListItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 self-end">
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
interface IOverlayCalendarStore {
|
||||
overlayBusyDates: EventBusyDate[] | undefined;
|
||||
setOverlayBusyDates: (busyDates: EventBusyDate[]) => void;
|
||||
}
|
||||
|
||||
export const useOverlayCalendarStore = create<IOverlayCalendarStore>((set) => ({
|
||||
overlayBusyDates: undefined,
|
||||
setOverlayBusyDates: (busyDates: EventBusyDate[]) => {
|
||||
set({ overlayBusyDates: busyDates });
|
||||
},
|
||||
}));
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface HasExternalId {
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
export function useLocalSet<T extends HasExternalId>(key: string, initialValue: T[]) {
|
||||
const [set, setSet] = useState<Set<T>>(() => {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
return storedValue ? new Set(JSON.parse(storedValue)) : new Set(initialValue);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(key, JSON.stringify(Array.from(set)));
|
||||
}, [key, set]);
|
||||
|
||||
const addValue = (value: T) => {
|
||||
setSet((prevSet) => new Set(prevSet).add(value));
|
||||
};
|
||||
|
||||
const removeById = (id: string) => {
|
||||
setSet((prevSet) => {
|
||||
const updatedSet = new Set(prevSet);
|
||||
updatedSet.forEach((item) => {
|
||||
if (item.externalId === id) {
|
||||
updatedSet.delete(item);
|
||||
}
|
||||
});
|
||||
return updatedSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleValue = (value: T) => {
|
||||
setSet((prevSet) => {
|
||||
const updatedSet = new Set(prevSet);
|
||||
let itemFound = false;
|
||||
|
||||
updatedSet.forEach((item) => {
|
||||
if (item.externalId === value.externalId) {
|
||||
itemFound = true;
|
||||
updatedSet.delete(item);
|
||||
}
|
||||
});
|
||||
|
||||
if (!itemFound) {
|
||||
updatedSet.add(value);
|
||||
}
|
||||
|
||||
return updatedSet;
|
||||
});
|
||||
};
|
||||
|
||||
const hasItem = (value: T) => {
|
||||
return Array.from(set).some((item) => item.externalId === value.externalId);
|
||||
};
|
||||
|
||||
const clearSet = () => {
|
||||
setSet(() => new Set());
|
||||
// clear local storage too
|
||||
localStorage.removeItem(key);
|
||||
};
|
||||
|
||||
return { set, addValue, removeById, toggleValue, hasItem, clearSet };
|
||||
}
|
|
@ -28,6 +28,17 @@ export const fadeInUp = {
|
|||
transition: { ease: "easeInOut", delay: 0.1 },
|
||||
};
|
||||
|
||||
export const fadeInRight = {
|
||||
variants: {
|
||||
visible: { opacity: 1, x: 0 },
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
},
|
||||
initial: "hidden",
|
||||
exit: "hidden",
|
||||
animate: "visible",
|
||||
transition: { ease: "easeInOut", delay: 0.1 },
|
||||
};
|
||||
|
||||
type ResizeAnimationConfig = {
|
||||
[key in BookerLayout]: {
|
||||
[key in BookerState | "default"]?: React.CSSProperties;
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import { CalendarX2 } from "lucide-react";
|
||||
// We do not need to worry about importing framer-motion here as it is lazy imported in Booker.
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import { CalendarX2, ChevronRight } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { Slots } from "@calcom/features/schedules";
|
||||
|
@ -7,17 +11,21 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import { Button, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { useBookerStore } from "../Booker/store";
|
||||
import { getQueryParam } from "../Booker/utils/query-param";
|
||||
import { useTimePreferences } from "../lib";
|
||||
import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay";
|
||||
import { SeatsAvailabilityText } from "./SeatsAvailabilityText";
|
||||
|
||||
type TOnTimeSelect = (
|
||||
time: string,
|
||||
attendees: number,
|
||||
seatsPerTimeSlot?: number | null,
|
||||
bookingUid?: string
|
||||
) => void;
|
||||
|
||||
type AvailableTimesProps = {
|
||||
slots: Slots[string];
|
||||
onTimeSelect: (
|
||||
time: string,
|
||||
attendees: number,
|
||||
seatsPerTimeSlot?: number | null,
|
||||
bookingUid?: string
|
||||
) => void;
|
||||
onTimeSelect: TOnTimeSelect;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
showTimeFormatToggle?: boolean;
|
||||
|
@ -25,6 +33,148 @@ type AvailableTimesProps = {
|
|||
selectedSlots?: string[];
|
||||
};
|
||||
|
||||
const SlotItem = ({
|
||||
slot,
|
||||
seatsPerTimeSlot,
|
||||
selectedSlots,
|
||||
onTimeSelect,
|
||||
showAvailableSeatsCount,
|
||||
}: {
|
||||
slot: Slots[string][number];
|
||||
seatsPerTimeSlot?: number | null;
|
||||
selectedSlots?: string[];
|
||||
onTimeSelect: TOnTimeSelect;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true";
|
||||
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
|
||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const layout = useBookerStore((state) => state.layout);
|
||||
const hasTimeSlots = !!seatsPerTimeSlot;
|
||||
const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone);
|
||||
|
||||
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot);
|
||||
const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
||||
const isNearlyFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
||||
const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400";
|
||||
|
||||
const nowDate = dayjs();
|
||||
const usersTimezoneDate = nowDate.tz(timezone);
|
||||
|
||||
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
|
||||
|
||||
const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay(
|
||||
computedDateWithUsersTimezone,
|
||||
selectedDuration,
|
||||
offset
|
||||
);
|
||||
const [overlapConfirm, setOverlapConfirm] = useState(false);
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
if (!overlayCalendarToggled) {
|
||||
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
|
||||
return;
|
||||
}
|
||||
if (isOverlapping && overlapConfirm) {
|
||||
setOverlapConfirm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOverlapping && !overlapConfirm) {
|
||||
setOverlapConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (!overlapConfirm) {
|
||||
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
|
||||
}
|
||||
}, [
|
||||
overlayCalendarToggled,
|
||||
isOverlapping,
|
||||
overlapConfirm,
|
||||
onTimeSelect,
|
||||
slot.time,
|
||||
slot?.attendees,
|
||||
slot.bookingUid,
|
||||
seatsPerTimeSlot,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
key={slot.time}
|
||||
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
|
||||
data-testid="time"
|
||||
data-disabled={bookingFull}
|
||||
data-time={slot.time}
|
||||
onClick={onButtonClick}
|
||||
className={classNames(
|
||||
"min-h-9 hover:border-brand-default mb-2 flex h-auto w-full flex-grow flex-col justify-center py-2",
|
||||
selectedSlots?.includes(slot.time) && "border-brand-default"
|
||||
)}
|
||||
color="secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
{!hasTimeSlots && overlayCalendarToggled && (
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
isOverlapping ? "bg-rose-600" : "bg-emerald-400"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{computedDateWithUsersTimezone.format(timeFormat)}
|
||||
</div>
|
||||
{bookingFull && <p className="text-sm">{t("booking_full")}</p>}
|
||||
{hasTimeSlots && !bookingFull && (
|
||||
<p className="flex items-center text-sm">
|
||||
<span
|
||||
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
|
||||
aria-hidden
|
||||
/>
|
||||
<SeatsAvailabilityText
|
||||
showExact={!!showAvailableSeatsCount}
|
||||
totalSeats={seatsPerTimeSlot}
|
||||
bookedSeats={slot.attendees || 0}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
{overlapConfirm && isOverlapping && (
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger asChild>
|
||||
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
|
||||
<Button
|
||||
variant={layout === "column_view" ? "icon" : "button"}
|
||||
StartIcon={layout === "column_view" ? ChevronRight : undefined}
|
||||
onClick={() =>
|
||||
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
|
||||
}>
|
||||
{layout !== "column_view" && t("confirm")}
|
||||
</Button>
|
||||
</m.div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content side="top" align="end" sideOffset={2}>
|
||||
<div className="text-emphasis bg-inverted text-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p>Busy</p>
|
||||
</div>
|
||||
<p className="text-muted">
|
||||
{overlappingTimeStart} - {overlappingTimeEnd}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvailableTimes = ({
|
||||
slots,
|
||||
onTimeSelect,
|
||||
|
@ -34,10 +184,7 @@ export const AvailableTimes = ({
|
|||
className,
|
||||
selectedSlots,
|
||||
}: AvailableTimesProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const hasTimeSlots = !!seatsPerTimeSlot;
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className={classNames("text-default flex flex-col", className)}>
|
||||
|
@ -50,45 +197,16 @@ export const AvailableTimes = ({
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slots.map((slot) => {
|
||||
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot);
|
||||
const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
||||
const isNearlyFull =
|
||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
||||
|
||||
const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400";
|
||||
return (
|
||||
<Button
|
||||
key={slot.time}
|
||||
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
|
||||
data-testid="time"
|
||||
data-disabled={bookingFull}
|
||||
data-time={slot.time}
|
||||
onClick={() => onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)}
|
||||
className={classNames(
|
||||
"min-h-9 hover:border-brand-default mb-2 flex h-auto w-full flex-col justify-center py-2",
|
||||
selectedSlots?.includes(slot.time) && "border-brand-default"
|
||||
)}
|
||||
color="secondary">
|
||||
{dayjs.utc(slot.time).tz(timezone).format(timeFormat)}
|
||||
{bookingFull && <p className="text-sm">{t("booking_full")}</p>}
|
||||
{hasTimeSlots && !bookingFull && (
|
||||
<p className="flex items-center text-sm">
|
||||
<span
|
||||
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
|
||||
aria-hidden
|
||||
/>
|
||||
<SeatsAvailabilityText
|
||||
showExact={!!showAvailableSeatsCount}
|
||||
totalSeats={seatsPerTimeSlot}
|
||||
bookedSeats={slot.attendees || 0}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{slots.map((slot) => (
|
||||
<SlotItem
|
||||
key={slot.time}
|
||||
onTimeSelect={onTimeSelect}
|
||||
slot={slot}
|
||||
selectedSlots={selectedSlots}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import { useOverlayCalendarStore } from "../Booker/components/OverlayCalendar/store";
|
||||
|
||||
function getCurrentTime(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function useCheckOverlapWithOverlay(start: Dayjs, selectedDuration: number | null, offset: number) {
|
||||
const overlayBusyDates = useOverlayCalendarStore((state) => state.overlayBusyDates);
|
||||
|
||||
let overlappingTimeStart: string | null = null;
|
||||
let overlappingTimeEnd: string | null = null;
|
||||
|
||||
const isOverlapping =
|
||||
overlayBusyDates &&
|
||||
overlayBusyDates.some((busyDate) => {
|
||||
const busyDateStart = dayjs(busyDate.start);
|
||||
const busyDateEnd = dayjs(busyDate.end);
|
||||
const selectedEndTime = dayjs(start.add(offset, "hours")).add(selectedDuration ?? 0, "minute");
|
||||
|
||||
const isOverlapping =
|
||||
(selectedEndTime.isSame(busyDateStart) || selectedEndTime.isAfter(busyDateStart)) &&
|
||||
start.add(offset, "hours") < busyDateEnd &&
|
||||
selectedEndTime > busyDateStart;
|
||||
|
||||
overlappingTimeStart = isOverlapping ? getCurrentTime(busyDateStart.toDate()) : null;
|
||||
overlappingTimeEnd = isOverlapping ? getCurrentTime(busyDateEnd.toDate()) : null;
|
||||
|
||||
return isOverlapping;
|
||||
});
|
||||
|
||||
return { isOverlapping, overlappingTimeStart, overlappingTimeEnd } as {
|
||||
isOverlapping: boolean;
|
||||
overlappingTimeStart: string | null;
|
||||
overlappingTimeEnd: string | null;
|
||||
};
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZCalendarOverlayInputSchema } from "./calendarOverlay.schema";
|
||||
import { scheduleRouter } from "./schedule/_router";
|
||||
import { ZListTeamAvailaiblityScheme } from "./team/listTeamAvailability.schema";
|
||||
import { ZUserInputSchema } from "./user.schema";
|
||||
|
@ -7,6 +8,7 @@ import { ZUserInputSchema } from "./user.schema";
|
|||
type AvailabilityRouterHandlerCache = {
|
||||
list?: typeof import("./list.handler").listHandler;
|
||||
user?: typeof import("./user.handler").userHandler;
|
||||
calendarOverlay?: typeof import("./calendarOverlay.handler").calendarOverlayHandler;
|
||||
listTeamAvailability?: typeof import("./team/listTeamAvailability.handler").listTeamAvailabilityHandler;
|
||||
};
|
||||
|
||||
|
@ -60,6 +62,22 @@ export const availabilityRouter = router({
|
|||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
schedule: scheduleRouter,
|
||||
calendarOverlay: authedProcedure.input(ZCalendarOverlayInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) {
|
||||
UNSTABLE_HANDLER_CACHE.calendarOverlay = await import("./calendarOverlay.handler").then(
|
||||
(mod) => mod.calendarOverlayHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.calendarOverlay({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
import type { TCalendarOverlayInputSchema } from "./calendarOverlay.schema";
|
||||
|
||||
type ListOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TCalendarOverlayInputSchema;
|
||||
};
|
||||
|
||||
export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => {
|
||||
const { user } = ctx;
|
||||
const { calendarsToLoad, dateFrom, dateTo } = input;
|
||||
|
||||
if (!dateFrom || !dateTo) {
|
||||
return [] as EventBusyDate[];
|
||||
}
|
||||
|
||||
// get all unique credentialIds from calendarsToLoad
|
||||
const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId)));
|
||||
|
||||
// To call getCalendar we need
|
||||
|
||||
// Ensure that the user has access to all of the credentialIds
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: uniqueCredentialIds,
|
||||
},
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (credentials.length !== uniqueCredentialIds.length) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Unauthorized - These credentials do not belong to you",
|
||||
});
|
||||
}
|
||||
|
||||
const composedSelectedCalendars = calendarsToLoad.map((calendar) => {
|
||||
const credential = credentials.find((item) => item.id === calendar.credentialId);
|
||||
if (!credential) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Unauthorized - These credentials do not belong to you",
|
||||
});
|
||||
}
|
||||
return {
|
||||
...calendar,
|
||||
userId: user.id,
|
||||
integration: credential.type,
|
||||
};
|
||||
});
|
||||
|
||||
// get all clanedar services
|
||||
const calendarBusyTimes = await getBusyCalendarTimes(
|
||||
"",
|
||||
credentials,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
composedSelectedCalendars
|
||||
);
|
||||
|
||||
// Convert to users timezone
|
||||
|
||||
const userTimeZone = input.loggedInUsersTz;
|
||||
const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => {
|
||||
const busyTimeStart = dayjs(busyTime.start);
|
||||
const busyTimeEnd = dayjs(busyTime.end);
|
||||
const busyTimeStartDate = busyTimeStart.tz(userTimeZone).toDate();
|
||||
const busyTimeEndDate = busyTimeEnd.tz(userTimeZone).toDate();
|
||||
|
||||
return {
|
||||
...busyTime,
|
||||
start: busyTimeStartDate,
|
||||
end: busyTimeEndDate,
|
||||
} as EventBusyDate;
|
||||
});
|
||||
|
||||
return calendarBusyTimesConverted;
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZCalendarOverlayInputSchema = z.object({
|
||||
loggedInUsersTz: z.string(),
|
||||
dateFrom: z.string().nullable(),
|
||||
dateTo: z.string().nullable(),
|
||||
calendarsToLoad: z.array(
|
||||
z.object({
|
||||
credentialId: z.number(),
|
||||
externalId: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TCalendarOverlayInputSchema = z.infer<typeof ZCalendarOverlayInputSchema>;
|
Loading…
Reference in New Issue