import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible"; import classNames from "classnames"; import { useSession } from "next-auth/react"; import { usePathname, useRouter } from "next/navigation"; import type { RefObject, Dispatch, SetStateAction } from "react"; import { createRef, useRef, useState } from "react"; import type { ControlProps } from "react-select"; import { components } from "react-select"; import { shallow } from "zustand/shallow"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { AvailableTimes } from "@calcom/web/modules/bookings/components/AvailableTimes"; import { AvailableTimesHeader } from "@calcom/web/modules/bookings/components/AvailableTimesHeader"; import { BookerStoreProvider, useInitializeBookerStoreContext, useBookerStoreContext, } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import { useEvent, useScheduleForEvent } from "@calcom/web/modules/schedules/hooks/useEvent"; import DatePicker from "@calcom/features/calendars/components/DatePicker"; import { Dialog } from "@calcom/features/components/controlled-dialog"; import { TimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect"; import type { Slot } from "~/schedules/lib/types"; import { useNonEmptyScheduleDays } from "@calcom/web/modules/schedules/hooks/useNonEmptyScheduleDays"; import { useSlotsForDate } from "@calcom/web/modules/schedules/hooks/useSlotsForDate"; import { APP_NAME, DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; import { weekdayToWeekIndex } from "@calcom/lib/dayjs"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; import { Select, ColorPicker } from "@calcom/ui/components/form"; import { Label } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form"; import { Switch } from "@calcom/ui/components/form"; import { ArrowLeftIcon, SunIcon } from "@coss/ui/icons"; import { HorizontalTabs } from "@calcom/ui/components/navigation"; import { showToast } from "@calcom/ui/components/toast"; import { useBookerTime } from "@calcom/features/bookings/Booker/hooks/useBookerTime"; import { EmbedTabName } from "@calcom/features/embed/lib/EmbedTabs"; import { buildCssVarsPerTheme } from "@calcom/features/embed/lib/buildCssVarsPerTheme"; import { EmbedTheme } from "@calcom/features/embed/lib/constants"; import { getDimension } from "@calcom/features/embed/lib/getDimension"; import { useEmbedDialogCtx } from "@calcom/features/embed/lib/hooks/useEmbedDialogCtx"; import { useEmbedParams } from "@calcom/features/embed/lib/hooks/useEmbedParams"; import type { EmbedTabs, EmbedType, EmbedTypes, PreviewState, EmbedConfig, } from "@calcom/features/embed/types"; type EventType = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"] | undefined; type EmbedDialogProps = { types: EmbedTypes; tabs: EmbedTabs; eventTypeHideOptionDisabled: boolean; defaultBrandColor: { brandColor: string | null; darkBrandColor: string | null; } | null; noQueryParamMode?: boolean; }; type GotoStateProps = { embedType?: EmbedType | null; embedTabName?: string | null; embedUrl?: string | null; eventId?: string | null; namespace?: string | null; date?: string | null; month?: string | null; dialog?: string; }; const queryParamsForDialog = [ "embedType", "embedTabName", "embedUrl", "eventId", "namespace", "date", "month", ]; function chooseTimezone({ timezoneFromBookerStore, timezoneFromTimePreferences, userSettingsTimezone, }: { timezoneFromBookerStore: string | null; timezoneFromTimePreferences: string; userSettingsTimezone: string | undefined; }) { // We prefer user's timezone configured in settings at the moment - Might be a better idea to prefer timezoneFromTimePreferences over user settings as the user might be in different timezone return timezoneFromBookerStore ?? userSettingsTimezone ?? timezoneFromTimePreferences; } function useRouterHelpers() { const router = useRouter(); const searchParams = useCompatSearchParams(); const pathname = usePathname(); const goto = (newSearchParams: Record) => { const newQuery = new URLSearchParams(searchParams.toString()); newQuery.delete("slug"); newQuery.delete("pages"); Object.keys(newSearchParams).forEach((key) => { newQuery.set(key, newSearchParams[key]); }); router.push(`${pathname}?${newQuery.toString()}`); }; const removeQueryParams = (queryParams: string[]) => { const params = new URLSearchParams(searchParams.toString()); queryParams.forEach((param) => { params.delete(param); }); router.push(`${pathname}?${params.toString()}`); }; return { goto, removeQueryParams }; } function useEmbedGoto(noQueryParamMode = false) { const { goto, removeQueryParams } = useRouterHelpers(); const { setEmbedState } = useEmbedDialogCtx(noQueryParamMode); const gotoState = (props: GotoStateProps) => { if (noQueryParamMode) { setEmbedState((prev) => ({ ...prev, embedType: props.embedType ?? prev?.embedType ?? null, embedTabName: props.embedTabName ?? prev?.embedTabName ?? null, embedUrl: props.embedUrl ?? prev?.embedUrl ?? null, eventId: props.eventId ?? prev?.eventId ?? null, namespace: props.namespace ?? prev?.namespace ?? null, date: props.date ?? prev?.date ?? null, month: props.month ?? prev?.month ?? null, })); } else { const validQueryParams = Object.fromEntries( Object.entries(props).filter(([_, value]) => value !== null) as [string, string][] ); goto(validQueryParams); } }; const resetState = () => { if (noQueryParamMode) { setEmbedState(null); } else { removeQueryParams(["dialog", ...queryParamsForDialog]); } }; const gotoEmbedTypeSelectionState = () => { if (noQueryParamMode) { setEmbedState((prev) => ({ ...prev, embedType: null, embedTabName: null, embedUrl: prev?.embedUrl ?? null, eventId: prev?.eventId ?? null, namespace: prev?.namespace ?? null, date: prev?.date ?? null, month: prev?.month ?? null, })); } else { removeQueryParams(["embedType", "embedTabName"]); } }; return { gotoState, resetState, gotoEmbedTypeSelectionState }; } const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: EmbedTheme; label: string }, false>) => { return ( {children} ); }; const ChooseEmbedTypesDialogContent = ({ types, noQueryParamMode, }: { types: EmbedTypes; noQueryParamMode: boolean; }) => { const { t } = useLocale(); const { gotoState } = useEmbedGoto(noQueryParamMode); return (

{t("choose_ways_put_cal_site", { appName: APP_NAME })}

{types.map((embed, index) => ( ))}
); }; const EmailEmbed = ({ eventType, username, orgSlug, isTeamEvent, selectedDuration, setSelectedDuration, userSettingsTimezone, }: { eventType?: EventType; username: string; orgSlug?: string; isTeamEvent: boolean; selectedDuration: number | undefined; setSelectedDuration: Dispatch>; userSettingsTimezone?: string; }) => { const { t, i18n } = useLocale(); const { timezoneFromBookerStore, timezoneFromTimePreferences } = useBookerTime(); const timezone = chooseTimezone({ timezoneFromBookerStore, timezoneFromTimePreferences, userSettingsTimezone, }); useInitializeBookerStore({ username, eventSlug: eventType?.slug ?? "", eventId: eventType?.id, layout: BookerLayouts.MONTH_VIEW, org: orgSlug, isTeamEvent, }); useInitializeBookerStoreContext({ username, eventSlug: eventType?.slug ?? "", eventId: eventType?.id, layout: BookerLayouts.MONTH_VIEW, org: orgSlug, isTeamEvent, }); const [month, selectedDate, selectedDatesAndTimes] = useBookerStoreContext( (state) => [state.month, state.selectedDate, state.selectedDatesAndTimes], shallow ); const [setSelectedDate, setMonth, setSelectedDatesAndTimes, setSelectedTimeslot, setTimezone] = useBookerStoreContext( (state) => [ state.setSelectedDate, state.setMonth, state.setSelectedDatesAndTimes, state.setSelectedTimeslot, state.setTimezone, ], shallow ); const event = useEvent(); const schedule = useScheduleForEvent({ orgSlug, eventId: eventType?.id, isTeamEvent, duration: selectedDuration, useApiV2: false, }); const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots); const handleSlotClick = (slot: Slot) => { const { time } = slot; if (!eventType) { return null; } if (selectedDatesAndTimes && selectedDatesAndTimes[eventType.slug]) { const selectedDatesAndTimesForEvent = selectedDatesAndTimes[eventType.slug]; const selectedSlots = selectedDatesAndTimesForEvent[selectedDate as string] ?? []; if (selectedSlots?.includes(time)) { // Checks whether a user has removed all their timeSlots and thus removes it from the selectedDatesAndTimesForEvent state if (selectedSlots?.length > 1) { const updatedDatesAndTimes = { ...selectedDatesAndTimes, [eventType.slug]: { ...selectedDatesAndTimesForEvent, [selectedDate as string]: selectedSlots?.filter((slot: string) => slot !== time), }, }; setSelectedDatesAndTimes(updatedDatesAndTimes); } else { const updatedDatesAndTimesForEvent = { ...selectedDatesAndTimesForEvent, }; delete updatedDatesAndTimesForEvent[selectedDate as string]; setSelectedTimeslot(null); setSelectedDatesAndTimes({ ...selectedDatesAndTimes, [eventType.slug]: updatedDatesAndTimesForEvent, }); } return; } const updatedDatesAndTimes = { ...selectedDatesAndTimes, [eventType.slug]: { ...selectedDatesAndTimesForEvent, [selectedDate as string]: [...selectedSlots, time], }, }; setSelectedDatesAndTimes(updatedDatesAndTimes); } else if (!selectedDatesAndTimes) { setSelectedDatesAndTimes({ [eventType.slug]: { [selectedDate as string]: [time] }, }); } else { setSelectedDatesAndTimes({ ...selectedDatesAndTimes, [eventType.slug]: { [selectedDate as string]: [time] }, }); } setSelectedTimeslot(time); }; const slots = useSlotsForDate(selectedDate, schedule?.data?.slots); if (!eventType) { return null; } if (!selectedDuration) { setSelectedDuration(eventType.length); } const multipleDurations = eventType?.metadata?.multipleDuration ?? []; const durationsOptions = multipleDurations.map((duration) => ({ label: `${duration} ${t("minutes")}`, value: duration, })); return (
{t("select_date")}
{ setSelectedDate({ date: date === null ? date : date.format("YYYY-MM-DD"), }); }} onMonthChange={(date: Dayjs) => { setMonth(date.format("YYYY-MM")); setSelectedDate({ date: date.format("YYYY-MM-DD") }); }} includedDates={nonEmptyScheduleDays} locale={i18n.language} browsingDate={month ? dayjs(month) : undefined} selected={dayjs(selectedDate)} weekStart={weekdayToWeekIndex(event?.data?.subsetOfUsers?.[0]?.weekStart)} eventSlug={eventType?.slug} />
{selectedDate ? (
{selectedDate ? (
) : null}
) : null}
{t("duration")}
{durationsOptions.length > 0 ? ( value={durationsOptions.find((option) => option.value === selectedDuration)} options={durationsOptions} onChange={(option) => { setSelectedDuration(option?.value); setSelectedDatesAndTimes({}); }} /> ) : ( {t("minutes")}} /> )}
{t("timezone")}
setTimezone(value)} />
); }; const EmailEmbedPreview = ({ eventType, emailContentRef, username, month, selectedDateAndTime, calLink, selectedDuration, userSettingsTimezone, }: { eventType: EventType; timezone?: string; emailContentRef: RefObject; username?: string; month?: string; selectedDateAndTime: { [key: string]: string[] }; calLink: string; selectedDuration: number | undefined; userSettingsTimezone?: string; }) => { const { t } = useLocale(); const { timeFormat, timezoneFromBookerStore, timezoneFromTimePreferences } = useBookerTime(); const timezone = chooseTimezone({ timezoneFromBookerStore, timezoneFromTimePreferences, userSettingsTimezone, }); if (!eventType) { return null; } return (
{eventType.title}
{t("duration")}: {selectedDuration} mins
{t("timezone")}: {timezone}
<> {selectedDateAndTime && Object.keys(selectedDateAndTime) .sort() .map((key) => { const sortedTimes = [...selectedDateAndTime[key]].sort(); const firstSlotOfSelectedDay = sortedTimes[0]; const selectedDate = dayjs(firstSlotOfSelectedDay) .tz(timezone) .format("dddd, MMMM D, YYYY"); return (
{selectedDate}  
{sortedTimes?.length > 0 && sortedTimes.map((time) => { // If teamId is present on eventType and is not null, it means it is a team event. // So we add 'team/' to the url. const bookingURL = `${eventType.bookerUrl}/${ eventType.teamId !== null ? "team/" : "" }${username}/${ eventType.slug }?duration=${selectedDuration}&date=${key}&month=${month}&slot=${time}&cal.tz=${timezone}`; return ( ); })}
{dayjs.utc(time).tz(timezone).format(timeFormat)}  
); })}
{t("powered_by")}{" "} Cal.com
); }; const EmbedTypeCodeAndPreviewDialogContent = ({ embedType, embedUrl, tabs, namespace, eventTypeHideOptionDisabled, types, defaultBrandColor, noQueryParamMode, }: EmbedDialogProps & { embedType: EmbedType; embedUrl: string; namespace: string; noQueryParamMode?: boolean; }) => { const { t } = useLocale(); const searchParams = useCompatSearchParams(); const pathname = usePathname(); const { resetState, gotoState, gotoEmbedTypeSelectionState } = useEmbedGoto(noQueryParamMode); const iframeRef = useRef(null); const dialogContentRef = useRef(null); const emailContentRef = useRef(null); const { data } = useSession(); const [month, selectedDatesAndTimes] = useBookerStoreContext( (state) => [state.month, state.selectedDatesAndTimes], shallow ); const embedParams = useEmbedParams(noQueryParamMode); const eventId = embedParams.eventId; const parsedEventId = parseInt(eventId ?? "", 10); const calLink = decodeURIComponent(embedUrl); const { data: eventTypeData } = trpc.viewer.eventTypes.get.useQuery( { id: parsedEventId }, { enabled: !Number.isNaN(parsedEventId) && embedType === "email", refetchOnWindowFocus: false, } ); const { data: userSettings } = trpc.viewer.me.get.useQuery(); const teamSlug = eventTypeData?.team ? eventTypeData.team.slug : null; const s = (href: string) => { const _searchParams = new URLSearchParams(searchParams.toString()); const [a, b] = href.split("="); _searchParams.set(a, b); return `${pathname?.split("?")[0] ?? ""}?${_searchParams.toString()}`; }; const parsedTabs = tabs.map((t) => { const { href, ...rest } = t; const tabName = href.split("=")[1]; return { ...rest, isActive: tabName === embedParams.embedTabName, ...(noQueryParamMode ? { onClick: () => { gotoState({ embedTabName: tabName }); }, // We still pass the href(which is unique) so that all the tabs aren't marked as active href: t.href, } : { href: s(t.href), }), }; }); const embedCodeRefs: Record<(typeof tabs)[0]["name"], RefObject> = {}; tabs .filter((tab) => tab.type === "code") .forEach((codeTab) => { embedCodeRefs[codeTab.name] = createRef(); }); const refOfEmbedCodesRefs = useRef(embedCodeRefs); const embed = types.find((embed) => embed.type === embedType); const [selectedDuration, setSelectedDuration] = useState(eventTypeData?.eventType.length); const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true); const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true); const defaultConfig = { layout: BookerLayouts.MONTH_VIEW, useSlotsViewOnSmallScreen: "true" as const, }; const paletteDefaultValue = (paletteName: string) => { if (paletteName === "brandColor") { return defaultBrandColor?.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR; } if (paletteName === "darkBrandColor") { return defaultBrandColor?.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR; } return "#000000"; }; const [previewState, setPreviewState] = useState({ inline: { width: "100%", height: "100%", config: defaultConfig, } as PreviewState["inline"], theme: EmbedTheme.auto, layout: defaultConfig.layout, floatingPopup: { config: defaultConfig, } as PreviewState["floatingPopup"], elementClick: { config: defaultConfig, } as PreviewState["elementClick"], hideEventTypeDetails: false, palette: { brandColor: defaultBrandColor?.brandColor ?? null, darkBrandColor: defaultBrandColor?.darkBrandColor ?? null, }, }); const close = () => { resetState(); }; // Use embed-code as default tab if (!embedParams.embedTabName) { gotoState({ embedTabName: "embed-code", }); } if (!embed || !embedUrl) { close(); return null; } const addToPalette = (update: Partial<(typeof previewState)["palette"]>) => { setPreviewState((previewState) => { return { ...previewState, palette: { ...previewState.palette, ...update, }, }; }); }; const previewInstruction = (instruction: { name: string; arg: unknown }) => { iframeRef.current?.contentWindow?.postMessage( { mode: "cal:preview", type: "instruction", instruction, }, "*" ); }; const inlineEmbedDimensionUpdate = ({ width, height }: { width: string; height: string }) => { iframeRef.current?.contentWindow?.postMessage( { mode: "cal:preview", type: "inlineEmbedDimensionUpdate", data: { width: getDimension(width), height: getDimension(height), }, }, "*" ); }; previewInstruction({ name: "ui", arg: { theme: previewState.theme, layout: previewState.layout, hideEventTypeDetails: previewState.hideEventTypeDetails, cssVarsPerTheme: buildCssVarsPerTheme({ brandColor: previewState.palette.brandColor, darkBrandColor: previewState.palette.darkBrandColor, }), }, }); const handleCopyEmailText = () => { const contentElement = emailContentRef.current; if (contentElement !== null) { const range = document.createRange(); range.selectNode(contentElement); const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(range); document.execCommand("copy"); selection.removeAllRanges(); } showToast(t("code_copied"), "success"); } }; if (embedType === "floating-popup") { previewInstruction({ name: "floatingButton", arg: { attributes: { id: "my-floating-button", }, ...previewState.floatingPopup, }, }); } if (embedType === "inline") { inlineEmbedDimensionUpdate({ width: previewState.inline.width, height: previewState.inline.height, }); } const ThemeOptions = [ { value: EmbedTheme.auto, label: "Auto" }, { value: EmbedTheme.dark, label: "Dark Theme" }, { value: EmbedTheme.light, label: "Light Theme" }, ]; const layoutOptions = [ { value: BookerLayouts.MONTH_VIEW, label: t("bookerlayout_month_view") }, { value: BookerLayouts.WEEK_VIEW, label: t("bookerlayout_week_view") }, { value: BookerLayouts.COLUMN_VIEW, label: t("bookerlayout_column_view") }, ]; const FloatingPopupPositionOptions = [ { value: "bottom-right" as const, label: "Bottom right", }, { value: "bottom-left" as const, label: "Bottom left", }, ]; const previewTab = tabs.find((tab) => tab.name === "Preview"); return (

{embed.subtitle}

{eventTypeData?.eventType && embedType === "email" ? ( ) : (
setIsEmbedCustomizationOpen((val) => !val)}> {/* Conditionally render Window Sizing only if inline embed AND NOT React Atom */} {embedType === "inline" && embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
{/*TODO: Add Auto/Fixed toggle from Figma */}
Window sizing
{ setPreviewState((previewState) => { const width = e.target.value || "100%"; return { ...previewState, inline: { ...previewState.inline, width, }, }; }); }} addOnLeading={<>W} />
{ const height = e.target.value || "100%"; setPreviewState((previewState) => { return { ...previewState, inline: { ...previewState.inline, height, }, }; }); }} addOnLeading={<>H} />
)}
Button text
{/* Default Values should come from preview iframe */} { setPreviewState((previewState) => { return { ...previewState, floatingPopup: { ...previewState.floatingPopup, buttonText: e.target.value, }, }; }); }} defaultValue={t("book_my_cal")} required />
{ setPreviewState((previewState) => { return { ...previewState, floatingPopup: { ...previewState.floatingPopup, hideButtonIcon: !checked, }, }; }); }} />
Display calendar icon
Position of button
null, }} onChange={(option) => { if (!option) { return; } setPreviewState((previewState) => { // Ensure theme is updated in config for all embed types const newConfig = (currentConfig?: EmbedConfig) => ({ ...(currentConfig ?? {}), theme: option.value, }); return { ...previewState, inline: { ...previewState.inline, config: newConfig(previewState.inline.config), }, floatingPopup: { ...previewState.floatingPopup, config: newConfig(previewState.floatingPopup.config), }, elementClick: { ...previewState.elementClick, config: newConfig(previewState.elementClick.config), }, // Keep updating top-level theme for preview iframe theme: option.value, }; }); }} options={ThemeOptions} /> )} {/* Conditionally render Hide Details Switch only if NOT Atom embed AND not disabled by prop */} {!eventTypeHideOptionDisabled && embedParams.embedTabName !== EmbedTabName.ATOM_REACT ? (
{ setPreviewState((previewState) => { return { ...previewState, hideEventTypeDetails: checked, }; }); }} />
{t("hide_eventtype_details")}
) : null} {/* Conditionally render Brand Colors only if NOT React Atom */} {embedParams.embedTabName !== EmbedTabName.ATOM_REACT && [ { name: "brandColor", title: "light_brand_color" }, { name: "darkBrandColor", title: "dark_brand_color" }, // { name: "lightColor", title: "Light Color" }, // { name: "lighterColor", title: "Lighter Color" }, // { name: "lightestColor", title: "Lightest Color" }, // { name: "highlightColor", title: "Highlight Color" }, // { name: "medianColor", title: "Median Color" }, ].map((palette) => ( ))}