8c39210a12
* chore: add new view for two step slot selection for embed * fix: make sure two step slot selection is false by default * chore: update embed playground to showcase two step slot selection * chore: add tests * chore: update embed snippet generator code to include two step slot selection * chore: update docs * fix: back button styling * chore: implement feedback from cubic * fix: add missing isEnableTwoStepSlotSelectionVisible properties to test mock store Co-Authored-By: unknown <> * chore: implement PR feedback * chore: update slot selection modal * chore: add prop to hide available times header * fix: scope two-step slot selection visibility to mobile only Restores the isMobile check in the timeslot visibility guard to ensure desktop embeds continue to show the booking UI when two-step slot selection is enabled. The feature should only hide the timeslot list on mobile devices. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> * fixup: use translations for loading instead of actual word * fix: type check * fix: add window.matchMedia mock for jsdom test environment Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: add window.matchMedia mock to packages/testing/src/setupVitest.ts Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: unit tests failing * add a width style to the two-step slot selection embed container. * refactor: rename enableTwoStepSlotSelection to useSlotsViewOnSmallScreen - Rename external-facing embed config option from enableTwoStepSlotSelection to useSlotsViewOnSmallScreen - Update URL parameter parsing in embed-iframe.ts - Update type definitions in types.ts and index.d.ts - Update internal variable names to slotsViewOnSmallScreen for consistency - Update documentation, playground examples, and tests Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Add config to prefill all booking fields so that slot has confirm button along with it * chore: implement PR feedback * fix: move twoStepSlotSelection embed outside misc-embeds div to fix e2e test The e2e test was failing because the misc-embeds div content was intercepting pointer events on mobile viewport. This follows the same pattern used for skeletonDemo - the embed is now outside misc-embeds and the div is hidden when testing this namespace. Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: prevent pointer event interception in twoStepSlotSelection embed Hide heading and note elements and set pointer-events: none on container elements while keeping pointer-events: auto on the iframe container. This prevents the container from intercepting clicks on the iframe during mobile viewport e2e tests. Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: hide all non-essential content for twoStepSlotSelection e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: disable pointer-events on body for twoStepSlotSelection e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: remove pointer-events: auto on container to let clicks pass through to iframe Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: explicitly set pointer-events: none on container and inline-embed-container Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: also set pointer-events: none on html element to prevent interception Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: make .place div fill viewport for twoStepSlotSelection e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: failing embed tests * fixup * fixup fixup * fix: failing tests * fix: update checks for verifying prefilled date --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
1666 lines
59 KiB
TypeScript
1666 lines
59 KiB
TypeScript
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/features/bookings/Booker/utils/event";
|
|
import DatePicker from "@calcom/features/calendars/DatePicker";
|
|
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
|
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
|
|
import type { Slot } from "@calcom/features/schedules/lib/use-schedule/types";
|
|
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
|
|
import { useSlotsForDate } from "@calcom/features/schedules/lib/use-schedule/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 { Icon } from "@calcom/ui/components/icon";
|
|
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
|
import { showToast } from "@calcom/ui/components/toast";
|
|
|
|
import { useBookerTime } from "@calcom/features/bookings/Booker/components/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<string, string>) => {
|
|
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 (
|
|
<components.Control {...props}>
|
|
<Icon name="sun" className="text-subtle mr-2 h-4 w-4" />
|
|
{children}
|
|
</components.Control>
|
|
);
|
|
};
|
|
|
|
const ChooseEmbedTypesDialogContent = ({
|
|
types,
|
|
noQueryParamMode,
|
|
}: {
|
|
types: EmbedTypes;
|
|
noQueryParamMode: boolean;
|
|
}) => {
|
|
const { t } = useLocale();
|
|
const { gotoState } = useEmbedGoto(noQueryParamMode);
|
|
return (
|
|
<DialogContent className="rounded-lg p-10" type="creation" size="lg">
|
|
<div className="mb-2">
|
|
<h3
|
|
className="font-cal text-emphasis mb-2 text-2xl font-semibold leading-none"
|
|
id="modal-title">
|
|
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
|
|
</h3>
|
|
<div>
|
|
<p className="text-subtle text-sm">
|
|
{t("choose_ways_put_cal_site", { appName: APP_NAME })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="items-start stack-y-2 md:flex md:stack-y-0">
|
|
{types.map((embed, index) => (
|
|
<button
|
|
className="hover:bg-subtle bg-cal-muted w-full self-stretch rounded-md border border-transparent p-6 text-left transition hover:rounded-md ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0 lg:w-1/3"
|
|
key={index}
|
|
data-testid={embed.type}
|
|
onClick={() => {
|
|
if (embed.type === "headless") {
|
|
window.open(
|
|
"https://cal.com/help/routing/headless-routing",
|
|
"_blank"
|
|
);
|
|
} else {
|
|
gotoState({
|
|
embedType: embed.type as EmbedType,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<div className="bg-default order-0 box-border flex-none rounded-md border border-solid transition dark:bg-transparent dark:invert">
|
|
{embed.illustration}
|
|
</div>
|
|
<div className="text-emphasis mt-4 font-semibold">
|
|
{embed.title}
|
|
</div>
|
|
<p className="text-subtle mt-2 text-sm">{embed.subtitle}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</DialogContent>
|
|
);
|
|
};
|
|
|
|
const EmailEmbed = ({
|
|
eventType,
|
|
username,
|
|
orgSlug,
|
|
isTeamEvent,
|
|
selectedDuration,
|
|
setSelectedDuration,
|
|
userSettingsTimezone,
|
|
}: {
|
|
eventType?: EventType;
|
|
username: string;
|
|
orgSlug?: string;
|
|
isTeamEvent: boolean;
|
|
selectedDuration: number | undefined;
|
|
setSelectedDuration: Dispatch<SetStateAction<number | undefined>>;
|
|
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 (
|
|
<div className="flex flex-col">
|
|
<div className="mb-[9px] font-medium">
|
|
<Collapsible open>
|
|
<CollapsibleContent>
|
|
<div className="text-default text-sm">{t("select_date")}</div>
|
|
<DatePicker
|
|
isLoading={schedule.isPending}
|
|
onChange={(date: Dayjs | null) => {
|
|
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}
|
|
/>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
{selectedDate ? (
|
|
<div className="mt-[9px] font-medium ">
|
|
{selectedDate ? (
|
|
<div className="flex h-full w-full flex-col gap-4">
|
|
<AvailableTimesHeader date={dayjs(selectedDate)} />
|
|
<AvailableTimes
|
|
className="w-full"
|
|
selectedSlots={
|
|
eventType.slug &&
|
|
selectedDatesAndTimes &&
|
|
selectedDatesAndTimes[eventType.slug] &&
|
|
selectedDatesAndTimes[eventType.slug][selectedDate as string]
|
|
? selectedDatesAndTimes[eventType.slug][
|
|
selectedDate as string
|
|
]
|
|
: undefined
|
|
}
|
|
handleSlotClick={handleSlotClick}
|
|
slots={slots}
|
|
showAvailableSeatsCount={eventType.seatsShowAvailabilityCount}
|
|
event={event}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<div className="mb-[9px] font-medium ">
|
|
<Collapsible open>
|
|
<CollapsibleContent>
|
|
<div className="text-default mb-[9px] text-sm">{t("duration")}</div>
|
|
{durationsOptions.length > 0 ? (
|
|
<Select<{ label: string; value: number }>
|
|
value={durationsOptions.find(
|
|
(option) => option.value === selectedDuration
|
|
)}
|
|
options={durationsOptions}
|
|
onChange={(option) => {
|
|
setSelectedDuration(option?.value);
|
|
setSelectedDatesAndTimes({});
|
|
}}
|
|
/>
|
|
) : (
|
|
<TextField
|
|
disabled
|
|
label={t("duration")}
|
|
defaultValue={eventType?.length ?? 15}
|
|
addOnSuffix={<>{t("minutes")}</>}
|
|
/>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
<div className="mb-[9px] font-medium ">
|
|
<Collapsible open>
|
|
<CollapsibleContent>
|
|
<div className="text-default mb-[9px] text-sm">{t("timezone")}</div>
|
|
<TimezoneSelect
|
|
id="timezone"
|
|
value={timezone}
|
|
onChange={({ value }) => setTimezone(value)}
|
|
/>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const EmailEmbedPreview = ({
|
|
eventType,
|
|
emailContentRef,
|
|
username,
|
|
month,
|
|
selectedDateAndTime,
|
|
calLink,
|
|
selectedDuration,
|
|
userSettingsTimezone,
|
|
}: {
|
|
eventType: EventType;
|
|
timezone?: string;
|
|
emailContentRef: RefObject<HTMLDivElement>;
|
|
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 (
|
|
<div className="flex h-full items-center justify-center border p-5 last:font-medium">
|
|
<div className="border bg-white p-4">
|
|
<div
|
|
style={{
|
|
paddingBottom: "3px",
|
|
fontSize: "13px",
|
|
color: "black",
|
|
lineHeight: "1.4",
|
|
minWidth: "30vw",
|
|
maxHeight: "50vh",
|
|
overflowY: "auto",
|
|
backgroundColor: "white",
|
|
}}
|
|
ref={emailContentRef}
|
|
>
|
|
<div
|
|
style={{
|
|
fontStyle: "normal",
|
|
fontSize: "20px",
|
|
fontWeight: "bold",
|
|
lineHeight: "19px",
|
|
marginTop: "15px",
|
|
marginBottom: "15px",
|
|
}}
|
|
>
|
|
<b style={{ color: "black" }}> {eventType.title}</b>
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontStyle: "normal",
|
|
fontWeight: "normal",
|
|
fontSize: "14px",
|
|
lineHeight: "17px",
|
|
color: "#333333",
|
|
}}
|
|
>
|
|
{t("duration")}:{" "}
|
|
<b style={{ color: "black" }}>{selectedDuration} mins</b>
|
|
</div>
|
|
<div>
|
|
<b style={{ color: "black" }}>
|
|
<span
|
|
style={{
|
|
fontStyle: "normal",
|
|
fontWeight: "normal",
|
|
fontSize: "14px",
|
|
lineHeight: "17px",
|
|
color: "#333333",
|
|
}}
|
|
>
|
|
{t("timezone")}: <b style={{ color: "black" }}>{timezone}</b>
|
|
</span>
|
|
</b>
|
|
</div>
|
|
<b style={{ color: "black" }}>
|
|
<>
|
|
{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 (
|
|
<table
|
|
key={key}
|
|
style={{
|
|
marginTop: "16px",
|
|
textAlign: "left",
|
|
borderCollapse: "collapse",
|
|
borderSpacing: "0px",
|
|
}}
|
|
>
|
|
<tbody>
|
|
<tr>
|
|
<td
|
|
style={{ textAlign: "left", marginTop: "16px" }}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: "14px",
|
|
lineHeight: "16px",
|
|
paddingBottom: "8px",
|
|
color: "rgb(26, 26, 26)",
|
|
fontWeight: "bold",
|
|
}}
|
|
>
|
|
{selectedDate}
|
|
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<table
|
|
style={{
|
|
borderCollapse: "separate",
|
|
borderSpacing: "0px 4px",
|
|
}}
|
|
>
|
|
<tbody>
|
|
<tr style={{ height: "25px" }}>
|
|
{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 (
|
|
<td
|
|
key={time}
|
|
style={{
|
|
padding: "0px",
|
|
width: "64px",
|
|
display: "inline-block",
|
|
marginRight: "4px",
|
|
marginBottom: "4px",
|
|
height: "24px",
|
|
border: "1px solid #111827",
|
|
borderRadius: "3px",
|
|
}}
|
|
>
|
|
<table style={{ height: "21px" }}>
|
|
<tbody>
|
|
<tr style={{ height: "21px" }}>
|
|
<td
|
|
style={{ width: "7px" }}
|
|
/>
|
|
<td
|
|
style={{
|
|
width: "50px",
|
|
textAlign: "center",
|
|
marginRight: "1px",
|
|
}}
|
|
>
|
|
<a
|
|
href={bookingURL}
|
|
className="spot"
|
|
style={{
|
|
fontFamily:
|
|
'"Proxima Nova", sans-serif',
|
|
textDecoration: "none",
|
|
textAlign: "center",
|
|
color: "#111827",
|
|
fontSize: "12px",
|
|
lineHeight: "16px",
|
|
}}
|
|
>
|
|
<b
|
|
style={{
|
|
fontWeight: "normal",
|
|
textDecoration:
|
|
"none",
|
|
}}
|
|
>
|
|
{dayjs
|
|
.utc(time)
|
|
.tz(timezone)
|
|
.format(timeFormat)}
|
|
|
|
</b>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
);
|
|
})}
|
|
<div style={{ marginTop: "13px" }}>
|
|
<a
|
|
className="more"
|
|
data-testid="see_all_available_times"
|
|
href={`${eventType.bookerUrl}/${calLink}?cal.tz=${timezone}`}
|
|
style={{
|
|
textDecoration: "none",
|
|
cursor: "pointer",
|
|
color: "black",
|
|
}}
|
|
>
|
|
{t("see_all_available_times")}
|
|
</a>
|
|
</div>
|
|
</>
|
|
</b>
|
|
<div
|
|
className="w-full text-right"
|
|
style={{
|
|
borderTop: "1px solid #CCCCCC",
|
|
marginTop: "8px",
|
|
paddingTop: "8px",
|
|
}}
|
|
>
|
|
<span>{t("powered_by")}</span>{" "}
|
|
<b style={{ color: "black" }}>
|
|
<span> Cal.com</span>
|
|
</b>
|
|
</div>
|
|
</div>
|
|
<b style={{ color: "black" }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<HTMLIFrameElement>(null);
|
|
const dialogContentRef = useRef<HTMLDivElement>(null);
|
|
const emailContentRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>
|
|
> = {};
|
|
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<PreviewState>({
|
|
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 (
|
|
<DialogContent
|
|
enableOverflow
|
|
ref={dialogContentRef}
|
|
className="rounded-lg p-0.5 sm:max-w-7xl!"
|
|
type="creation"
|
|
>
|
|
<div className="flex">
|
|
<div className="bg-cal-muted flex h-[95vh] w-1/3 flex-col overflow-y-auto p-8">
|
|
<h3
|
|
className="text-emphasis mb-2.5 flex items-center text-xl font-semibold leading-5"
|
|
id="modal-title"
|
|
>
|
|
<button className="h-6 w-6" onClick={gotoEmbedTypeSelectionState}>
|
|
<Icon name="arrow-left" className="mr-4 w-4" />
|
|
</button>
|
|
{embed.title}
|
|
</h3>
|
|
<h4 className="text-subtle mb-6 text-sm font-normal">
|
|
{embed.subtitle}
|
|
</h4>
|
|
{eventTypeData?.eventType && embedType === "email" ? (
|
|
<EmailEmbed
|
|
eventType={eventTypeData?.eventType}
|
|
username={teamSlug ?? (data?.user.username as string)}
|
|
userSettingsTimezone={userSettings?.timeZone}
|
|
orgSlug={data?.user?.org?.slug}
|
|
isTeamEvent={!!teamSlug}
|
|
selectedDuration={selectedDuration}
|
|
setSelectedDuration={setSelectedDuration}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col">
|
|
<div
|
|
className={classNames(
|
|
"font-medium",
|
|
embedType === "element-click" ? "hidden" : ""
|
|
)}
|
|
>
|
|
<Collapsible
|
|
open={isEmbedCustomizationOpen}
|
|
onOpenChange={() =>
|
|
setIsEmbedCustomizationOpen((val) => !val)
|
|
}
|
|
>
|
|
<CollapsibleContent className="text-sm">
|
|
{/* Conditionally render Window Sizing only if inline embed AND NOT React Atom */}
|
|
{embedType === "inline" &&
|
|
embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
|
|
<div>
|
|
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
|
<div className="text-default mb-[9px] text-sm">
|
|
Window sizing
|
|
</div>
|
|
<div className="justify-left mb-6 flex items-center font-normal! ">
|
|
<div className="mr-[9px]">
|
|
<TextField
|
|
labelProps={{ className: "hidden" }}
|
|
className="focus:ring-offset-0"
|
|
required
|
|
value={previewState.inline.width}
|
|
onChange={(e) => {
|
|
setPreviewState((previewState) => {
|
|
const width = e.target.value || "100%";
|
|
|
|
return {
|
|
...previewState,
|
|
inline: {
|
|
...previewState.inline,
|
|
width,
|
|
},
|
|
};
|
|
});
|
|
}}
|
|
addOnLeading={<>W</>}
|
|
/>
|
|
</div>
|
|
|
|
<TextField
|
|
labelProps={{ className: "hidden" }}
|
|
className="focus:ring-offset-0"
|
|
value={previewState.inline.height}
|
|
required
|
|
onChange={(e) => {
|
|
const height = e.target.value || "100%";
|
|
|
|
setPreviewState((previewState) => {
|
|
return {
|
|
...previewState,
|
|
inline: {
|
|
...previewState.inline,
|
|
height,
|
|
},
|
|
};
|
|
});
|
|
}}
|
|
addOnLeading={<>H</>}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={classNames(
|
|
"items-center justify-between",
|
|
embedType === "floating-popup"
|
|
? "text-emphasis"
|
|
: "hidden"
|
|
)}
|
|
>
|
|
<div className="mb-2 text-sm">Button text</div>
|
|
{/* Default Values should come from preview iframe */}
|
|
<TextField
|
|
labelProps={{ className: "hidden" }}
|
|
onChange={(e) => {
|
|
setPreviewState((previewState) => {
|
|
return {
|
|
...previewState,
|
|
floatingPopup: {
|
|
...previewState.floatingPopup,
|
|
buttonText: e.target.value,
|
|
},
|
|
};
|
|
});
|
|
}}
|
|
defaultValue={t("book_my_cal")}
|
|
required
|
|
/>
|
|
</div>
|
|
<div
|
|
className={classNames(
|
|
"mt-4 flex items-center justify-start",
|
|
embedType === "floating-popup"
|
|
? "text-emphasis space-x-2 rtl:space-x-reverse"
|
|
: "hidden"
|
|
)}
|
|
>
|
|
<Switch
|
|
defaultChecked={true}
|
|
onCheckedChange={(checked) => {
|
|
setPreviewState((previewState) => {
|
|
return {
|
|
...previewState,
|
|
floatingPopup: {
|
|
...previewState.floatingPopup,
|
|
hideButtonIcon: !checked,
|
|
},
|
|
};
|
|
});
|
|
}}
|
|
/>
|
|
<div className="text-default my-2 text-sm">
|
|
Display calendar icon
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={classNames(
|
|
"mt-4 items-center justify-between",
|
|
embedType === "floating-popup"
|
|
? "text-emphasis"
|
|
: "hidden"
|
|
)}
|
|
>
|
|
<div className="mb-2">Position of button</div>
|
|
<Select
|
|
onChange={(position) => {
|
|
setPreviewState((previewState) => {
|
|
return {
|
|
...previewState,
|
|
floatingPopup: {
|
|
...previewState.floatingPopup,
|
|
buttonPosition: position?.value,
|
|
},
|
|
};
|
|
});
|
|
}}
|
|
defaultValue={FloatingPopupPositionOptions[0]}
|
|
options={FloatingPopupPositionOptions}
|
|
/>
|
|
</div>
|
|
<div className="mt-3 flex flex-col xl:flex-row xl:justify-between">
|
|
<div
|
|
className={classNames(
|
|
"mt-4",
|
|
embedType === "floating-popup" ? "" : "hidden"
|
|
)}
|
|
>
|
|
<div className="whitespace-nowrap">Button color</div>
|
|
<div className="mt-2 w-40 xl:mt-0 xl:w-full">
|
|
<ColorPicker
|
|
className="w-[130px]"
|
|
popoverAlign="start"
|
|
container={dialogContentRef?.current ?? undefined}
|
|
defaultValue="#000000"
|
|
onChange={(color) => {
|
|
setPreviewState((previewState) => {
|
|
return {
|
|
...previewState,
|
|
floatingPopup: {
|
|
...previewState.floatingPopup,
|
|
buttonColor: color,
|
|
},
|
|
};
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={classNames(
|
|
"mt-4",
|
|
embedType === "floating-popup" ? "" : "hidden"
|
|
)}
|
|
>
|
|
<div className="whitespace-nowrap">Text color</div>
|
|
<div className="mb-6 mt-2 w-40 xl:mt-0 xl:w-full">
|
|
<ColorPicker
|
|
className="w-[130px]"
|
|
popoverAlign="start"
|
|
container={dialogContentRef?.current ?? undefined}
|
|
defaultValue="#000000"
|
|
onChange={(color) => {
|
|
setPreviewState((previewState) => {
|
|
return {
|
|
...previewState,
|
|
floatingPopup: {
|
|
...previewState.floatingPopup,
|
|
buttonTextColor: color,
|
|
},
|
|
};
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
<div className="font-medium">
|
|
<Collapsible
|
|
open={isBookingCustomizationOpen}
|
|
onOpenChange={() =>
|
|
setIsBookingCustomizationOpen((val) => !val)
|
|
}
|
|
>
|
|
<CollapsibleContent>
|
|
<div className="text-sm">
|
|
{/* Conditionally render EmbedTheme only if NOT React Atom */}
|
|
{embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
|
|
<Label className="mb-6">
|
|
<div className="mb-2">Embed theme</div>
|
|
<Select
|
|
className="w-full"
|
|
defaultValue={ThemeOptions[0]}
|
|
components={{
|
|
Control: ThemeSelectControl,
|
|
IndicatorSeparator: () => 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}
|
|
/>
|
|
</Label>
|
|
)}
|
|
{/* Conditionally render Hide Details Switch only if NOT Atom embed AND not disabled by prop */}
|
|
{!eventTypeHideOptionDisabled &&
|
|
embedParams.embedTabName !== EmbedTabName.ATOM_REACT ? (
|
|
<div className="mb-6 flex items-center justify-start space-x-2 rtl:space-x-reverse">
|
|
<Switch
|
|
checked={previewState.hideEventTypeDetails}
|
|
onCheckedChange={(checked) => {
|
|
setPreviewState((previewState) => {
|
|
return {
|
|
...previewState,
|
|
hideEventTypeDetails: checked,
|
|
};
|
|
});
|
|
}}
|
|
/>
|
|
<div className="text-default text-sm">
|
|
{t("hide_eventtype_details")}
|
|
</div>
|
|
</div>
|
|
) : 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) => (
|
|
<Label key={palette.name} className="mb-6">
|
|
<div className="mb-2">{t(palette.title)}</div>
|
|
<div className="w-full">
|
|
<ColorPicker
|
|
popoverAlign="start"
|
|
container={
|
|
dialogContentRef?.current ?? undefined
|
|
}
|
|
defaultValue={paletteDefaultValue(palette.name)}
|
|
onChange={(color) => {
|
|
addToPalette({
|
|
[palette.name as keyof (typeof previewState)["palette"]]:
|
|
color,
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</Label>
|
|
))}
|
|
<Label className="mb-6">
|
|
<div className="mb-2">{t("layout")}</div>
|
|
<Select
|
|
className="w-full"
|
|
defaultValue={layoutOptions[0]}
|
|
onChange={(option) => {
|
|
if (!option) {
|
|
return;
|
|
}
|
|
setPreviewState((previewState) => {
|
|
// Ensure layout is updated in config for all embed types
|
|
const newConfig = (
|
|
currentConfig?: EmbedConfig
|
|
) => ({
|
|
...(currentConfig ?? {}),
|
|
layout: 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 layout for preview iframe
|
|
layout: option.value,
|
|
};
|
|
});
|
|
}}
|
|
options={layoutOptions}
|
|
/>
|
|
</Label>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex h-[95vh] w-2/3 flex-col px-8 pt-8">
|
|
<HorizontalTabs
|
|
data-testid="embed-tabs"
|
|
tabs={
|
|
embedType === "email"
|
|
? parsedTabs.filter((tab) => tab.name === "Preview")
|
|
: parsedTabs.filter((tab) => tab.name !== "Preview")
|
|
}
|
|
linkShallow
|
|
/>
|
|
<>
|
|
<div className="flex h-full flex-col">
|
|
{tabs.map((tab) => {
|
|
if (embedType !== "email") {
|
|
if (tab.name === "Preview") return null;
|
|
return (
|
|
<div
|
|
key={tab.href}
|
|
className={classNames(
|
|
embedParams.embedTabName === tab.href.split("=")[1]
|
|
? "flex-1"
|
|
: "hidden"
|
|
)}
|
|
>
|
|
{tab.type === "code" && (
|
|
<tab.Component
|
|
namespace={namespace}
|
|
embedType={embedType}
|
|
calLink={calLink}
|
|
previewState={previewState}
|
|
ref={refOfEmbedCodesRefs.current[tab.name]}
|
|
/>
|
|
)}
|
|
<div
|
|
className={
|
|
embedParams.embedTabName === "embed-preview"
|
|
? "mt-2 block"
|
|
: "hidden"
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (
|
|
embedType === "email" &&
|
|
(tab.name !== "Preview" || !eventTypeData?.eventType)
|
|
)
|
|
return;
|
|
|
|
return (
|
|
<div
|
|
key={tab.href}
|
|
className={classNames("flex grow flex-col")}
|
|
>
|
|
<div className="flex h-[55vh] grow flex-col">
|
|
<EmailEmbedPreview
|
|
selectedDuration={selectedDuration}
|
|
calLink={calLink}
|
|
eventType={eventTypeData?.eventType}
|
|
emailContentRef={emailContentRef}
|
|
username={teamSlug ?? (data?.user.username as string)}
|
|
userSettingsTimezone={userSettings?.timeZone}
|
|
month={month as string}
|
|
selectedDateAndTime={
|
|
selectedDatesAndTimes
|
|
? selectedDatesAndTimes[
|
|
eventTypeData?.eventType.slug as string
|
|
]
|
|
: {}
|
|
}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={
|
|
embedParams.embedTabName === "embed-preview"
|
|
? "mt-2 block"
|
|
: "hidden"
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{embedType !== "email" && previewTab && (
|
|
<div className="flex-1">
|
|
<previewTab.Component
|
|
namespace={namespace}
|
|
embedType={embedType}
|
|
calLink={calLink}
|
|
previewState={previewState}
|
|
ref={iframeRef}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter
|
|
className="mt-10 flex-row-reverse gap-x-2"
|
|
showDivider
|
|
>
|
|
<DialogClose />
|
|
<Button
|
|
type="submit"
|
|
onClick={() => {
|
|
if (embedType === "email") {
|
|
handleCopyEmailText();
|
|
} else {
|
|
const currentTabHref = embedParams.embedTabName;
|
|
const currentTabName = tabs.find(
|
|
(tab) => tab.href === `embedTabName=${currentTabHref}`
|
|
)?.name;
|
|
if (!currentTabName) return;
|
|
const currentTabCodeEl =
|
|
refOfEmbedCodesRefs.current[currentTabName].current;
|
|
if (!currentTabCodeEl) {
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(currentTabCodeEl.value);
|
|
showToast(t("code_copied"), "success");
|
|
}
|
|
}}
|
|
>
|
|
{embedType === "email" ? t("copy") : t("copy_code")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
);
|
|
};
|
|
|
|
export const EmbedDialog = ({
|
|
types,
|
|
tabs,
|
|
eventTypeHideOptionDisabled,
|
|
defaultBrandColor,
|
|
noQueryParamMode = false,
|
|
}: EmbedDialogProps) => {
|
|
const { embedState, setEmbedState } = useEmbedDialogCtx(noQueryParamMode);
|
|
const embedParams = useEmbedParams(noQueryParamMode);
|
|
|
|
const handleDialogClose = () => {
|
|
if (noQueryParamMode) {
|
|
setEmbedState(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<BookerStoreProvider>
|
|
<Dialog
|
|
{...(noQueryParamMode
|
|
? {
|
|
open: embedState !== null,
|
|
onOpenChange: (open) => !open && handleDialogClose(),
|
|
}
|
|
: {
|
|
// Must not set name when noQueryParam mode as required by Dialog component
|
|
name: "embed",
|
|
clearQueryParamsOnClose: queryParamsForDialog,
|
|
})}
|
|
>
|
|
{!embedParams.embedType ? (
|
|
<ChooseEmbedTypesDialogContent
|
|
types={types}
|
|
noQueryParamMode={noQueryParamMode}
|
|
/>
|
|
) : (
|
|
<EmbedTypeCodeAndPreviewDialogContent
|
|
embedType={embedParams.embedType as EmbedType}
|
|
embedUrl={embedParams.embedUrl}
|
|
namespace={embedParams.namespace}
|
|
tabs={tabs}
|
|
types={types}
|
|
eventTypeHideOptionDisabled={eventTypeHideOptionDisabled}
|
|
defaultBrandColor={defaultBrandColor}
|
|
noQueryParamMode={noQueryParamMode}
|
|
/>
|
|
)}
|
|
</Dialog>
|
|
</BookerStoreProvider>
|
|
);
|
|
};
|
|
|
|
type EmbedButtonProps<T> = {
|
|
embedUrl: string;
|
|
namespace: string;
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
as?: T;
|
|
eventId?: number;
|
|
noQueryParamMode?: boolean;
|
|
};
|
|
|
|
export const EmbedButton = <T extends React.ElementType = typeof Button>({
|
|
embedUrl,
|
|
children,
|
|
className = "",
|
|
as,
|
|
eventId,
|
|
namespace,
|
|
noQueryParamMode,
|
|
...props
|
|
}: EmbedButtonProps<T> & React.ComponentPropsWithoutRef<T>) => {
|
|
const { gotoState } = useEmbedGoto(noQueryParamMode);
|
|
className = classNames("hidden lg:inline-flex", className);
|
|
|
|
const openEmbedModal = () => {
|
|
gotoState({
|
|
dialog: "embed",
|
|
eventId: eventId ? eventId.toString() : "",
|
|
namespace,
|
|
embedUrl,
|
|
});
|
|
};
|
|
const Component = as ?? Button;
|
|
|
|
return (
|
|
<Component
|
|
{...props}
|
|
className={className}
|
|
data-test-embed-url={embedUrl}
|
|
data-testid="embed"
|
|
type="button"
|
|
onClick={openEmbedModal}
|
|
>
|
|
{children}
|
|
</Component>
|
|
);
|
|
};
|