Merge branch 'main' into org-subTeams-delete-members

pull/11710/head
Somay 2023-10-10 17:00:43 +05:30
commit da4dabc7cd
17 changed files with 877 additions and 55 deletions

View File

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

View File

@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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