Files
calendar/apps/web/modules/bookings/components/Header.tsx
T
Benny JooGitHubunknown <>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
73f51920c5 refactor: move Booker hooks from packages/features to apps/web/modules (#27343)
* refactor: move Booker hooks from packages/features to apps/web/modules

Migrate the following Booker hooks:
- useOverlayCalendar
- useSkipConfirmStep
- useInitializeWeekStart
- useIsQuickAvailabilityCheckFeatureEnabled
- useDecoyBooking

These hooks are only imported by apps/web files, making them safe to move
without creating circular dependencies.

Part of the tRPC-driven UI migration from packages/features to apps/web/modules.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: revert useIsQuickAvailabilityCheckFeatureEnabled move and fix import paths

- Reverted useIsQuickAvailabilityCheckFeatureEnabled.ts back to packages/features since it's imported by useSlots.ts in packages/features (would create circular dependency)
- Fixed import paths in useOverlayCalendar.ts to use @calcom/features paths
- Fixed import paths in useSkipConfirmStep.ts to use @calcom/features paths
- Updated Booker.tsx to import useIsQuickAvailabilityCheckFeatureEnabled from original location

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: use @calcom/web alias for hook imports to fix vitest resolution

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor

* migrate more hooks

* fix

* mv types

* fix

* fix

* fix

* fix

* revert: remove formatting-only changes to make PR easier to review

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

* fix

* fix

* revert: restore original import ordering in moved hooks

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix imports

* fix

* fix

* wip

* wip

* wip

* wip

* wip

* wip

* fix: add isBrowser guard to useSlotsViewOnSmallScreen hook

Address Cubic AI review feedback (confidence 9/10) by adding the isBrowser
guard to the useSlotsViewOnSmallScreen hook to follow the file's client-only
contract and avoid executing during SSR/prerendering.

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-31 07:33:33 -03:00

254 lines
8.8 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { shallow } from "zustand/shallow";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useInitializeWeekStart } from "@calcom/web/modules/bookings/hooks/useInitializeWeekStart";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { formatDateTime } from "@calcom/lib/dateTimeFormatter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { TimeFormatToggle } from "@calcom/web/modules/bookings/components/TimeFormatToggle";
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
export function Header({
extraDays,
isMobile,
enabledLayouts,
nextSlots,
eventSlug,
isMyLink,
renderOverlay,
isCalendarView,
}: {
extraDays: number;
isMobile: boolean;
enabledLayouts: BookerLayouts[];
nextSlots: number;
eventSlug: string;
isMyLink: boolean;
renderOverlay?: () => JSX.Element | null;
isCalendarView?: boolean;
}) {
const { t, i18n } = useLocale();
const isEmbed = useIsEmbed();
const isPlatform = useIsPlatform();
const [layout, setLayout] = useBookerStoreContext((state) => [state.layout, state.setLayout], shallow);
const selectedDateString = useBookerStoreContext((state) => state.selectedDate);
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
const addToSelectedDate = useBookerStoreContext((state) => state.addToSelectedDate);
const isMonthView = isCalendarView !== undefined ? !isCalendarView : layout === BookerLayouts.MONTH_VIEW;
const today = dayjs();
const selectedDate = selectedDateString ? dayjs(selectedDateString) : today;
const selectedDateMin3DaysDifference = useMemo(() => {
const diff = today.diff(selectedDate, "days");
return diff > 3 || diff < -3;
}, [today, selectedDate]);
useInitializeWeekStart(isPlatform, isCalendarView ?? false);
const onLayoutToggle = useCallback(
(newLayout: string) => {
if (layout === newLayout || !newLayout) return;
setLayout(newLayout as BookerLayout);
},
[setLayout, layout]
);
if (isMobile || !enabledLayouts) return null;
// In month view we only show the layout toggle.
if (isMonthView) {
return (
<div className="flex gap-2">
{isMyLink && !isEmbed ? (
<Tooltip content={t("troubleshooter_tooltip")} side="bottom">
<Button
color="primary"
target="_blank"
href={`${WEBAPP_URL}/availability/troubleshoot?eventType=${eventSlug}`}>
{t("need_help")}
</Button>
</Tooltip>
) : (
renderOverlay?.()
)}
<LayoutToggleWithData
layout={layout}
enabledLayouts={enabledLayouts}
onLayoutToggle={onLayoutToggle}
/>
</div>
);
}
const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days");
const isSameMonth = () => {
return selectedDate.format("MMM") === endDate.format("MMM");
};
const isSameYear = () => {
return selectedDate.format("YYYY") === endDate.format("YYYY");
};
const formatMonthLabel = (date: Date) =>
formatDateTime(date, { locale: i18n.language ?? "en", month: "short" });
const FormattedSelectedDateRange = () => {
return (
<h3 className="min-w-[150px] text-base font-semibold leading-4">
{formatMonthLabel(selectedDate.toDate())} {selectedDate.format("D")}
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
{!isSameMonth() && formatMonthLabel(endDate.toDate())} {endDate.format("D")},{" "}
<span className="text-subtle">
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
</span>
</h3>
);
};
return (
<div className="border-default relative z-10 flex border-b px-5 py-4 ltr:border-l rtl:border-r">
<div className="flex items-center gap-5 rtl:grow">
<FormattedSelectedDateRange />
<ButtonGroup>
<Button
className="group rtl:ml-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon="chevron-left"
aria-label="Previous Day"
onClick={() => addToSelectedDate(layout === BookerLayouts.COLUMN_VIEW ? -nextSlots : -extraDays)}
/>
<Button
className="group rtl:mr-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon="chevron-right"
aria-label="Next Day"
onClick={() => addToSelectedDate(layout === BookerLayouts.COLUMN_VIEW ? nextSlots : extraDays)}
/>
{selectedDateMin3DaysDifference && (
<Button
className="capitalize ltr:ml-2 rtl:mr-2"
color="secondary"
onClick={() => {
const selectedDate = (isCalendarView ? today.startOf("week") : today).format("YYYY-MM-DD");
setSelectedDate({ date: selectedDate });
}}>
{t("today")}
</Button>
)}
</ButtonGroup>
</div>
<div className="ml-auto flex gap-2">
{renderOverlay?.()}
<TimeFormatToggle />
<div className="fixed top-4 ltr:right-4 rtl:left-4">
<LayoutToggleWithData
layout={layout}
enabledLayouts={enabledLayouts}
onLayoutToggle={onLayoutToggle}
/>
</div>
{/*
This second layout toggle is hidden, but needed to reserve the correct spot in the DIV
for the fixed toggle above to fit into. If we wouldn't make it fixed in this view, the transition
would be really weird, because the element is positioned fixed in the month view, and then
when switching layouts wouldn't anymore, causing it to animate from the center to the top right,
while it actually already was on place. That's why we have this element twice.
*/}
<div className="pointer-events-none opacity-0" aria-hidden>
<LayoutToggleWithData
layout={layout}
enabledLayouts={enabledLayouts}
onLayoutToggle={onLayoutToggle}
/>
</div>
</div>
</div>
);
}
const LayoutToggle = ({
onLayoutToggle,
layout,
enabledLayouts,
}: {
onLayoutToggle: (layout: string) => void;
layout: string;
enabledLayouts?: BookerLayouts[];
}) => {
const isEmbed = useIsEmbed();
const isPlatform = useIsPlatform();
const { t } = useLocale();
const layoutOptions = useMemo(() => {
return [
{
value: BookerLayouts.MONTH_VIEW,
label: (
<>
<Icon name="calendar" width="16" height="16" />
<span className="sr-only">${t("switch_monthly")}</span>
</>
),
tooltip: t("switch_monthly"),
},
{
value: BookerLayouts.WEEK_VIEW,
label: (
<>
<Icon name="grid-3x3" width="16" height="16" />
<span className="sr-only">${t("switch_weekly")}</span>
</>
),
tooltip: t("switch_weekly"),
},
{
value: BookerLayouts.COLUMN_VIEW,
label: (
<>
<Icon name="columns-3" width="16" height="16" />
<span className="sr-only">${t("switch_columnview")}</span>
</>
),
tooltip: t("switch_columnview"),
},
].filter((layout) => enabledLayouts?.includes(layout.value as BookerLayouts));
}, [t, enabledLayouts]);
// We don't want to show the layout toggle in embed mode as of now as it doesn't look rightly placed when embedded.
// There is a Embed API to control the layout toggle from outside of the iframe.
if (isEmbed) {
return null;
}
// just like embed the layout toggle doesn't look rightly placed in platform
// the layout can be toggled via props in the booker atom
if (isPlatform) return null;
return <ToggleGroup onValueChange={onLayoutToggle} defaultValue={layout} options={layoutOptions} />;
};
const LayoutToggleWithData = ({
enabledLayouts,
onLayoutToggle,
layout,
}: {
enabledLayouts: BookerLayouts[];
onLayoutToggle: (layout: string) => void;
layout: string;
}) => {
return enabledLayouts.length <= 1 ? null : (
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
);
};