From b4c6388ce041324ad19084485a30c45480221586 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:05:20 +0100 Subject: [PATCH] feat: overlay your calendar (#11693) * Init header + login modal component * Add calendar settings for authed user * Local storage and using query params for toggle * Toggle connect screen if query param present and no session * Local storage + store + way more than that should be in single commit * Display busy events on weekly view * Confirm booking slot of overlap exists * use chevron right when on column view * Show hover card - overlapping date times * Invalidate on switch * FIx clearing local storage when you login to another account * Force re-render on url state (atom quirks) * Add loading screen * Add dialog close * Remove extra grid config * Translations * [WIP] - tests * fix: google calendar busy times (#11696) Co-authored-by: CarinaWolli * New Crowdin translations by Github Action * fix: rescheduled value DB update on reschedule and insights view cancelleds (#11474) * v3.3.5 * fix minutes string (#11703) Co-authored-by: CarinaWolli * Regenerated yarn.lock * Add error component + loader * await tests * disable tests - add note * Refactor to include selected time * use no-scrollbar * Fix i18n * Fix tablet toolbar * overflow + i18n * Export empty object as test is TODO * Uses booker timezone * Fix hiding switch too early * Handle selected timezone * Fix timezone issues * Fix timezone issues --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli Co-authored-by: Crowdin Bot Co-authored-by: alannnc Co-authored-by: Alex van Andel Co-authored-by: Peer Richelsen Co-authored-by: Peer Richelsen --- apps/web/playwright/overlay-calendar.e2e.ts | 39 ++++ apps/web/public/static/locales/en/common.json | 4 + .../Booker/components/AvailableTimeSlots.tsx | 2 +- .../bookings/Booker/components/EventMeta.tsx | 9 + .../bookings/Booker/components/Header.tsx | 9 +- .../Booker/components/LargeCalendar.tsx | 27 ++- .../OverlayCalendarContainer.tsx | 154 +++++++++++++ .../OverlayCalendarContinueModal.tsx | 47 ++++ .../OverlayCalendarSettingsModal.tsx | 155 +++++++++++++ .../components/OverlayCalendar/store.ts | 15 ++ .../Booker/components/hooks/useLocalSet.tsx | 64 +++++ packages/features/bookings/Booker/config.ts | 11 + .../bookings/components/AvailableTimes.tsx | 218 ++++++++++++++---- .../lib/useCheckOverlapWithOverlay.tsx | 41 ++++ .../routers/viewer/availability/_router.tsx | 20 +- .../availability/calendarOverlay.handler.ts | 102 ++++++++ .../availability/calendarOverlay.schema.ts | 15 ++ 17 files changed, 877 insertions(+), 55 deletions(-) create mode 100644 apps/web/playwright/overlay-calendar.e2e.ts create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/store.ts create mode 100644 packages/features/bookings/Booker/components/hooks/useLocalSet.tsx create mode 100644 packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx create mode 100644 packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts diff --git a/apps/web/playwright/overlay-calendar.e2e.ts b/apps/web/playwright/overlay-calendar.e2e.ts new file mode 100644 index 0000000000..803f772fb3 --- /dev/null +++ b/apps/web/playwright/overlay-calendar.e2e.ts @@ -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(); +// }); +// }); +// }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index db4ff60a2d..b96f728d13 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index 8d34240b8d..f2d40e3654 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -133,7 +133,7 @@ export const AvailableTimeSlots = ({ : slotsPerDay.length > 0 && slotsPerDay.map((slots) => ( 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; } diff --git a/packages/features/bookings/Booker/components/Header.tsx b/packages/features/bookings/Booker/components/Header.tsx index 5d65575129..d9942547ad 100644 --- a/packages/features/bookings/Booker/components/Header.tsx +++ b/packages/features/bookings/Booker/components/Header.tsx @@ -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 ; + return ( +
+ + +
+ ); } const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days"); @@ -113,6 +119,7 @@ export function Header({
+
diff --git a/packages/features/bookings/Booker/components/LargeCalendar.tsx b/packages/features/bookings/Booker/components/LargeCalendar.tsx index 021f53180c..b9684912bc 100644 --- a/packages/features/bookings/Booker/components/LargeCalendar.tsx +++ b/packages/features/bookings/Booker/components/LargeCalendar.tsx @@ -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 (
{ availableTimeslots={availableSlots} startHour={0} endHour={23} - events={[]} + events={overlayEventsForDate} startDate={startDate} endDate={endDate} onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx new file mode 100644 index 0000000000..7603d82795 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx @@ -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 ( + <> +
+
+ { + if (!session) { + setContinueWithProvider(state); + } else { + toggleOverlayCalendarQueryParam(state); + } + }} + /> + +
+ {session && ( +
+ { + setContinueWithProvider(val); + }} + /> + { + setCalendarSettingsOverlay(val); + }} + /> + + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx new file mode 100644 index 0000000000..68793fa4a1 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx @@ -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 ( + <> + + +
+ +
+ + {/* Agh modal hacks */} + <> + +
+
+ + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx new file mode 100644 index 0000000000..24ccc80a73 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx @@ -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 ( + +
+ + + + +
+
+ ); +}; + +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 ( + <> + + +
+ {isLoading ? ( + + ) : ( + <> + {data?.connectedCalendars.length === 0 ? ( + router.push("/apps/categories/calendar")} + /> + ) : ( + <> + {data?.connectedCalendars.map((item) => ( + + {item.error && !item.calendars && ( + + )} + {item?.error === undefined && item.calendars && ( + +
+ { + // eslint-disable-next-line @next/next/no-img-element + item.integration.logo && ( + {item.integration.title} + ) + } +
+ + + {item.integration.name || item.integration.title} + + + {item.primary.email} +
+
+
+
    + {item.calendars.map((cal, index) => { + const id = cal.integrationTitle ?? `calendar-switch-${index}`; + return ( +
  • + { + toggleValue({ + credentialId: item.credentialId, + externalId: cal.externalId, + }); + setOverlayBusyDates([]); + utils.viewer.availability.calendarOverlay.reset(); + }} + /> + +
  • + ); + })} +
+
+
+ )} +
+ ))} + + )} + + )} +
+ +
+ {t("done")} +
+
+
+ + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/store.ts b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts new file mode 100644 index 0000000000..1d9fd90b55 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts @@ -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((set) => ({ + overlayBusyDates: undefined, + setOverlayBusyDates: (busyDates: EventBusyDate[]) => { + set({ overlayBusyDates: busyDates }); + }, +})); diff --git a/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx b/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx new file mode 100644 index 0000000000..3bcc9dad14 --- /dev/null +++ b/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +export interface HasExternalId { + externalId: string; +} + +export function useLocalSet(key: string, initialValue: T[]) { + const [set, setSet] = useState>(() => { + 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 }; +} diff --git a/packages/features/bookings/Booker/config.ts b/packages/features/bookings/Booker/config.ts index b3f537284f..516db66c94 100644 --- a/packages/features/bookings/Booker/config.ts +++ b/packages/features/bookings/Booker/config.ts @@ -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; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 29bc587255..e46e020a6e 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -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 ( + +
+ + {overlapConfirm && isOverlapping && ( + + + + + + + + +
+
+

Busy

+
+

+ {overlappingTimeStart} - {overlappingTimeEnd} +

+
+
+
+
+ )} +
+
+ ); +}; + 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 (
@@ -50,45 +197,16 @@ export const AvailableTimes = ({

)} - - {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 ( - - ); - })} + {slots.map((slot) => ( + + ))}
); diff --git a/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx b/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx new file mode 100644 index 0000000000..a1a3020da8 --- /dev/null +++ b/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx @@ -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; + }; +} diff --git a/packages/trpc/server/routers/viewer/availability/_router.tsx b/packages/trpc/server/routers/viewer/availability/_router.tsx index 1084dc5dc7..12a2fbcfb0 100644 --- a/packages/trpc/server/routers/viewer/availability/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts new file mode 100644 index 0000000000..3ac4cc8581 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts @@ -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; + }; + 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; +}; diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts new file mode 100644 index 0000000000..c424ef3bf0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts @@ -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;