2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@ -0,0 +1,3 @@
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID="clye3x2f6000133strp0tf85t"
VITE_BOOKER_EMBED_API_URL="http://localhost:5555/api/v2"

View File

@ -0,0 +1 @@
globals.min.css

View File

@ -0,0 +1 @@
Atoms - customizable UI components to integrate scheduling into products.

View File

@ -0,0 +1,587 @@
import { useMemo, useState, useEffect } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
import WebSchedule, {
ScheduleComponent as PlatformSchedule,
} from "@calcom/features/schedules/components/Schedule";
import WebShell from "@calcom/features/shell/Shell";
import { availabilityAsString } from "@calcom/lib/availability";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { sortAvailabilityStrings } from "@calcom/lib/weekstart";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { TimeRange, WorkingHours } from "@calcom/types/schedule";
import {
Button,
ConfirmationDialogContent,
EditableHeading,
Form,
SkeletonText,
Dialog,
DialogTrigger,
Label,
SelectSkeletonLoader,
Skeleton,
Switch,
TimezoneSelect as WebTimezoneSelect,
Tooltip,
VerticalDivider,
} from "@calcom/ui";
import { Icon } from "@calcom/ui";
import { Shell as PlatformShell } from "../src/components/ui/shell";
import { cn } from "../src/lib/utils";
import { Timezone as PlatformTimzoneSelect } from "../timezone/index";
import type { AvailabilityFormValues } from "./types";
export type Schedule = {
id: number;
startTime: Date;
endTime: Date;
userId: number | null;
eventTypeId: number | null;
date: Date | null;
scheduleId: number | null;
days: number[];
};
export type CustomClassNames = {
containerClassName?: string;
ctaClassName?: string;
editableHeadingClassName?: string;
formClassName?: string;
timezoneSelectClassName?: string;
subtitlesClassName?: string;
scheduleClassNames?: {
scheduleContainer?: string;
scheduleDay?: string;
dayRanges?: string;
timeRanges?: string;
labelAndSwitchContainer?: string;
};
};
export type Availability = Pick<Schedule, "days" | "startTime" | "endTime">;
type AvailabilitySettingsProps = {
skeletonLabel?: string;
schedule: {
name: string;
id: number;
availability: TimeRange[][];
isLastSchedule: boolean;
isDefault: boolean;
workingHours: WorkingHours[];
dateOverrides: { ranges: TimeRange[] }[];
timeZone: string;
schedule: Availability[];
};
travelSchedules?: RouterOutputs["viewer"]["getTravelSchedules"];
handleDelete: () => void;
isDeleting: boolean;
isSaving: boolean;
isLoading: boolean;
timeFormat: number | null;
weekStart: string;
backPath: string | boolean;
handleSubmit: (data: AvailabilityFormValues) => Promise<void>;
isPlatform?: boolean;
customClassNames?: CustomClassNames;
disableEditableHeading?: boolean;
enableOverrides?: boolean;
};
const DeleteDialogButton = ({
disabled,
buttonClassName,
isPending,
onDeleteConfirmed,
handleDelete,
}: {
disabled?: boolean;
onDeleteConfirmed?: () => void;
buttonClassName: string;
handleDelete: () => void;
isPending: boolean;
}) => {
const { t } = useLocale();
return (
<Dialog>
<DialogTrigger asChild>
<Button
StartIcon="trash"
variant="icon"
color="destructive"
aria-label={t("delete")}
className={buttonClassName}
disabled={disabled}
tooltip={disabled ? t("requires_at_least_one_schedule") : t("delete")}
/>
</DialogTrigger>
<ConfirmationDialogContent
isPending={isPending}
variety="danger"
title={t("delete_schedule")}
confirmBtnText={t("delete")}
loadingText={t("delete")}
onConfirm={() => {
handleDelete();
onDeleteConfirmed?.();
}}>
{t("delete_schedule_description")}
</ConfirmationDialogContent>
</Dialog>
);
};
const useExcludedDates = () => {
const watchValues = useWatch<AvailabilityFormValues>({ name: "dateOverrides" }) as {
ranges: TimeRange[];
}[];
return useMemo(() => {
return watchValues?.map((field) => dayjs(field.ranges[0].start).utc().format("YYYY-MM-DD"));
}, [watchValues]);
};
const DateOverride = ({
workingHours,
userTimeFormat,
travelSchedules,
weekStart,
}: {
workingHours: WorkingHours[];
userTimeFormat: number | null;
travelSchedules?: RouterOutputs["viewer"]["getTravelSchedules"];
weekStart: 0 | 1 | 2 | 3 | 4 | 5 | 6;
}) => {
const { append, replace, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
name: "dateOverrides",
});
const excludedDates = useExcludedDates();
const { t } = useLocale();
return (
<div className="p-6">
<h3 className="text-emphasis font-medium leading-6">
{t("date_overrides")}{" "}
<Tooltip content={t("date_overrides_info")}>
<span className="inline-block align-middle">
<Icon name="info" className="h-4 w-4" />
</span>
</Tooltip>
</h3>
<p className="text-subtle mb-4 text-sm">{t("date_overrides_subtitle")}</p>
<div className="space-y-2">
<DateOverrideList
excludedDates={excludedDates}
replace={replace}
fields={fields}
weekStart={weekStart}
workingHours={workingHours}
userTimeFormat={userTimeFormat}
hour12={Boolean(userTimeFormat === 12)}
travelSchedules={travelSchedules}
/>
<DateOverrideInputDialog
workingHours={workingHours}
excludedDates={excludedDates}
onChange={(ranges) => ranges.forEach((range) => append({ ranges: [range] }))}
userTimeFormat={userTimeFormat}
weekStart={weekStart}
Trigger={
<Button color="secondary" StartIcon="plus" data-testid="add-override">
{t("add_an_override")}
</Button>
}
/>
</div>
</div>
);
};
// Simplify logic by assuming this will never be opened on a large screen
const SmallScreenSideBar = ({ open, children }: { open: boolean; children: JSX.Element }) => {
return (
<div
className={classNames(
open
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
: ""
)}>
<div
className={classNames(
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
open ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
{open ? children : null}
</div>
</div>
);
};
export function AvailabilitySettings({
schedule,
travelSchedules,
handleDelete,
isDeleting,
isLoading,
isSaving,
timeFormat,
weekStart,
backPath,
handleSubmit,
isPlatform = false,
customClassNames,
disableEditableHeading = false,
enableOverrides = false,
}: AvailabilitySettingsProps) {
const [openSidebar, setOpenSidebar] = useState(false);
const { t, i18n } = useLocale();
const form = useForm<AvailabilityFormValues>({
defaultValues: {
...schedule,
schedule: schedule.availability || [],
},
});
useEffect(() => {
const subscription = form.watch(
(value, { name }) => {
if (!!name && name.split(".")[0] !== "schedule" && name !== "name")
handleSubmit(value as AvailabilityFormValues);
},
{
...schedule,
schedule: schedule.availability || [],
}
);
return () => subscription.unsubscribe();
}, [form.watch]);
const [Shell, Schedule, TimezoneSelect] = useMemo(() => {
return isPlatform
? [PlatformShell, PlatformSchedule, PlatformTimzoneSelect]
: [WebShell, WebSchedule, WebTimezoneSelect];
}, [isPlatform]);
return (
<Shell
headerClassName={cn(customClassNames?.containerClassName)}
backPath={backPath}
title={schedule.name ? `${schedule.name} | ${t("availability")}` : t("availability")}
heading={
<Controller
control={form.control}
name="name"
render={({ field }) => (
<EditableHeading
className={cn(customClassNames?.editableHeadingClassName)}
isReady={!isLoading}
disabled={disableEditableHeading}
{...field}
data-testid="availablity-title"
/>
)}
/>
}
subtitle={
schedule ? (
schedule.schedule
.filter((availability) => !!availability.days.length)
.map((availability) =>
availabilityAsString(availability, {
locale: i18n.language,
hour12: timeFormat === 12,
})
)
// sort the availability strings as per user's weekstart (settings)
.sort(sortAvailabilityStrings(i18n.language, weekStart))
.map((availabilityString, index) => (
<span key={index} className={cn(customClassNames?.subtitlesClassName)}>
{availabilityString}
<br />
</span>
))
) : (
<SkeletonText className="h-4 w-48" />
)
}
CTA={
<div className={cn(customClassNames?.ctaClassName, "flex items-center justify-end")}>
<div className="sm:hover:bg-muted hidden items-center rounded-md px-2 sm:flex">
{!openSidebar ? (
<>
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 cursor-pointer self-center pe-2"
loadingClassName="me-4"
waitForTranslation={!isPlatform}>
{t("set_to_default")}
</Skeleton>
<Controller
control={form.control}
name="isDefault"
render={({ field: { value, onChange } }) => (
<Switch
id="hiddenSwitch"
disabled={isSaving || schedule.isDefault}
checked={value}
onCheckedChange={onChange}
/>
)}
/>
</>
) : null}
</div>
<VerticalDivider className="hidden sm:inline" />
<DeleteDialogButton
buttonClassName="hidden sm:inline"
disabled={schedule.isLastSchedule}
isPending={isDeleting}
handleDelete={handleDelete}
/>
<VerticalDivider className="hidden sm:inline" />
<SmallScreenSideBar open={openSidebar}>
<>
<div
className={classNames(
openSidebar
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
: ""
)}>
<div
className={classNames(
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
<div className="flex flex-row items-center pt-5">
<Button StartIcon="arrow-left" color="minimal" onClick={() => setOpenSidebar(false)} />
<p className="-ml-2">{t("availability_settings")}</p>
<DeleteDialogButton
buttonClassName="ml-16 inline"
disabled={schedule.isLastSchedule}
isPending={isDeleting}
handleDelete={handleDelete}
onDeleteConfirmed={() => {
setOpenSidebar(false);
}}
/>
</div>
<div className="flex flex-col px-2 py-2">
<Skeleton as={Label} waitForTranslation={!isPlatform}>
{t("name")}
</Skeleton>
<Controller
control={form.control}
name="name"
render={({ field }) => (
<input
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle focus:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
{...field}
/>
)}
/>
</div>
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 cursor-pointer self-center pr-2 sm:inline"
waitForTranslation={!isPlatform}>
{t("set_to_default")}
</Skeleton>
<Controller
control={form.control}
name="isDefault"
render={({ field: { value, onChange } }) => (
<Switch
id="hiddenSwitch"
disabled={isSaving || value}
checked={value}
onCheckedChange={onChange}
/>
)}
/>
</div>
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton
as={Label}
htmlFor="timeZone-sm-viewport"
className="mb-0 inline-block leading-none"
waitForTranslation={!isPlatform}>
{t("timezone")}
</Skeleton>
<Controller
control={form.control}
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-sm-viewport"
value={value}
className={cn(
"focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm",
customClassNames?.timezoneSelectClassName
)}
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
{!isPlatform && (
<>
<hr className="border-subtle my-7" />
<div className="rounded-md md:block">
<Skeleton
as="h3"
className="mb-0 inline-block text-sm font-medium"
waitForTranslation={!isPlatform}>
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton
as={Button}
href="/availability/troubleshoot"
color="secondary"
waitForTranslation={!isPlatform}>
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
</>
)}
</div>
</div>
</div>
</div>
</>
</SmallScreenSideBar>
<div className="border-default border-l-2" />
<Button className="ml-4 lg:ml-0" type="submit" form="availability-form" loading={isSaving}>
{t("save")}
</Button>
<Button
className="ml-3 sm:hidden"
StartIcon="ellipsis-vertical"
variant="icon"
color="secondary"
onClick={() => setOpenSidebar(true)}
/>
</div>
}>
<div className="mt-4 w-full md:mt-0">
<Form
form={form}
id="availability-form"
handleSubmit={async (props) => {
handleSubmit(props);
}}
className={cn(customClassNames?.formClassName, "flex flex-col sm:mx-0 xl:flex-row xl:space-x-6")}>
<div className="flex-1 flex-row xl:mr-0">
<div className="border-subtle mb-6 rounded-md border">
<div>
{typeof weekStart === "string" && (
<Schedule
labels={{
addTime: t("add_time_availability"),
copyTime: t("copy_times_to"),
deleteTime: t("delete"),
}}
className={
customClassNames?.scheduleClassNames ? { ...customClassNames.scheduleClassNames } : {}
}
control={form.control}
name="schedule"
userTimeFormat={timeFormat}
handleSubmit={handleSubmit}
weekStart={
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
/>
)}
</div>
</div>
{enableOverrides && (
<DateOverride
workingHours={schedule.workingHours}
userTimeFormat={timeFormat}
travelSchedules={travelSchedules}
weekStart={
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
/>
)}
</div>
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton
as={Label}
htmlFor="timeZone-lg-viewport"
className="mb-0 inline-block leading-none"
waitForTranslation={!isPlatform}>
{t("timezone")}
</Skeleton>
<Controller
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-lg-viewport"
value={value}
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
{isPlatform ? (
<></>
) : (
<>
<hr className="border-subtle my-6 mr-8" />
<div className="rounded-md">
<Skeleton
as="h3"
className="mb-0 inline-block text-sm font-medium"
waitForTranslation={!isPlatform}>
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton
as={Button}
href="/availability/troubleshoot"
color="secondary"
waitForTranslation={!isPlatform}>
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
</>
)}
</div>
</div>
</Form>
</div>
</Shell>
);
}

View File

@ -0,0 +1,152 @@
import type { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import type { User } from "@calcom/prisma/client";
import { transformApiScheduleForAtom } from "./transformApiScheduleForAtom";
const SCHEDULE_OWNER_ID = 256;
describe("transformScheduleForAtom", () => {
const user: Pick<User, "id" | "defaultScheduleId" | "timeZone"> = {
id: SCHEDULE_OWNER_ID,
defaultScheduleId: 139,
timeZone: "America/New_York",
};
beforeEach(() => {
jest.resetAllMocks();
});
it("should return null if user is not provided", () => {
const schedule: ScheduleOutput_2024_06_11 | null = {
id: 139,
ownerId: SCHEDULE_OWNER_ID,
name: "Default",
timeZone: "America/Cancun",
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
isDefault: true,
overrides: [],
};
expect(transformApiScheduleForAtom(undefined, schedule, 1)).toBeNull();
});
it("should return null if schedule is not provided", () => {
expect(transformApiScheduleForAtom(user, null, 1)).toBeNull();
});
it("should transform schedule correctly", () => {
const schedule: ScheduleOutput_2024_06_11 = {
id: 139,
ownerId: SCHEDULE_OWNER_ID,
name: "Default",
timeZone: "America/Cancun",
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
isDefault: true,
overrides: [],
};
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const expectedResult = {
id: 139,
name: "Default",
isManaged: false,
workingHours: [
{
days: [1, 2, 3, 4, 5],
startTime: 540,
endTime: 1020,
userId: 256,
},
],
schedule: [
{
userId: 256,
scheduleId: 139,
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(1970, 0, 1, 9, 0)),
endTime: new Date(Date.UTC(1970, 0, 1, 17, 0)),
date: null,
},
],
availability: [
[],
[
{
start: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T09:00:00.000Z`
),
end: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T17:00:00.000Z`
),
},
],
[
{
start: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T09:00:00.000Z`
),
end: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T17:00:00.000Z`
),
},
],
[
{
start: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T09:00:00.000Z`
),
end: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T17:00:00.000Z`
),
},
],
[
{
start: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T09:00:00.000Z`
),
end: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T17:00:00.000Z`
),
},
],
[
{
start: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T09:00:00.000Z`
),
end: new Date(
`${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}T17:00:00.000Z`
),
},
],
[],
],
timeZone: "America/Cancun",
dateOverrides: [],
isDefault: true,
isLastSchedule: true,
readOnly: false,
};
const result = transformApiScheduleForAtom(user, schedule, 1);
expect(result).toEqual(expectedResult);
});
});

View File

@ -0,0 +1,59 @@
import {
transformAvailabilityForAtom,
transformDateOverridesForAtom,
transformApiScheduleAvailability,
transformApiScheduleOverrides,
transformWorkingHoursForAtom,
} from "@calcom/lib/schedules/transformers";
import type { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import type { User } from "@calcom/prisma/client";
export function transformApiScheduleForAtom(
user: Pick<User, "id" | "defaultScheduleId" | "timeZone"> | undefined,
schedule: ScheduleOutput_2024_06_11 | null | undefined,
userSchedulesCount: number
) {
if (!user || !schedule) {
return null;
}
const transformedSchedule = {
...schedule,
availability: transformApiScheduleAvailability(schedule.availability),
overrides: transformApiScheduleOverrides(schedule.overrides),
};
const combined = [...transformedSchedule.availability, ...transformedSchedule.overrides];
const availability = combined.map((entry) => {
return {
...entry,
userId: schedule.ownerId,
scheduleId: schedule.id,
days: "days" in entry ? entry.days : [],
date: "date" in entry ? entry.date : null,
};
});
const atomSchedule = {
...schedule,
availability,
userId: schedule.ownerId,
};
const timeZone = schedule.timeZone || user.timeZone;
const defaultScheduleId = user.defaultScheduleId;
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.ownerId !== user.id,
workingHours: transformWorkingHoursForAtom(atomSchedule),
schedule: availability,
availability: transformAvailabilityForAtom(atomSchedule),
timeZone,
dateOverrides: transformDateOverridesForAtom(atomSchedule, timeZone),
isDefault: defaultScheduleId === schedule.id,
isLastSchedule: userSchedulesCount <= 1,
readOnly: schedule.ownerId !== user.id,
};
}

View File

@ -0,0 +1,72 @@
import type { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types";
import type { AvailabilityFormValues } from "../types";
import { transformAtomScheduleForApi } from "./transformAtomScheduleForApi";
describe("transformAtomScheduleForApi", () => {
it("should transform atom schedule correctly to API format", () => {
const input: AvailabilityFormValues = {
name: "Default",
schedule: [
[],
[
{
start: new Date("2024-05-14T09:00:00.000Z"),
end: new Date("2024-05-14T17:00:00.000Z"),
},
],
[
{
start: new Date("2024-05-14T09:00:00.000Z"),
end: new Date("2024-05-14T17:00:00.000Z"),
},
],
[
{
start: new Date("2024-05-14T09:00:00.000Z"),
end: new Date("2024-05-14T17:00:00.000Z"),
},
],
[
{
start: new Date("2024-05-14T09:00:00.000Z"),
end: new Date("2024-05-14T17:00:00.000Z"),
},
],
[
{
start: new Date("2024-05-14T11:00:00.000Z"),
end: new Date("2024-05-14T12:00:00.000Z"),
},
],
[],
],
dateOverrides: [],
timeZone: "America/Cancun",
isDefault: true,
};
const expectedOutput: UpdateScheduleInput_2024_06_11 = {
name: "Default",
timeZone: "America/Cancun",
isDefault: true,
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday"],
startTime: "09:00",
endTime: "17:00",
},
{
days: ["Friday"],
startTime: "11:00",
endTime: "12:00",
},
],
overrides: [],
};
const result = transformAtomScheduleForApi(input);
expect(result).toEqual(expectedOutput);
});
});

View File

@ -0,0 +1,88 @@
import type {
ScheduleAvailabilityInput_2024_06_11,
UpdateScheduleInput_2024_06_11,
WeekDay,
} from "@calcom/platform-types";
import type { AvailabilityFormValues } from "../types";
export function transformAtomScheduleForApi(body: AvailabilityFormValues): UpdateScheduleInput_2024_06_11 {
const { name, schedule, dateOverrides, timeZone, isDefault } = body;
const overrides =
dateOverrides.flatMap(
(dateOverridesRanges) =>
dateOverridesRanges?.ranges?.map((range) => transfromAtomOverrideForApi(range)) ?? []
) ?? [];
const availability = formatScheduleTime(schedule);
return { name, timeZone, isDefault, availability, overrides };
}
type AtomDateOverride = {
start: Date;
end: Date;
};
function transfromAtomOverrideForApi(override: AtomDateOverride) {
const date = `${override.start.getUTCFullYear()}-${(override.start.getUTCMonth() + 1)
.toString()
.padStart(2, "0")}-${override.start.getUTCDate().toString().padStart(2, "0")}`;
return {
date,
startTime: padHoursMinutesWithZeros(`${override.start.getUTCHours()}:${override.start.getUTCMinutes()}`),
endTime: padHoursMinutesWithZeros(`${override.end.getUTCHours()}:${override.end.getUTCMinutes()}`),
};
}
function padHoursMinutesWithZeros(hhMM: string) {
const [hours, minutes] = hhMM.split(":");
const formattedHours = hours.padStart(2, "0");
const formattedMinutes = minutes.padStart(2, "0");
return `${formattedHours}:${formattedMinutes}`;
}
function formatScheduleTime(
weekSchedule: AvailabilityFormValues["schedule"]
): UpdateScheduleInput_2024_06_11["availability"] {
const daysOfWeek: WeekDay[] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const formattedSchedule = weekSchedule.map((daySchedule, index) =>
daySchedule.map((event) => ({
startTime: convertToHHMM(event.start.toISOString()),
endTime: convertToHHMM(event.end.toISOString()),
days: [daysOfWeek[index]],
}))
);
const timeMap: { [key: string]: ScheduleAvailabilityInput_2024_06_11 } = {};
formattedSchedule.flat().forEach((event) => {
const timeKey = `${event.startTime}-${event.endTime}`;
if (!timeMap[timeKey]) {
timeMap[timeKey] = { startTime: event.startTime, endTime: event.endTime, days: [] };
}
timeMap[timeKey].days.push(...event.days);
});
return Object.values(timeMap);
}
function convertToHHMM(isoDate: string): string {
const date = new Date(isoDate);
const hours = date.getUTCHours().toString().padStart(2, "0");
const minutes = date.getUTCMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}

View File

@ -0,0 +1,4 @@
export { AvailabilitySettingsPlatformWrapper } from "./wrappers/AvailabilitySettingsPlatformWrapper";
export { AvailabilitySettings } from "./AvailabilitySettings";
export * from "../types";

View File

@ -0,0 +1,22 @@
import type { Schedule as ScheduleType, TimeRange } from "@calcom/types/schedule";
export type Availability = {
id: number;
userId: number | null;
eventTypeId: number | null;
days: number[];
startTime: string;
endTime: Date;
date: Date | null;
scheduleId: number | null;
};
export type WeekdayFormat = "short" | "long";
export type AvailabilityFormValues = {
name: string;
schedule: ScheduleType;
dateOverrides: { ranges: TimeRange[] }[];
timeZone: string;
isDefault: boolean;
};

View File

@ -0,0 +1,132 @@
import type { ScheduleLabelsType } from "@calcom/features/schedules/components/Schedule";
import type { ApiErrorResponse, ApiResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import useDeleteSchedule from "../../hooks/schedules/useDeleteSchedule";
import { useSchedule } from "../../hooks/schedules/useSchedule";
import { useSchedules } from "../../hooks/schedules/useSchedules";
import useUpdateSchedule from "../../hooks/schedules/useUpdateSchedule";
import { useMe } from "../../hooks/useMe";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import type { Availability } from "../AvailabilitySettings";
import type { CustomClassNames } from "../AvailabilitySettings";
import { AvailabilitySettings } from "../AvailabilitySettings";
import { transformApiScheduleForAtom } from "../atom-api-transformers/transformApiScheduleForAtom";
import { transformAtomScheduleForApi } from "../atom-api-transformers/transformAtomScheduleForApi";
import type { AvailabilityFormValues } from "../types";
type AvailabilitySettingsPlatformWrapperProps = {
id?: string;
labels?: {
tooltips: Partial<ScheduleLabelsType>;
};
customClassNames?: Partial<CustomClassNames>;
onUpdateSuccess?: (res: ApiResponse<ScheduleOutput_2024_06_11>) => void;
onUpdateError?: (err: ApiErrorResponse) => void;
onDeleteSuccess?: (res: ApiResponse) => void;
onDeleteError?: (err: ApiErrorResponse) => void;
disableEditableHeading?: boolean;
enableOverrides?: boolean;
};
export const AvailabilitySettingsPlatformWrapper = ({
id,
customClassNames,
onDeleteError,
onDeleteSuccess,
onUpdateError,
onUpdateSuccess,
disableEditableHeading = false,
enableOverrides = false,
}: AvailabilitySettingsPlatformWrapperProps) => {
const { isLoading, data: schedule } = useSchedule(id);
const { data: schedules } = useSchedules();
const { data: me } = useMe();
const atomSchedule = transformApiScheduleForAtom(me?.data, schedule, schedules?.length || 0);
const { timeFormat } = me?.data || { timeFormat: null };
const { toast } = useToast();
const { mutate: deleteSchedule, isPending: isDeletionInProgress } = useDeleteSchedule({
onSuccess: (res) => {
onDeleteSuccess?.(res);
toast({
description: "Schedule deleted successfully",
});
},
onError: (err) => {
onDeleteError?.(err);
toast({
description: "Could not delete schedule",
});
},
});
const { mutate: updateSchedule, isPending: isSavingInProgress } = useUpdateSchedule({
onSuccess: (res) => {
onUpdateSuccess?.(res);
toast({
description: "Schedule updated successfully",
});
},
onError: (err) => {
onUpdateError?.(err);
toast({
description: "Could not update schedule",
});
},
});
const handleDelete = async (id: number) => {
await deleteSchedule({ id });
};
const handleUpdate = async (id: number, body: AvailabilityFormValues) => {
const updateBody = transformAtomScheduleForApi(body);
updateSchedule({ id, ...updateBody });
};
if (isLoading) return <div className="px-10 py-4 text-xl">Loading...</div>;
if (!atomSchedule) return <div className="px-10 py-4 text-xl">No user schedule present</div>;
return (
<AtomsWrapper>
<AvailabilitySettings
disableEditableHeading={disableEditableHeading}
handleDelete={() => {
atomSchedule.id && handleDelete(atomSchedule.id);
}}
handleSubmit={async (data) => {
atomSchedule.id && handleUpdate(atomSchedule.id, data);
}}
weekStart={me?.data?.weekStart || "Sunday"}
timeFormat={timeFormat}
enableOverrides={enableOverrides}
isLoading={isLoading}
schedule={{
name: atomSchedule.name,
id: atomSchedule.id,
isLastSchedule: atomSchedule.isLastSchedule,
isDefault: atomSchedule.isDefault,
workingHours: atomSchedule.workingHours,
dateOverrides: atomSchedule.dateOverrides,
timeZone: atomSchedule.timeZone,
availability: atomSchedule.availability,
schedule:
atomSchedule.schedule.reduce(
(acc: Availability[], avail: Availability) => [
...acc,
{ days: avail.days, startTime: new Date(avail.startTime), endTime: new Date(avail.endTime) },
],
[]
) || [],
}}
isDeleting={isDeletionInProgress}
isSaving={isSavingInProgress}
backPath=""
isPlatform={true}
customClassNames={customClassNames}
/>
</AtomsWrapper>
);
};

View File

@ -0,0 +1,106 @@
import { useRouter } from "next/navigation";
import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { showToast } from "@calcom/ui";
import { AvailabilitySettings } from "../AvailabilitySettings";
export const AvailabilitySettingsWebWrapper = () => {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useUtils();
const me = useMeQuery();
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
const fromEventType = searchParams?.get("fromEventType");
const { timeFormat } = me.data || { timeFormat: null };
const { data: schedule, isPending } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
{
enabled: !!scheduleId,
}
);
const { data: travelSchedules, isPending: isPendingTravelSchedules } =
trpc.viewer.getTravelSchedules.useQuery();
const isDefaultSchedule = me.data?.defaultScheduleId === scheduleId;
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
onSuccess: async ({ prevDefaultId, currentDefaultId, ...data }) => {
if (prevDefaultId && currentDefaultId) {
// check weather the default schedule has been changed by comparing previous default schedule id and current default schedule id.
if (prevDefaultId !== currentDefaultId) {
// if not equal, invalidate previous default schedule id and refetch previous default schedule id.
utils.viewer.availability.schedule.get.invalidate({ scheduleId: prevDefaultId });
utils.viewer.availability.schedule.get.refetch({ scheduleId: prevDefaultId });
}
}
utils.viewer.availability.schedule.get.invalidate({ scheduleId: data.schedule.id });
utils.viewer.availability.list.invalidate();
showToast(
t("availability_updated_successfully", {
scheduleName: data.schedule.name,
}),
"success"
);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
onError: withErrorFromUnknown((err) => {
showToast(err.message, "error");
}),
onSettled: () => {
utils.viewer.availability.list.invalidate();
},
onSuccess: () => {
showToast(t("schedule_deleted_successfully"), "success");
router.push("/availability");
},
});
// TODO: reimplement Skeletons for this page in here
if (isPending) return null;
// We wait for the schedule to be loaded before rendering the form inside AvailabilitySettings
// since `defaultValues` cannot be redeclared after first render and using `values` will
// trigger a form reset when revalidating. Introducing flaky behavior.
if (!schedule) return null;
return (
<AvailabilitySettings
schedule={schedule}
travelSchedules={isDefaultSchedule ? travelSchedules || [] : []}
isDeleting={deleteMutation.isPending}
isLoading={isPending}
isSaving={updateMutation.isPending}
enableOverrides={true}
timeFormat={timeFormat}
weekStart={me.data?.weekStart || "Sunday"}
backPath={fromEventType ? true : "/availability"}
handleDelete={() => {
scheduleId && deleteMutation.mutate({ scheduleId });
}}
handleSubmit={async ({ dateOverrides, ...values }) => {
scheduleId &&
updateMutation.mutate({
scheduleId,
dateOverrides: dateOverrides.flatMap((override) => override.ranges),
...values,
});
}}
/>
);
};

View File

@ -0,0 +1,15 @@
import type { BookerPlatformWrapperAtomProps } from "../booker/BookerPlatformWrapper";
import { BookerPlatformWrapper } from "../booker/BookerPlatformWrapper";
import { CalProvider } from "../cal-provider/CalProvider";
export const BookerEmbed = (props: BookerPlatformWrapperAtomProps) => {
return (
<CalProvider
clientId={import.meta.env.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID}
options={{
apiUrl: import.meta.env.VITE_BOOKER_EMBED_API_URL,
}}>
<BookerPlatformWrapper {...props} />
</CalProvider>
);
};

View File

@ -0,0 +1 @@
export { BookerEmbed } from "./BookerEmbed";

View File

@ -0,0 +1,12 @@
import { Meta, Canvas, ArgsTable } from "@storybook/blocks";
import { Title } from "@calcom/storybook/components";
import { BookerWebWrapper as Booker } from "./BookerWebWrapper";
import * as BookerStories from "./Booker.stories";
<Meta of={BookerStories} />
<Title title="Booker" />
<ArgsTable of={Booker} />
<Canvas of={BookerStories.Default}/>

View File

@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BookerWebWrapper as Booker } from "./BookerWebWrapper";
const meta: Meta<typeof Booker> = {
component: Booker,
title: "Atoms/Booker",
};
export default meta;
type Story = StoryObj<typeof Booker>;
export const Default: Story = {
name: "Booker",
render: () => <Booker username="pro" eventSlug="" entity={{}} />,
};

View File

@ -0,0 +1,402 @@
import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useEffect } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import type { BookerProps } from "@calcom/features/bookings/Booker";
import { Booker as BookerComponent } from "@calcom/features/bookings/Booker";
import { useOverlayCalendarStore } from "@calcom/features/bookings/Booker/components/OverlayCalendar/store";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
import type { BookingResponse } from "@calcom/platform-libraries";
import type {
ApiErrorResponse,
ApiSuccessResponse,
ApiSuccessResponseWithoutData,
} from "@calcom/platform-types";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { transformApiEventTypeForAtom } from "../event-types/atom-api-transformers/transformApiEventTypeForAtom";
import { useEventType } from "../hooks/event-types/public/useEventType";
import { useAtomsContext } from "../hooks/useAtomsContext";
import { useAvailableSlots } from "../hooks/useAvailableSlots";
import { useCalendarsBusyTimes } from "../hooks/useCalendarsBusyTimes";
import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
import type { UseCreateBookingInput } from "../hooks/useCreateBooking";
import { useCreateBooking } from "../hooks/useCreateBooking";
import { useCreateInstantBooking } from "../hooks/useCreateInstantBooking";
import { useCreateRecurringBooking } from "../hooks/useCreateRecurringBooking";
import {
useGetBookingForReschedule,
QUERY_KEY as BOOKING_RESCHEDULE_KEY,
} from "../hooks/useGetBookingForReschedule";
import { useHandleBookEvent } from "../hooks/useHandleBookEvent";
import { useMe } from "../hooks/useMe";
import { useSlots } from "../hooks/useSlots";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
export type BookerPlatformWrapperAtomProps = Omit<BookerProps, "username" | "entity"> & {
rescheduleUid?: string;
bookingUid?: string;
username: string | string[];
entity?: BookerProps["entity"];
// values for the booking form and booking fields
defaultFormValues?: {
firstName?: string;
lastName?: string;
guests?: string[];
name?: string;
email?: string;
notes?: string;
rescheduleReason?: string;
} & Record<string, string | string[]>;
handleCreateBooking?: (input: UseCreateBookingInput) => void;
onCreateBookingSuccess?: (data: ApiSuccessResponse<BookingResponse>) => void;
onCreateBookingError?: (data: ApiErrorResponse | Error) => void;
onCreateRecurringBookingSuccess?: (data: ApiSuccessResponse<BookingResponse[]>) => void;
onCreateRecurringBookingError?: (data: ApiErrorResponse | Error) => void;
onCreateInstantBookingSuccess?: (data: ApiSuccessResponse<BookingResponse>) => void;
onCreateInstantBookingError?: (data: ApiErrorResponse | Error) => void;
onReserveSlotSuccess?: (data: ApiSuccessResponse<string>) => void;
onReserveSlotError?: (data: ApiErrorResponse) => void;
onDeleteSlotSuccess?: (data: ApiSuccessResponseWithoutData) => void;
onDeleteSlotError?: (data: ApiErrorResponse) => void;
locationUrl?: string;
};
export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => {
const { clientId } = useAtomsContext();
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const setSelectedDate = useBookerStore((state) => state.setSelectedDate);
const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration);
const setBookingData = useBookerStore((state) => state.setBookingData);
const setOrg = useBookerStore((state) => state.setOrg);
const bookingData = useBookerStore((state) => state.bookingData);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
const setSelectedMonth = useBookerStore((state) => state.setMonth);
useGetBookingForReschedule({
uid: props.rescheduleUid ?? props.bookingUid ?? "",
onSuccess: (data) => {
setBookingData(data);
},
});
const queryClient = useQueryClient();
const username = useMemo(() => {
return formatUsername(props.username);
}, [props.username]);
setSelectedDuration(props.duration ?? null);
setOrg(props.entity?.orgSlug ?? null);
const isDynamic = useMemo(() => {
return getUsernameList(username ?? "").length > 1;
}, [username]);
const { isSuccess, isError, isPending, data } = useEventType(username, props.eventSlug);
const event = useMemo(() => {
return {
isSuccess,
isError,
isPending,
data: data && data.length > 0 ? transformApiEventTypeForAtom(data[0], props.entity) : undefined,
};
}, [isSuccess, isError, isPending, data, props.entity]);
if (isDynamic && props.duration && event.data) {
// note(Lauris): Mandatory - In case of "dynamic" event type default event duration returned by the API is 30,
// but we are re-using the dynamic event type as a team event, so we must set the event length to whatever the event length is.
event.data.length = props.duration;
}
const bookerLayout = useBookerLayout(event.data);
useInitializeBookerStore({
...props,
eventId: event.data?.id,
rescheduleUid: props.rescheduleUid ?? null,
bookingUid: props.bookingUid ?? null,
layout: bookerLayout.defaultLayout,
org: props.entity?.orgSlug,
username,
bookingData,
});
const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
const month = useBookerStore((state) => state.month);
const eventSlug = useBookerStore((state) => state.eventSlug);
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const { data: session } = useMe();
const hasSession = !!session;
const { name: defaultName, guests: defaultGuests, ...restFormValues } = props.defaultFormValues ?? {};
const prefillFormParams = useMemo(() => {
return {
name: defaultName ?? null,
guests: defaultGuests ?? [],
};
}, [defaultName, defaultGuests]);
const extraOptions = useMemo(() => {
return restFormValues;
}, [restFormValues]);
const date = dayjs(selectedDate).format("YYYY-MM-DD");
const prefetchNextMonth =
(bookerLayout.layout === BookerLayouts.WEEK_VIEW &&
!!bookerLayout.extraDays &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.extraDays, "day").month()) ||
(bookerLayout.layout === BookerLayouts.COLUMN_VIEW &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month());
const monthCount =
((bookerLayout.layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
bookerLayout.layout === BookerLayouts.COLUMN_VIEW) &&
dayjs(date).add(1, "month").month() !==
dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month()
? 2
: undefined;
const { timezone } = useTimePreferences();
const [startTime, endTime] = useTimesForSchedule({
month,
monthCount,
dayCount,
prefetchNextMonth,
selectedDate,
});
const schedule = useAvailableSlots({
usernameList: getUsernameList(username ?? ""),
eventTypeId: event?.data?.id ?? 0,
startTime,
endTime,
timeZone: session?.data?.timeZone,
duration: selectedDuration ?? undefined,
rescheduleUid: props.rescheduleUid,
enabled:
Boolean(username) &&
Boolean(month) &&
Boolean(timezone) &&
// Should only wait for one or the other, not both.
(Boolean(eventSlug) || Boolean(event?.data?.id) || event?.data?.id === 0),
orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : undefined,
});
const bookerForm = useBookingForm({
event: event.data,
sessionEmail:
session?.data?.email && clientId
? session.data.email.replace(`+${clientId}`, "")
: session?.data?.email,
sessionUsername: session?.data?.username,
sessionName: session?.data?.username,
hasSession,
extraOptions: extraOptions ?? {},
prefillFormParams: prefillFormParams,
});
const {
mutate: createBooking,
isPending: creatingBooking,
error: createBookingError,
isError: isCreateBookingError,
} = useCreateBooking({
onSuccess: (data) => {
schedule.refetch();
props.onCreateBookingSuccess?.(data);
},
onError: props.onCreateBookingError,
});
const {
mutate: createRecBooking,
isPending: creatingRecBooking,
error: createRecBookingError,
isError: isCreateRecBookingError,
} = useCreateRecurringBooking({
onSuccess: (data) => {
schedule.refetch();
props.onCreateRecurringBookingSuccess?.(data);
},
onError: props.onCreateRecurringBookingError,
});
const {
mutate: createInstantBooking,
isPending: creatingInstantBooking,
error: createInstantBookingError,
isError: isCreateInstantBookingError,
} = useCreateInstantBooking({
onSuccess: (data) => {
schedule.refetch();
props.onCreateInstantBookingSuccess?.(data);
},
onError: props.onCreateInstantBookingError,
});
const slots = useSlots(event);
const [calendarSettingsOverlay] = useOverlayCalendarStore(
(state) => [state.calendarSettingsOverlayModal, state.setCalendarSettingsOverlayModal],
shallow
);
const { data: connectedCalendars, isPending: fetchingConnectedCalendars } = useConnectedCalendars({
enabled: !!calendarSettingsOverlay,
});
const calendars = connectedCalendars as ConnectedDestinationCalendars;
const { set, clearSet } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const { data: overlayBusyDates } = useCalendarsBusyTimes({
loggedInUsersTz: session?.data?.timeZone || "Europe/London",
dateFrom: selectedDate,
dateTo: selectedDate,
calendarsToLoad: Array.from(set).map((item) => ({
credentialId: item.credentialId,
externalId: item.externalId,
})),
onError: () => {
clearSet();
},
enabled: Boolean(
hasSession && set.size > 0 && localStorage?.getItem("overlayCalendarSwitchDefault") === "true"
),
});
const handleBookEvent = useHandleBookEvent({
event,
bookingForm: bookerForm.bookingForm,
hashedLink: props.hashedLink,
metadata: {},
handleBooking: props?.handleCreateBooking ?? createBooking,
handleInstantBooking: createInstantBooking,
handleRecBooking: createRecBooking,
locationUrl: props.locationUrl,
});
useEffect(() => {
// reset booker whenever it's unmounted
return () => {
slots.handleRemoveSlot();
setBookerState("loading");
setSelectedDate(null);
setSelectedTimeslot(null);
setSelectedDuration(null);
setOrg(null);
setSelectedMonth(null);
setSelectedDuration(null);
if (props.rescheduleUid) {
// clean booking data from cache
queryClient.removeQueries({
queryKey: [BOOKING_RESCHEDULE_KEY, props.rescheduleUid],
exact: true,
});
setBookingData(null);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<AtomsWrapper>
<BookerComponent
customClassNames={props.customClassNames}
eventSlug={props.eventSlug}
username={username}
entity={
event?.data?.entity ?? {
considerUnpublished: false,
orgSlug: undefined,
teamSlug: undefined,
name: undefined,
}
}
rescheduleUid={props.rescheduleUid ?? null}
bookingUid={props.bookingUid ?? null}
isRedirect={false}
fromUserNameRedirected=""
hasSession={hasSession}
onGoBackInstantMeeting={function (): void {
throw new Error("Function not implemented.");
}}
onConnectNowInstantMeeting={function (): void {
throw new Error("Function not implemented.");
}}
onOverlayClickNoCalendar={function (): void {
throw new Error("Function not implemented.");
}}
onClickOverlayContinue={function (): void {
throw new Error("Function not implemented.");
}}
onOverlaySwitchStateChange={function (): void {
throw new Error("Function not implemented.");
}}
extraOptions={extraOptions ?? {}}
bookings={{
handleBookEvent: () => {
handleBookEvent();
return;
},
expiryTime: undefined,
bookingForm: bookerForm.bookingForm,
bookerFormErrorRef: bookerForm.bookerFormErrorRef,
errors: {
hasDataErrors: isCreateBookingError || isCreateRecBookingError || isCreateInstantBookingError,
dataErrors: createBookingError || createRecBookingError || createInstantBookingError,
},
loadingStates: {
creatingBooking: creatingBooking,
creatingRecurringBooking: creatingRecBooking,
creatingInstantBooking: creatingInstantBooking,
},
instantVideoMeetingUrl: undefined,
}}
slots={slots}
calendars={{
overlayBusyDates: overlayBusyDates?.data,
isOverlayCalendarEnabled: false,
connectedCalendars: calendars?.connectedCalendars || [],
loadingConnectedCalendar: fetchingConnectedCalendars,
onToggleCalendar: () => {
return;
},
}}
verifyEmail={{
isEmailVerificationModalVisible: false,
setEmailVerificationModalVisible: () => {
return;
},
setVerifiedEmail: () => {
return;
},
handleVerifyEmail: () => {
return;
},
renderConfirmNotVerifyEmailButtonCond: true,
isVerificationCodeSending: false,
}}
bookerForm={bookerForm}
event={event}
schedule={schedule}
bookerLayout={bookerLayout}
verifyCode={undefined}
isPlatform
/>
</AtomsWrapper>
);
};
function formatUsername(username: string | string[]): string {
if (typeof username === "string") {
return username;
}
return username.join("+");
}

View File

@ -0,0 +1,212 @@
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useCallback, useEffect } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import type { BookerProps } from "@calcom/features/bookings/Booker";
import { Booker as BookerComponent } from "@calcom/features/bookings/Booker";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useBookings } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import { useCalendars } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useSlots";
import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
type BookerWebWrapperAtomProps = BookerProps;
export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const event = useEvent();
const bookerLayout = useBookerLayout(event.data);
const selectedDate = searchParams?.get("date");
const isRedirect = searchParams?.get("redirected") === "true" || false;
const fromUserNameRedirected = searchParams?.get("username") || "";
const rescheduleUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
const bookingUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null;
const date = dayjs(selectedDate).format("YYYY-MM-DD");
useEffect(() => {
// This event isn't processed by BookingPageTagManager because BookingPageTagManager hasn't loaded when it is fired. I think we should have a queue in fire method to handle this.
sdkActionManager?.fire("navigatedToBooker", {});
}, []);
useInitializeBookerStore({
...props,
eventId: event?.data?.id,
rescheduleUid,
bookingUid: bookingUid,
layout: bookerLayout.defaultLayout,
org: props.entity.orgSlug,
});
const [bookerState, _] = useBookerStore((state) => [state.state, state.setState], shallow);
const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow);
const { data: session } = useSession();
const routerQuery = useRouterQuery();
const hasSession = !!session;
const firstNameQueryParam = searchParams?.get("firstName");
const lastNameQueryParam = searchParams?.get("lastName");
const metadata = Object.keys(routerQuery)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: searchParams?.get(key),
}),
{}
);
const prefillFormParams = useMemo(() => {
return {
name:
searchParams?.get("name") ||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
};
}, [searchParams, firstNameQueryParam, lastNameQueryParam]);
const bookerForm = useBookingForm({
event: event.data,
sessionEmail: session?.user.email,
sessionUsername: session?.user.username,
sessionName: session?.user.name,
hasSession,
extraOptions: routerQuery,
prefillFormParams,
});
const calendars = useCalendars({ hasSession });
const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail,
name: bookerForm.formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
});
const slots = useSlots(event);
const prefetchNextMonth =
(bookerLayout.layout === BookerLayouts.WEEK_VIEW &&
!!bookerLayout.extraDays &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.extraDays, "day").month()) ||
(bookerLayout.layout === BookerLayouts.COLUMN_VIEW &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month());
const monthCount =
((bookerLayout.layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
bookerLayout.layout === BookerLayouts.COLUMN_VIEW) &&
dayjs(date).add(1, "month").month() !==
dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month()
? 2
: undefined;
/**
* Prioritize dateSchedule load
* Component will render but use data already fetched from here, and no duplicate requests will be made
* */
const debouncedFormEmail = useDebounce(bookerForm.formEmail, 600);
const schedule = useScheduleForEvent({
prefetchNextMonth,
username: props.username,
monthCount,
dayCount,
eventSlug: props.eventSlug,
month: props.month,
duration: props.duration,
selectedDate,
bookerEmail: debouncedFormEmail,
});
const bookings = useBookings({
event,
hashedLink: props.hashedLink,
bookingForm: bookerForm.bookingForm,
metadata: metadata ?? {},
teamMemberEmail: schedule.data?.teamMember,
});
const verifyCode = useVerifyCode({
onSuccess: () => {
verifyEmail.setVerifiedEmail(bookerForm.formEmail);
verifyEmail.setEmailVerificationModalVisible(false);
bookings.handleBookEvent();
},
});
// Toggle query param for overlay calendar
const onOverlaySwitchStateChange = useCallback(
(state: boolean) => {
const current = new URLSearchParams(Array.from(searchParams?.entries() ?? []));
if (state) {
current.set("overlayCalendar", "true");
localStorage.setItem("overlayCalendarSwitchDefault", "true");
} else {
current.delete("overlayCalendar");
localStorage.removeItem("overlayCalendarSwitchDefault");
}
// cast to string
const value = current.toString();
const query = value ? `?${value}` : "";
router.push(`${pathname}${query}`);
},
[searchParams, pathname, router]
);
useBrandColors({
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
theme: event.data?.profile.theme,
});
return (
<BookerComponent
{...props}
onGoBackInstantMeeting={() => {
if (pathname) window.location.href = pathname;
}}
onConnectNowInstantMeeting={() => {
const newPath = `${pathname}?isInstantMeeting=true`;
router.push(newPath);
}}
onOverlayClickNoCalendar={() => {
router.push("/apps/categories/calendar");
}}
onClickOverlayContinue={() => {
const currentUrl = new URL(window.location.href);
currentUrl.pathname = "/login/";
currentUrl.searchParams.set("callbackUrl", window.location.pathname);
currentUrl.searchParams.set("overlayCalendar", "true");
router.push(currentUrl.toString());
}}
onOverlaySwitchStateChange={onOverlaySwitchStateChange}
sessionUsername={session?.user.username}
isRedirect={isRedirect}
fromUserNameRedirected={fromUserNameRedirected}
rescheduleUid={rescheduleUid}
bookingUid={bookingUid}
hasSession={hasSession}
extraOptions={routerQuery}
bookings={bookings}
calendars={calendars}
slots={slots}
verifyEmail={verifyEmail}
bookerForm={bookerForm}
event={event}
bookerLayout={bookerLayout}
schedule={schedule}
verifyCode={verifyCode}
isPlatform={false}
/>
);
};

View File

@ -0,0 +1,5 @@
/** Export file is only used for building the dist version of this Atom. */
// import "../globals.css";
export { BookerWebWrapper as Booker } from "./BookerWebWrapper";
export * from "../types";

View File

@ -0,0 +1 @@
export { BookerWebWrapper } from "./BookerWebWrapper";

View File

@ -0,0 +1,172 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { useState, useCallback } from "react";
// Deutsch und Englisch Übersetzungen importieren
import deTranslations from "@calcom/web/public/static/locales/de/common.json";
import enTranslations from "@calcom/web/public/static/locales/en/common.json";
// import esTranslations from "@calcom/web/public/static/locales/es/common.json";
// import frTranslations from "@calcom/web/public/static/locales/fr/common.json";
// import ptBrTranslations from "@calcom/web/public/static/locales/pt-BR/common.json";
import { AtomsContext } from "../hooks/useAtomsContext";
import { useOAuthClient } from "../hooks/useOAuthClient";
import { useOAuthFlow } from "../hooks/useOAuthFlow";
import { useTimezone } from "../hooks/useTimezone";
import { useUpdateUserTimezone } from "../hooks/useUpdateUserTimezone";
import http from "../lib/http";
import { Toaster } from "../src/components/ui/toaster";
import { EN, DE } from "./CalProvider"; // EN und DE importieren
import type {
CalProviderProps,
CalProviderLanguagesType,
translationKeys,
deTranslationKeys,
enTranslationKeys,
// esTranslationKeys,
// frTranslationKeys,
// ptBrTranslationKeys,
} from "./CalProvider";
export function BaseCalProvider({
clientId,
accessToken,
options,
children,
labels,
autoUpdateTimezone,
language = DE, // Standardsprache auf Deutsch gesetzt
onTimezoneChange,
}: CalProviderProps) {
const [error, setError] = useState<string>("");
const { mutateAsync } = useUpdateUserTimezone();
const handleTimezoneChange = useCallback(
async (currentTimezone: string) => {
await mutateAsync({ timeZone: currentTimezone });
},
[mutateAsync]
);
const getTimezoneChangeHandler = useCallback(() => {
if (onTimezoneChange) return onTimezoneChange;
if (!onTimezoneChange && autoUpdateTimezone) return handleTimezoneChange;
return undefined;
}, [onTimezoneChange, autoUpdateTimezone, handleTimezoneChange]);
useTimezone(getTimezoneChangeHandler());
const { isInit } = useOAuthClient({
clientId,
apiUrl: options.apiUrl,
refreshUrl: options.refreshUrl,
onError: setError,
onSuccess: () => {
setError("");
},
});
const { isRefreshing, currentAccessToken } = useOAuthFlow({
accessToken,
refreshUrl: options.refreshUrl,
onError: setError,
onSuccess: () => {
setError("");
},
clientId,
});
const translations = {
t: (key: string, values: Record<string, string | number | null | undefined>) => {
let translation = labels?.[key as keyof typeof labels] ?? String(getTranslation(key, language) ?? "");
if (!translation) {
return "";
}
if (values) {
const valueKeys = Object.keys(values) as (keyof typeof values)[];
if (valueKeys.length) {
valueKeys.forEach((valueKey) => {
if (translation)
translation = translation.replace(
`{{${String(valueKey)}}}`,
values[valueKey]?.toString() ?? `{{${String(valueKey)}}}`
);
});
}
}
return replaceOccurrences(translation, language === DE ? deTranslations : enTranslations) ?? "";
},
i18n: {
language: language,
defaultLocale: language,
locales: [language],
exists: (key: translationKeys | string) => Boolean(getTranslation(key, language)),
},
};
return isInit ? (
<AtomsContext.Provider
value={{
clientId,
accessToken: currentAccessToken,
options,
error,
getClient: () => http,
isRefreshing: isRefreshing,
isInit: isInit,
isValidClient: Boolean(!error && clientId && isInit),
isAuth: Boolean(isInit && !error && clientId && currentAccessToken && http.getAuthorizationHeader()),
...translations,
}}>
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</AtomsContext.Provider>
) : (
<AtomsContext.Provider
value={{
clientId,
options,
error,
getClient: () => http,
isAuth: false,
isValidClient: Boolean(!error && clientId),
isInit: false,
isRefreshing: false,
...translations,
}}>
<>
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</>
</AtomsContext.Provider>
);
}
function replaceOccurrences(input: string, replacementMap: { [key: string]: string }): string {
const pattern = /\$t\((.*?)\)/g;
return input.replace(pattern, (match, key) => {
if (key in replacementMap) {
return replacementMap[key];
}
// Wenn der Schlüssel nicht gefunden wird, den ursprünglichen Wert zurückgeben
return match;
});
}
function getTranslation(key: string, language: CalProviderLanguagesType) {
switch (language) {
case "de":
return deTranslations[key as deTranslationKeys];
case "en":
return enTranslations[key as enTranslationKeys];
// case "es":
// return esTranslations[key as esTranslationKeys];
// case "fr":
// return frTranslations[key as frTranslationKeys];
// case "pt-BR":
// return ptBrTranslations[key as ptBrTranslationKeys];
default:
return deTranslations[key as deTranslationKeys]; // Fallback auf Deutsch
}
}

View File

@ -0,0 +1,124 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, type ReactNode } from "react";
import type { API_VERSIONS_ENUM } from "@calcom/platform-constants";
import { VERSION_2024_06_14 } from "@calcom/platform-constants";
import type deTranslations from "@calcom/web/public/static/locales/de/common.json";
import type enTranslations from "@calcom/web/public/static/locales/en/common.json";
// import type esTranslations from "@calcom/web/public/static/locales/es/common.json";
// import type frTranslations from "@calcom/web/public/static/locales/fr/common.json";
// import type ptBrTranslations from "@calcom/web/public/static/locales/pt-BR/common.json";
import http from "../lib/http";
import { BaseCalProvider } from "./BaseCalProvider";
export type enTranslationKeys = keyof typeof enTranslations;
// export type frTranslationKeys = keyof typeof frTranslations;
export type deTranslationKeys = keyof typeof deTranslations;
// export type esTranslationKeys = keyof typeof esTranslations;
// export type ptBrTranslationKeys = keyof typeof ptBrTranslations;
export type translationKeys =
| enTranslationKeys
// | frTranslationKeys
| deTranslationKeys
// | esTranslationKeys
// | ptBrTranslationKeys;
const FR = "fr";
export const EN = "en";
const PT_BR = "pt-BR";
export const DE = "de";
const ES = "es";
export const CAL_PROVIDER_LANGUAUES = [EN, DE] as const; // Nur Deutsch und Englisch als verfügbare Sprachen
export type CalProviderLanguagesType = (typeof CAL_PROVIDER_LANGUAUES)[number];
const queryClient = new QueryClient();
// type i18nFrProps = {
// labels?: Partial<Record<frTranslationKeys, string>>;
// language?: "fr";
// };
type i18nEnProps = {
labels?: Partial<Record<enTranslationKeys, string>>;
language?: "en";
};
// type i18nPtBrProps = {
// labels?: Partial<Record<ptBrTranslationKeys, string>>;
// language?: "pt-BR";
// };
type i18nDeProps = {
labels?: Partial<Record<deTranslationKeys, string>>;
language?: "de";
};
// type i18nEsProps = {
// labels?: Partial<Record<esTranslationKeys, string>>;
// language?: "es";
// };
export type i18nProps = i18nEnProps | i18nDeProps; // Nur Deutsch und Englisch
export type CalProviderProps = {
children?: ReactNode;
clientId: string;
accessToken?: string;
options: { refreshUrl?: string; apiUrl: string };
autoUpdateTimezone?: boolean;
onTimezoneChange?: () => void;
version?: API_VERSIONS_ENUM;
} & i18nProps;
/**
* Renders a CalProvider component.
*
* @component
* @param {string} props.clientId - The platform oauth client ID.
* @param {string} props.accessToken - The access token of your managed user. - Optional
* @param {object} props.options - The options object.
* @param {string} [options.apiUrl] - The API URL. https://api.cal.com/v2
* @param {string} [options.refreshUrl] - The url point to your refresh endpoint. - Optional, required if accessToken is provided.
* @param {boolean} [autoUpdateTimezone=true] - Whether to automatically update the timezone. - Optional
* @param {function} props.onTimezoneChange - The callback function for timezone change. - Optional
* @param {ReactNode} props.children - The child components. - Optional
* @returns {JSX.Element} The rendered CalProvider component.
*/
export function CalProvider({
clientId,
accessToken,
options,
children,
autoUpdateTimezone = true,
labels,
language = DE, // Standardsprache auf Deutsch gesetzt
onTimezoneChange,
version = VERSION_2024_06_14,
}: CalProviderProps) {
useEffect(() => {
http.setVersionHeader(version);
}, [version]);
useEffect(() => {
if (accessToken) {
queryClient.resetQueries();
}
}, [accessToken]);
return (
<QueryClientProvider client={queryClient}>
<BaseCalProvider
autoUpdateTimezone={autoUpdateTimezone}
onTimezoneChange={onTimezoneChange}
clientId={clientId}
accessToken={accessToken}
options={options}
version={version}
labels={labels as Record<translationKeys, string>}
language={language}>
{children}
</BaseCalProvider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1 @@
export { CalProvider } from "./CalProvider";

View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.cjs",
"css": "globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@ -0,0 +1,73 @@
import type { FC } from "react";
import type { CALENDARS } from "@calcom/platform-constants";
import { Button } from "@calcom/ui";
import type { OnCheckErrorType, UseCheckProps } from "../hooks/connect/useCheck";
import { useCheck } from "../hooks/connect/useCheck";
import { useConnect } from "../hooks/connect/useConnect";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
import { cn } from "../src/lib/utils";
export type OAuthConnectProps = {
className?: string;
label: string;
alreadyConnectedLabel: string;
loadingLabel: string;
onCheckError?: OnCheckErrorType;
redir?: string;
initialData: UseCheckProps["initialData"];
};
export const OAuthConnect: FC<
OAuthConnectProps & {
calendar: (typeof CALENDARS)[number];
}
> = ({
label,
alreadyConnectedLabel,
loadingLabel,
className,
onCheckError,
calendar,
redir,
initialData,
}) => {
const { connect } = useConnect(calendar, redir);
const { allowConnect, checked } = useCheck({
onCheckError,
calendar: calendar,
initialData,
});
const isChecking = !checked;
const isDisabled = isChecking || !allowConnect;
let displayedLabel = label;
if (isChecking) {
displayedLabel = loadingLabel;
} else if (!allowConnect) {
displayedLabel = alreadyConnectedLabel;
}
return (
<AtomsWrapper>
<Button
StartIcon="calendar-days"
color="primary"
disabled={isDisabled}
className={cn(
"",
className,
isChecking && "animate-pulse",
isDisabled && "cursor-not-allowed",
!isDisabled && "cursor-pointer"
)}
onClick={() => connect()}>
{displayedLabel}
</Button>
</AtomsWrapper>
);
};

View File

@ -0,0 +1,143 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { FC } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button, Form, PasswordField, TextField } from "@calcom/ui";
import { SUCCESS_STATUS } from "../../../constants/api";
import { useCheck } from "../../hooks/connect/useCheck";
import { useSaveCalendarCredentials } from "../../hooks/connect/useConnect";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import { cn } from "../../src/lib/utils";
import type { OAuthConnectProps } from "../OAuthConnect";
export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
label = "Connect Apple Calendar",
alreadyConnectedLabel = "Connected Apple Calendar",
loadingLabel = "Checking Apple Calendar",
className,
initialData,
}) => {
const form = useForm({
defaultValues: {
username: "",
password: "",
},
});
const { toast } = useToast();
const { allowConnect, checked, refetch } = useCheck({
calendar: "apple",
initialData,
});
const [isDialogOpen, setIsDialogOpen] = useState(false);
let displayedLabel = label;
const { mutate: saveCredentials, isPending: isSaving } = useSaveCalendarCredentials({
onSuccess: (res) => {
if (res.status === SUCCESS_STATUS) {
form.reset();
setIsDialogOpen(false);
refetch();
toast({
description: "Calendar credentials added successfully",
});
}
},
onError: (err) => {
toast({
description: `Error: ${err}`,
});
},
});
const isChecking = !checked;
const isDisabled = isChecking || !allowConnect;
if (isChecking) {
displayedLabel = loadingLabel;
} else if (!allowConnect) {
displayedLabel = alreadyConnectedLabel;
}
return (
<AtomsWrapper>
<Dialog open={isDialogOpen}>
<DialogTrigger>
<Button
StartIcon="calendar-days"
color="primary"
disabled={isDisabled}
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}
onClick={() => setIsDialogOpen(true)}>
{displayedLabel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect to Apple Server</DialogTitle>
<DialogDescription>
Generate an app specific password to use with Cal.com at{" "}
<span className="font-bold">https://appleid.apple.com/account/manage</span>. Your credentials
will be stored and encrypted.
</DialogDescription>
</DialogHeader>
<Form
form={form}
handleSubmit={async (values) => {
const { username, password } = values;
await saveCredentials({ calendar: "apple", username, password });
}}>
<fieldset
className="space-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
required
type="text"
{...form.register("username")}
label="Apple ID"
placeholder="appleid@domain.com"
data-testid="apple-calendar-email"
/>
<PasswordField
required
{...form.register("password")}
label="Password"
placeholder="•••••••••••••"
autoComplete="password"
data-testid="apple-calendar-password"
/>
</fieldset>
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button
disabled={isSaving}
type="button"
color="secondary"
onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
disabled={isSaving}
type="submit"
loading={form.formState.isSubmitting}
data-testid="apple-calendar-login-button">
Save
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
</AtomsWrapper>
);
};

View File

@ -0,0 +1,29 @@
import type { FC } from "react";
import { GOOGLE_CALENDAR } from "@calcom/platform-constants";
import type { OAuthConnectProps } from "../OAuthConnect";
import { OAuthConnect } from "../OAuthConnect";
export const GcalConnect: FC<Partial<OAuthConnectProps>> = ({
label = "Connect Google Calendar",
alreadyConnectedLabel = "Connected Google Calendar",
loadingLabel = "Checking Google Calendar",
className,
onCheckError,
redir,
initialData,
}) => {
return (
<OAuthConnect
label={label}
alreadyConnectedLabel={alreadyConnectedLabel}
loadingLabel={loadingLabel}
calendar={GOOGLE_CALENDAR}
className={className}
onCheckError={onCheckError}
redir={redir}
initialData={initialData}
/>
);
};

View File

@ -0,0 +1,3 @@
export { GcalConnect as GoogleCalendar } from "./google/GcalConnect";
export { OutlookConnect as OutlookCalendar } from "./outlook/OutlookConnect";
export { AppleConnect as AppleCalendar } from "./apple/AppleConnect";

View File

@ -0,0 +1,29 @@
import type { FC } from "react";
import { OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import type { OAuthConnectProps } from "../OAuthConnect";
import { OAuthConnect } from "../OAuthConnect";
export const OutlookConnect: FC<Partial<OAuthConnectProps>> = ({
label = "Connect Outlook Calendar",
alreadyConnectedLabel = "Connected Outlook Calendar",
loadingLabel = "Checking Outlook Calendar",
className,
onCheckError,
redir,
initialData,
}) => {
return (
<OAuthConnect
label={label}
alreadyConnectedLabel={alreadyConnectedLabel}
loadingLabel={loadingLabel}
calendar={OFFICE_365_CALENDAR}
className={className}
onCheckError={onCheckError}
redir={redir}
initialData={initialData}
/>
);
};

View File

@ -0,0 +1,257 @@
import type { BookerProps } from "@calcom/features/bookings/Booker";
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
import { defaultEvents } from "@calcom/lib/defaultEvents";
import type { CommonField, OptionsField, SystemField } from "@calcom/lib/event-types/transformers";
import {
transformApiEventTypeLocations,
transformApiEventTypeBookingFields,
} from "@calcom/lib/event-types/transformers";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import type { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import {
bookerLayoutOptions,
BookerLayouts,
bookerLayouts as bookerLayoutsSchema,
userMetadata as userMetadataSchema,
eventTypeBookingFields,
} from "@calcom/prisma/zod-utils";
export function transformApiEventTypeForAtom(
eventType: Omit<EventTypeOutput_2024_06_14, "ownerId">,
entity: BookerProps["entity"] | undefined
) {
const { lengthInMinutes, locations, bookingFields, users, ...rest } = eventType;
const isDefault = isDefaultEvent(rest.title);
const user = users[0];
const defaultEventBookerLayouts = {
enabledLayouts: [...bookerLayoutOptions],
defaultLayout: BookerLayouts.MONTH_VIEW,
};
const firstUsersMetadata = userMetadataSchema.parse(user.metadata || {});
const bookerLayouts = bookerLayoutsSchema.parse(
firstUsersMetadata?.defaultBookerLayouts || defaultEventBookerLayouts
);
return {
...rest,
length: lengthInMinutes,
locations: getLocations(locations),
bookingFields: getBookingFields(bookingFields),
isDefault,
isDynamic: false,
profile: {
username: user.username,
name: user.name,
weekStart: user.weekStart,
image: "",
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
theme: null,
bookerLayouts,
},
entity: entity
? {
...entity,
orgSlug: entity.orgSlug || null,
teamSlug: entity.teamSlug || null,
fromRedirectOfNonOrgLink: true,
name: entity.name || null,
logoUrl: entity.logoUrl || undefined,
}
: {
fromRedirectOfNonOrgLink: true,
considerUnpublished: false,
orgSlug: null,
teamSlug: null,
name: null,
logoUrl: undefined,
},
hosts: [],
users: users.map((user) => ({
...user,
metadata: undefined,
bookerUrl: getBookerBaseUrlSync(null),
profile: {
username: user.username || "",
name: user.name,
weekStart: user.weekStart,
image: "",
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
theme: null,
organization: null,
id: user.id,
organizationId: null,
userId: user.id,
upId: `usr-${user.id}`,
},
})),
};
}
function isDefaultEvent(eventSlug: string) {
const foundInDefaults = defaultEvents.find((obj) => {
return obj.slug === eventSlug;
});
return !!foundInDefaults;
}
function getLocations(locations: EventTypeOutput_2024_06_14["locations"]) {
const transformed = transformApiEventTypeLocations(locations);
const withPrivateHidden = transformed.map((location) => {
const { displayLocationPublicly, type } = location;
switch (type) {
case "address":
return displayLocationPublicly ? location : { ...location, address: "" };
case "link":
return displayLocationPublicly ? location : { ...location, link: "" };
case "phone":
return displayLocationPublicly
? location
: {
...location,
hostPhoneNumber: "",
};
default:
return location;
}
});
return withPrivateHidden;
}
function getBookingFields(bookingFields: EventTypeOutput_2024_06_14["bookingFields"]) {
const transformedBookingFields: (CommonField | SystemField | OptionsField)[] =
transformApiEventTypeBookingFields(bookingFields);
// These fields should be added before other user fields
const systemBeforeFields: SystemField[] = [
{
type: "name",
// This is the `name` of the main field
name: "name",
editable: "system",
// This Label is used in Email only as of now.
defaultLabel: "your_name",
required: true,
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
},
{
defaultLabel: "email_address",
type: "email",
name: "email",
required: true,
editable: "system",
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
},
{
defaultLabel: "location",
type: "radioInput",
name: "location",
editable: "system",
hideWhenJustOneOption: true,
required: false,
getOptionsAt: "locations",
optionsInputs: {
attendeeInPerson: {
type: "address",
required: true,
placeholder: "",
},
phone: {
type: "phone",
required: true,
placeholder: "",
},
},
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
},
];
// These fields should be added after other user fields
const systemAfterFields: SystemField[] = [
{
defaultLabel: "reason_for_reschedule",
type: "textarea",
editable: "system-but-optional",
name: "rescheduleReason",
defaultPlaceholder: "reschedule_placeholder",
required: false,
views: [
{
id: "reschedule",
label: "Reschedule View",
},
],
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
},
];
const missingSystemBeforeFields: SystemField[] = [];
for (const field of systemBeforeFields) {
const existingBookingFieldIndex = transformedBookingFields.findIndex(
(f) => getFieldIdentifier(f.name) === getFieldIdentifier(field.name)
);
// Only do a push, we must not update existing system fields as user could have modified any property in it,
if (existingBookingFieldIndex === -1) {
missingSystemBeforeFields.push(field);
} else {
// Adding the fields from Code first and then fields from DB. Allows, the code to push new properties to the field
transformedBookingFields[existingBookingFieldIndex] = {
...field,
...transformedBookingFields[existingBookingFieldIndex],
};
}
}
transformedBookingFields.push(...missingSystemBeforeFields);
const missingSystemAfterFields: SystemField[] = [];
for (const field of systemAfterFields) {
const existingBookingFieldIndex = transformedBookingFields.findIndex(
(f) => getFieldIdentifier(f.name) === getFieldIdentifier(field.name)
);
// Only do a push, we must not update existing system fields as user could have modified any property in it,
if (existingBookingFieldIndex === -1) {
missingSystemAfterFields.push(field);
} else {
transformedBookingFields[existingBookingFieldIndex] = {
// Adding the fields from Code first and then fields from DB. Allows, the code to push new properties to the field
...field,
...transformedBookingFields[existingBookingFieldIndex],
};
}
}
transformedBookingFields.push(...missingSystemAfterFields);
return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(transformedBookingFields);
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
interface IUseAddSelectedCalendar {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useAddSelectedCalendar = (
{ onSuccess, onError }: IUseAddSelectedCalendar = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const newlyAddedCalendarEntry = useMutation<
ApiResponse<{
status: string;
data: {
userId: number;
integration: string;
externalId: string;
credentialId: number | null;
};
}>,
unknown,
{ credentialId: number; integration: string; externalId: string }
>({
mutationFn: (data) => {
return http.post(`/selected-calendars`, data).then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return newlyAddedCalendarEntry;
};

View File

@ -0,0 +1,62 @@
import { useMutation } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
interface IUseDeleteCalendarCredentials {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useDeleteCalendarCredentials = (
{ onSuccess, onError }: IUseDeleteCalendarCredentials = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const deleteCalendarCredentials = useMutation<
ApiResponse<{
status: string;
data: {
id: number;
type: string;
userId: number | null;
teamId: number | null;
appId: string | null;
invalid: boolean | null;
};
}>,
unknown,
{ id: number; calendar: (typeof CALENDARS)[number] }
>({
mutationFn: (data) => {
const { id, calendar } = data;
const body = {
id,
};
return http.post(`/calendars/${calendar}/disconnect`, body).then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return deleteCalendarCredentials;
};

View File

@ -0,0 +1,60 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
interface IUseRemoveSelectedCalendar {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useRemoveSelectedCalendar = (
{ onSuccess, onError }: IUseRemoveSelectedCalendar = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const deletedCalendarEntry = useMutation<
ApiResponse<{
status: string;
data: {
userId: number;
integration: string;
externalId: string;
credentialId: number | null;
};
}>,
unknown,
{ credentialId: number; integration: string; externalId: string }
>({
mutationFn: (data) => {
const { credentialId, externalId, integration } = data;
return http
.delete(
`/selected-calendars?credentialId=${credentialId}&integration=${integration}&externalId=${externalId}`
)
.then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return deletedCalendarEntry;
};

View File

@ -0,0 +1,61 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
import { useAtomsContext } from "../useAtomsContext";
export interface UseCheckProps {
onCheckError?: OnCheckErrorType;
calendar: (typeof CALENDARS)[number];
initialData?: {
status: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data: {
allowConnect: boolean;
checked: boolean;
};
};
}
export type OnCheckErrorType = (err: ApiErrorResponse) => void;
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`];
export const useCheck = ({ onCheckError, calendar, initialData }: UseCheckProps) => {
const { isInit, accessToken } = useAtomsContext();
const queryClient = useQueryClient();
const { data: check, refetch } = useQuery({
queryKey: getQueryKey(calendar),
staleTime: 6000,
enabled: isInit && !!accessToken,
queryFn: () => {
return http
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/calendars/${calendar}/check`)
.then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
return { status: SUCCESS_STATUS, data: { allowConnect: false, checked: true } };
}
onCheckError?.(responseBody);
return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
})
.catch((err) => {
onCheckError?.(err);
return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
});
},
initialData,
});
return {
allowConnect: check?.data?.allowConnect ?? false,
checked: check?.data?.checked ?? false,
refetch: () => {
queryClient.setQueryData(getQueryKey(calendar), {
status: SUCCESS_STATUS,
data: { allowConnect: false, checked: false },
});
refetch();
},
};
};

View File

@ -0,0 +1,92 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
import http from "../../lib/http";
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-redirect-uri`];
interface IPUpdateOAuthCredentials {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse) => void;
}
export const useGetRedirectUrl = (calendar: (typeof CALENDARS)[number], redir?: string) => {
const authUrl = useQuery({
queryKey: getQueryKey(calendar),
staleTime: Infinity,
enabled: false,
queryFn: () => {
return http
?.get<ApiResponse<{ authUrl: string }>>(
`/calendars/${calendar}/connect${redir ? `?redir=${redir}` : ""}`
)
.then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
return responseBody.data.authUrl;
}
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
return "";
});
},
});
return authUrl;
};
export const useConnect = (calendar: (typeof CALENDARS)[number], redir?: string) => {
const { refetch } = useGetRedirectUrl(calendar, redir);
const connect = async () => {
const redirectUri = await refetch();
if (redirectUri.data) {
window.location.href = redirectUri.data;
}
};
return { connect };
};
export const useSaveCalendarCredentials = (
{ onSuccess, onError }: IPUpdateOAuthCredentials = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const mutation = useMutation<
ApiResponse<{ status: string }>,
unknown,
{ username: string; password: string; calendar: (typeof CALENDARS)[number] }
>({
mutationFn: (data) => {
const { calendar, username, password } = data;
const body = {
username,
password,
};
return http.post(`/calendars/${calendar}/credentials`, body).then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return mutation;
};

View File

@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../../../lib/http";
export const QUERY_KEY = "use-event-by-id";
export const useEventTypeById = (id: number | null) => {
const pathname = `/${V2_ENDPOINTS.eventTypes}/${id}`;
return useQuery({
queryKey: [QUERY_KEY, id],
queryFn: () => {
return http?.get<ApiResponse<EventTypeOutput_2024_06_14>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<EventTypeOutput_2024_06_14>).data;
}
throw new Error(res.data.error.message);
});
},
});
};

View File

@ -0,0 +1,59 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { shallow } from "zustand/shallow";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { SUCCESS_STATUS, V2_ENDPOINTS } from "@calcom/platform-constants";
import type { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../../../lib/http";
export const QUERY_KEY = "use-event-type";
export type UsePublicEventReturnType = ReturnType<typeof useEventType>;
export const useEventType = (username: string, eventSlug: string) => {
const [stateUsername, stateEventSlug] = useBookerStore(
(state) => [state.username, state.eventSlug],
shallow
);
const requestUsername = stateUsername ?? username;
const requestEventSlug = stateEventSlug ?? eventSlug;
const isDynamic = useMemo(() => {
return getUsernameList(requestUsername ?? "").length > 1;
}, [requestUsername]);
const event = useQuery({
queryKey: [QUERY_KEY, stateUsername ?? username, stateEventSlug ?? eventSlug],
queryFn: async () => {
if (isDynamic) {
return http
.get<ApiResponse<EventTypeOutput_2024_06_14[]>>(
`/${V2_ENDPOINTS.eventTypes}?usernames=${encodeURIComponent(getUsernameList(username).join(","))}`
)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data.data;
}
throw new Error(res.data.error.message);
});
}
return http
.get<ApiResponse<EventTypeOutput_2024_06_14[]>>(
`/${V2_ENDPOINTS.eventTypes}?username=${requestUsername}&eventSlug=${requestEventSlug}`
)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data.data;
}
throw new Error(res.data.error.message);
});
},
});
return event;
};

View File

@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../../../lib/http";
export const QUERY_KEY = "use-event-types";
export const useEventTypes = (username: string) => {
const pathname = `/${V2_ENDPOINTS.eventTypes}?username=${username}`;
return useQuery({
queryKey: [QUERY_KEY, username],
queryFn: () => {
return http?.get<ApiResponse<EventTypeOutput_2024_06_14[]>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<EventTypeOutput_2024_06_14[]>).data;
}
throw new Error(res.data.error.message);
});
},
});
};

View File

@ -0,0 +1,56 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { V2_ENDPOINTS } from "@calcom/platform-constants";
import type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
import http from "../../lib/http";
import { QUERY_KEY } from "./useSchedule";
interface IUseDeleteScheduleProps {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse) => void;
}
type DeleteScheduleInput = {
id: number;
};
const useDeleteSchedule = (
{ onSuccess, onError }: IUseDeleteScheduleProps = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const queryClient = useQueryClient();
const mutation = useMutation<ApiResponse<undefined>, unknown, DeleteScheduleInput>({
mutationFn: (data) => {
const { id } = data;
const pathname = `/${V2_ENDPOINTS.availability}/${id}`;
return http?.delete(pathname).then((res) => res.data);
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
return mutation;
};
export default useDeleteSchedule;

View File

@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { GetScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import http from "../../lib/http";
export const QUERY_KEY = "user-schedule";
export const useSchedule = (id?: string) => {
const pathname = id ? `/${V2_ENDPOINTS.availability}/${id}` : `/${V2_ENDPOINTS.availability}/default`;
const { isLoading, error, data } = useQuery({
queryKey: [QUERY_KEY, id],
queryFn: () => {
return http.get<GetScheduleOutput_2024_06_11>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data.data;
}
throw new Error(res.data.error?.message);
});
},
});
return { isLoading, error, data };
};

View File

@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import http from "../../lib/http";
export const QUERY_KEY = "user-schedules";
export const useSchedules = () => {
const pathname = `/${V2_ENDPOINTS.availability}`;
const { isLoading, error, data } = useQuery({
queryKey: [QUERY_KEY],
queryFn: () => {
return http.get<GetSchedulesOutput_2024_06_11>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data.data;
}
throw new Error(res.data.error?.message);
});
},
});
return { isLoading, error, data };
};

View File

@ -0,0 +1,56 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { V2_ENDPOINTS } from "@calcom/platform-constants";
import type { ApiResponse, UpdateScheduleInput_2024_06_11, ApiErrorResponse } from "@calcom/platform-types";
import type { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import http from "../../lib/http";
import { QUERY_KEY as ScheduleQueryKey } from "./useSchedule";
interface IPUpdateOAuthClient {
onSuccess?: (res: ApiResponse<ScheduleOutput_2024_06_11>) => void;
onError?: (err: ApiErrorResponse) => void;
}
const useUpdateSchedule = (
{ onSuccess, onError }: IPUpdateOAuthClient = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const queryClient = useQueryClient();
const mutation = useMutation<
ApiResponse<ScheduleOutput_2024_06_11>,
unknown,
UpdateScheduleInput_2024_06_11 & { id: number }
>({
mutationFn: (data) => {
const pathname = `/${V2_ENDPOINTS.availability}/${data.id}`;
return http.patch<ApiResponse<ScheduleOutput_2024_06_11>>(pathname, data).then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
queryClient.invalidateQueries({ queryKey: [ScheduleQueryKey] });
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return mutation;
};
export default useUpdateSchedule;

View File

@ -0,0 +1,5 @@
import { createContext, useContext } from "react";
export const ApiKeyContext = createContext({ key: "", error: "" });
export const useApiKey = () => useContext(ApiKeyContext);

View File

@ -0,0 +1,41 @@
import { createContext, useContext } from "react";
import type { translationKeys, CalProviderLanguagesType } from "../cal-provider/CalProvider";
import type http from "../lib/http";
export interface IAtomsContextOptions {
refreshUrl?: string;
apiUrl: string;
}
export interface IAtomsContext {
clientId: string;
accessToken?: string;
options: IAtomsContextOptions;
error?: string;
getClient: () => typeof http | void;
refreshToken?: string;
isRefreshing?: boolean;
isAuth: boolean;
isValidClient: boolean;
isInit: boolean;
t: (key: string, values: Record<string, string | number | undefined | null>) => string;
i18n: {
language: CalProviderLanguagesType;
defaultLocale: CalProviderLanguagesType;
locales: CalProviderLanguagesType[];
exists: (key: translationKeys | string) => boolean;
};
}
export const AtomsContext = createContext({
clientId: "",
accessToken: "",
options: { refreshUrl: "", apiUrl: "" },
error: "",
getClient: () => {
return;
},
} as IAtomsContext);
export const useAtomsContext = () => useContext(AtomsContext);

View File

@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import type { GetAvailableSlotsInput, ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
export const QUERY_KEY = "get-available-slots";
export const useAvailableSlots = ({ enabled, ...rest }: GetAvailableSlotsInput & { enabled: boolean }) => {
const availableSlots = useQuery({
queryKey: [QUERY_KEY, rest.startTime, rest.endTime, rest.eventTypeId, rest.eventTypeSlug],
queryFn: () => {
return http
.get<ApiResponse<AvailableSlotsType>>("/slots/available", {
params: rest,
})
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<AvailableSlotsType>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: enabled,
});
return availableSlots;
};

View File

@ -0,0 +1,18 @@
import { useState } from "react";
import type { IUseBookings } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBookings) => {
const eventSlug = useBookerStore((state) => state.eventSlug);
const setFormValues = useBookerStore((state) => state.setFormValues);
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
const bookingData = useBookerStore((state) => state.bookingData);
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const seatedEventData = useBookerStore((state) => state.seatedEventData);
const [expiryTime, setExpiryTime] = useState<Date | undefined>();
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const duration = useBookerStore((state) => state.selectedDuration);
const hasInstantMeetingTokenExpired = expiryTime && new Date(expiryTime) < new Date();
};

View File

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
import type { EventBusyDate } from "@calcom/types/Calendar";
import http from "../lib/http";
export const QUERY_KEY = "get-calendars-busy-times";
type UseCalendarsBusyTimesProps = CalendarBusyTimesInput & { onError?: () => void; enabled: boolean };
export const useCalendarsBusyTimes = ({ onError, enabled, ...rest }: UseCalendarsBusyTimesProps) => {
const availableSlots = useQuery({
queryKey: [
QUERY_KEY,
rest?.calendarsToLoad?.toString() ?? "",
rest.dateFrom ?? "",
rest.dateTo ?? "",
rest.loggedInUsersTz,
],
queryFn: () => {
return http
.get<ApiResponse<EventBusyDate[]>>("/calendars/busy-times", {
params: rest,
})
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
onError?.();
throw new Error(res.data.error.message);
});
},
enabled,
});
return availableSlots;
};

View File

@ -0,0 +1,48 @@
import { useMutation } from "@tanstack/react-query";
import type { z } from "zod";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
import type { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
import http from "../lib/http";
interface IUseCancelBooking {
onSuccess?: () => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
type inputParams = z.infer<typeof schemaBookingCancelParams>;
export const useCancelBooking = (
{ onSuccess, onError }: IUseCancelBooking = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const cancelBooking = useMutation<ApiResponse, Error, inputParams>({
mutationFn: (data) => {
return http.post<ApiResponse>(`/bookings/${data.id}/cancel`, data).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error.message);
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.();
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err);
},
});
return cancelBooking;
};

View File

@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
export const QUERY_KEY = "get-connected-calendars";
export const useConnectedCalendars = (props: { enabled?: boolean }) => {
const calendars = useQuery({
queryKey: [QUERY_KEY],
queryFn: () => {
return http.get<ApiResponse<ConnectedDestinationCalendars>>("/calendars").then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<ConnectedDestinationCalendars>)?.data;
}
throw new Error(res.data.error.message);
});
},
enabled: props?.enabled ?? true,
});
return calendars;
};

View File

@ -0,0 +1,47 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { BookingResponse } from "@calcom/platform-libraries";
import type { ApiResponse, ApiErrorResponse, ApiSuccessResponse } from "@calcom/platform-types";
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import http from "../lib/http";
export type UseCreateBookingInput = BookingCreateBody & { locationUrl?: string };
interface IUseCreateBooking {
onSuccess?: (res: ApiSuccessResponse<BookingResponse>) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useCreateBooking = (
{ onSuccess, onError }: IUseCreateBooking = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const createBooking = useMutation<ApiResponse<BookingResponse>, Error, UseCreateBookingInput>({
mutationFn: (data) => {
return http.post<ApiResponse<BookingResponse>>("/bookings", data).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error.message);
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err);
},
});
return createBooking;
};

View File

@ -0,0 +1,45 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { BookingResponse } from "@calcom/platform-libraries";
import type { ApiResponse, ApiErrorResponse, ApiSuccessResponse } from "@calcom/platform-types";
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import http from "../lib/http";
interface IUseCreateInstantBooking {
onSuccess?: (res: ApiSuccessResponse<BookingResponse>) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useCreateInstantBooking = (
{ onSuccess, onError }: IUseCreateInstantBooking = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const createInstantBooking = useMutation<ApiResponse<BookingResponse>, Error, BookingCreateBody>({
mutationFn: (data) => {
return http.post<ApiResponse<BookingResponse>>("/bookings/instant", data).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error.message);
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err);
},
});
return createInstantBooking;
};

View File

@ -0,0 +1,44 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/platform-libraries";
import type { ApiResponse, ApiErrorResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
interface IUseCreateRecurringBooking {
onSuccess?: (res: ApiSuccessResponse<BookingResponse[]>) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useCreateRecurringBooking = (
{ onSuccess, onError }: IUseCreateRecurringBooking = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const createRecurringBooking = useMutation<
ApiResponse<BookingResponse[]>,
Error,
RecurringBookingCreateBody[]
>({
mutationFn: (data) => {
return http.post<ApiResponse<BookingResponse[]>>("/bookings/recurring", data).then((res) => res.data);
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err);
},
});
return createRecurringBooking;
};

View File

@ -0,0 +1,52 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type {
RemoveSelectedSlotInput,
ApiResponse,
ApiSuccessResponseWithoutData,
ApiErrorResponse,
} from "@calcom/platform-types";
import http from "../lib/http";
interface IUseDeleteSelectedSlot {
onSuccess?: (res: ApiSuccessResponseWithoutData) => void;
onError?: (err: ApiErrorResponse) => void;
}
export const useDeleteSelectedSlot = (
{ onSuccess, onError }: IUseDeleteSelectedSlot = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const deletedSlot = useMutation<ApiResponse, unknown, RemoveSelectedSlotInput>({
mutationFn: (props: RemoveSelectedSlotInput) => {
return http
.delete<ApiResponse>("/slots/selected-slot", {
params: props,
})
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error.message);
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return deletedSlot;
};

View File

@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { getBookingInfo } from "@calcom/platform-libraries";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
export const QUERY_KEY = "user-booking";
export const useGetBooking = (uid = "") => {
const pathname = `/${V2_ENDPOINTS.bookings}/${uid}`;
const bookingQuery = useQuery({
queryKey: [QUERY_KEY, uid],
queryFn: () => {
return http
.get<ApiResponse<Awaited<ReturnType<typeof getBookingInfo>>["bookingInfo"]>>(pathname)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<Awaited<ReturnType<typeof getBookingInfo>>["bookingInfo"]>)
.data;
}
throw new Error(res.data.error.message);
});
},
enabled: !!uid,
});
return bookingQuery;
};

View File

@ -0,0 +1,54 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { getBookingForReschedule } from "@calcom/platform-libraries";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
import { useAtomsContext } from "./useAtomsContext";
export const QUERY_KEY = "user-booking";
interface IUseGetBookingForReschedule {
onSuccess?: (res: ApiSuccessResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>["data"]) => void;
onError?: (err: Error) => void;
uid?: string;
}
export const useGetBookingForReschedule = (
props: IUseGetBookingForReschedule = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
uid: "",
}
) => {
const { isInit } = useAtomsContext();
const pathname = `/${V2_ENDPOINTS.bookings}/${props.uid}/reschedule`;
const bookingQuery = useQuery({
queryKey: [QUERY_KEY, props.uid],
queryFn: () => {
return http
.get<ApiResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>>(pathname)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
props.onSuccess?.(
(res.data as ApiSuccessResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>).data
);
return (res.data as ApiSuccessResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>).data;
}
const error = new Error(res.data.error.message);
props.onError?.(error);
throw error;
})
.catch((err) => {
props.onError?.(err);
});
},
enabled: isInit && !!props?.uid,
});
return bookingQuery;
};

View File

@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS, V2_ENDPOINTS } from "@calcom/platform-constants";
import type { getAllUserBookings } from "@calcom/platform-libraries";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import type { GetBookingsInput } from "@calcom/platform-types/bookings";
import http from "../lib/http";
export const QUERY_KEY = "user-bookings";
export const useGetBookings = (input: GetBookingsInput) => {
const pathname = `/${V2_ENDPOINTS.bookings}`;
const bookingsQuery = useQuery({
queryKey: [QUERY_KEY, input?.limit ?? 50, input?.cursor ?? 0, input?.filters?.status ?? "upcoming"],
queryFn: () => {
return http
.get<ApiResponse<Awaited<ReturnType<typeof getAllUserBookings>>>>(pathname, {
params: input,
})
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<Awaited<ReturnType<typeof getAllUserBookings>>>).data;
}
throw new Error(res.data.error.message);
});
},
});
return bookingsQuery;
};

View File

@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import http from "../lib/http";
const useGetCityTimezones = () => {
const pathname = `/timezones`;
const { isLoading, data } = useQuery({
queryKey: ["city-timezones"],
queryFn: () => {
return http?.get(pathname).then((res) => res.data);
},
});
return { isLoading, data };
};
export default useGetCityTimezones;

View File

@ -0,0 +1,107 @@
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import {
useTimePreferences,
mapBookingToMutationInput,
mapRecurringBookingToMutationInput,
} from "@calcom/features/bookings/lib";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import type { UseCreateBookingInput } from "./useCreateBooking";
type UseHandleBookingProps = {
bookingForm: UseBookingFormReturnType["bookingForm"];
event?: {
data?: Pick<
BookerEvent,
"id" | "isDynamic" | "metadata" | "recurringEvent" | "length" | "slug" | "schedulingType"
> | null;
};
metadata: Record<string, string>;
hashedLink?: string | null;
teamMemberEmail?: string;
handleBooking: (input: UseCreateBookingInput) => void;
handleInstantBooking: (input: BookingCreateBody) => void;
handleRecBooking: (input: BookingCreateBody[]) => void;
locationUrl?: string;
};
export const useHandleBookEvent = ({
bookingForm,
event,
metadata,
hashedLink,
teamMemberEmail,
handleBooking,
handleInstantBooking,
handleRecBooking,
locationUrl,
}: UseHandleBookingProps) => {
const setFormValues = useBookerStore((state) => state.setFormValues);
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const duration = useBookerStore((state) => state.selectedDuration);
const { timezone } = useTimePreferences();
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
const { t, i18n } = useLocale();
const username = useBookerStore((state) => state.username);
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const bookingData = useBookerStore((state) => state.bookingData);
const seatedEventData = useBookerStore((state) => state.seatedEventData);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const orgSlug = useBookerStore((state) => state.org);
const handleBookEvent = () => {
const values = bookingForm.getValues();
if (timeslot) {
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
bookingForm.clearErrors();
// It shouldn't be possible that this method is fired without having event data,
// but since in theory (looking at the types) it is possible, we still handle that case.
if (!event?.data) {
bookingForm.setError("globalError", { message: t("error_booking_event") });
return;
}
// Ensures that duration is an allowed value, if not it defaults to the
// default event duration.
const validDuration = event.data.isDynamic
? duration || event.data.length
: duration && event.data.metadata?.multipleDuration?.includes(duration)
? duration
: event.data.length;
const bookingInput = {
values,
duration: validDuration,
event: event.data,
date: timeslot,
timeZone: timezone,
language: i18n.language,
rescheduleUid: rescheduleUid || undefined,
bookingUid: (bookingData && bookingData.uid) || seatedEventData?.bookingUid || undefined,
username: username || "",
metadata: metadata,
hashedLink,
teamMemberEmail,
orgSlug: orgSlug ? orgSlug : undefined,
};
if (isInstantMeeting) {
handleInstantBooking(mapBookingToMutationInput(bookingInput));
} else if (event.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
handleRecBooking(mapRecurringBookingToMutationInput(bookingInput, recurringEventCount));
} else {
handleBooking({ ...mapBookingToMutationInput(bookingInput), locationUrl });
}
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
bookingForm.clearErrors();
}
};
return handleBookEvent;
};

View File

@ -0,0 +1,6 @@
import { useAtomsContext } from "./useAtomsContext";
export const useIsPlatform = () => {
const context = useAtomsContext();
return Boolean(context?.clientId);
};

View File

@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, UserResponse } from "@calcom/platform-types";
import http from "../lib/http";
export const QUERY_KEY = "get-me";
/**
* Custom hook to fetch the current user's information.
* Access Token must be provided to CalProvider in order to use this hook
* @returns The result of the query containing the user's profile.
*/
export const useMe = () => {
const pathname = `/${V2_ENDPOINTS.me}`;
const me = useQuery({
queryKey: [QUERY_KEY],
queryFn: () => {
return http?.get<ApiResponse<UserResponse>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error.message);
});
},
enabled: Boolean(http.getAuthorizationHeader()),
});
return me;
};

View File

@ -0,0 +1,51 @@
import type { AxiosError } from "axios";
import { useState, useEffect } from "react";
import { usePrevious } from "react-use";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../lib/http";
export interface useOAuthClientProps {
clientId: string;
apiUrl?: string;
refreshUrl?: string;
onError: (error: string) => void;
onSuccess: () => void;
}
export const useOAuthClient = ({ clientId, apiUrl, refreshUrl, onError, onSuccess }: useOAuthClientProps) => {
const prevClientId = usePrevious(clientId);
const [isInit, setIsInit] = useState<boolean>(false);
useEffect(() => {
if (apiUrl && http.getUrl() !== apiUrl) {
http.setUrl(apiUrl);
setIsInit(true);
}
if (refreshUrl && http.getRefreshUrl() !== refreshUrl) {
http.setRefreshUrl(refreshUrl);
}
}, [apiUrl, refreshUrl]);
useEffect(() => {
if (clientId && http.getUrl() && prevClientId !== clientId) {
try {
http
.get<ApiResponse>(`/provider/${clientId}`)
.then(() => {
onSuccess();
http.setClientIdHeader(clientId);
})
.catch((err: AxiosError) => {
if (err.response?.status === 401) {
onError("Invalid oAuth Client.");
}
});
} catch (err) {
console.error(err);
}
}
}, [clientId, onError, prevClientId, onSuccess]);
return { isInit };
};

View File

@ -0,0 +1,83 @@
import type { AxiosError, AxiosRequestConfig } from "axios";
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import { useEffect, useState } from "react";
import usePrevious from "react-use/lib/usePrevious";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../lib/http";
export interface useOAuthProps {
accessToken?: string;
refreshUrl?: string;
onError?: (error: string) => void;
onSuccess?: () => void;
clientId: string;
}
const debouncedRefresh = debounce(http.refreshTokens, 10000, { leading: true, trailing: false });
export const useOAuthFlow = ({ accessToken, refreshUrl, clientId, onError, onSuccess }: useOAuthProps) => {
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [clientAccessToken, setClientAccessToken] = useState<string>("");
const prevAccessToken = usePrevious(accessToken);
useEffect(() => {
const interceptorId =
clientAccessToken && http.getAuthorizationHeader()
? http.responseInterceptor.use(undefined, async (err: AxiosError) => {
const originalRequest = err.config as AxiosRequestConfig;
if (refreshUrl && err.response?.status === 498 && !isRefreshing) {
setIsRefreshing(true);
const refreshedToken = await debouncedRefresh(refreshUrl);
if (refreshedToken) {
setClientAccessToken(refreshedToken);
onSuccess?.();
return http.instance({
...originalRequest,
headers: { ...originalRequest.headers, Authorization: `Bearer ${refreshedToken}` },
});
} else {
onError?.("Invalid Refresh Token.");
}
setIsRefreshing(false);
}
return Promise.reject(err.response);
})
: "";
return () => {
if (interceptorId) {
http.responseInterceptor.eject(interceptorId);
}
};
}, [clientAccessToken, isRefreshing, refreshUrl, onError, onSuccess]);
useEffect(() => {
if (accessToken && http.getUrl() && prevAccessToken !== accessToken) {
http.setAuthorizationHeader(accessToken);
try {
http
.get<ApiResponse>(`/provider/${clientId}/access-token`)
.catch(async (err: AxiosError) => {
if ((err.response?.status === 498 || err.response?.status === 401) && refreshUrl) {
setIsRefreshing(true);
const refreshedToken = await http.refreshTokens(refreshUrl);
if (refreshedToken) {
setClientAccessToken(refreshedToken);
onSuccess?.();
} else {
onError?.("Invalid Refresh Token.");
}
setIsRefreshing(false);
}
})
.finally(() => {
setClientAccessToken(accessToken);
});
} catch (err) {}
}
}, [accessToken, clientId, refreshUrl, prevAccessToken, onError, onSuccess]);
return { isRefreshing, currentAccessToken: clientAccessToken };
};

View File

@ -0,0 +1,58 @@
import { useQuery } from "@tanstack/react-query";
import { shallow } from "zustand/shallow";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { SUCCESS_STATUS, V2_ENDPOINTS } from "@calcom/platform-constants";
import type { PublicEventType } from "@calcom/platform-libraries";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../lib/http";
export const QUERY_KEY = "get-public-event";
export type UsePublicEventReturnType = ReturnType<typeof usePublicEvent>;
type Props = {
username: string;
eventSlug: string;
isDynamic?: boolean;
};
export const usePublicEvent = (props: Props) => {
const [username, eventSlug] = useBookerStore((state) => [state.username, state.eventSlug], shallow);
const isTeamEvent = useBookerStore((state) => state.isTeamEvent);
const org = useBookerStore((state) => state.org);
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const requestUsername = username ?? props.username;
const requestEventSlug = eventSlug ?? props.eventSlug;
const event = useQuery({
queryKey: [QUERY_KEY, username ?? props.username, eventSlug ?? props.eventSlug, props.isDynamic],
queryFn: () => {
return http
.get<ApiResponse<PublicEventType>>(
`/${V2_ENDPOINTS.eventTypes}/${requestUsername}/${requestEventSlug}/public`,
{
params: {
isTeamEvent,
org: org ?? null,
},
}
)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
if (props.isDynamic && selectedDuration && res.data.data) {
// note(Lauris): Mandatory - In case of "dynamic" event type default event duration returned by the API is 30,
// but we are re-using the dynamic event type as a team event, so we must set the event length to whatever the event length is.
res.data.data.length = selectedDuration;
}
return res.data.data;
}
throw new Error(res.data.error.message);
});
},
});
return event;
};

View File

@ -0,0 +1,48 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type {
ApiResponse,
ApiErrorResponse,
ApiSuccessResponse,
ReserveSlotInput,
} from "@calcom/platform-types";
import http from "../lib/http";
interface IUseReserveSlot {
onSuccess?: (res: ApiSuccessResponse<string>) => void;
onError?: (err: ApiErrorResponse) => void;
}
export const useReserveSlot = (
{ onSuccess, onError }: IUseReserveSlot = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const reserveSlot = useMutation<ApiResponse<string>, unknown, ReserveSlotInput>({
mutationFn: (props: ReserveSlotInput) => {
return http.post<ApiResponse<string>>("/slots/reserve", props).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error.message);
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return reserveSlot;
};

View File

@ -0,0 +1,71 @@
import { useEffect } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
import { useDeleteSelectedSlot } from "./useDeleteSelectedSlot";
import { useReserveSlot } from "./useReserveSlot";
export type UseSlotsReturnType = ReturnType<typeof useSlots>;
export const useSlots = (event: { data?: Pick<BookerEvent, "id" | "length"> | null }) => {
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore(
(state) => [state.selectedTimeslot, state.setSelectedTimeslot],
shallow
);
const [slotReservationId, setSlotReservationId] = useSlotReservationId();
const reserveSlotMutation = useReserveSlot({
onSuccess: (res) => {
setSlotReservationId(res.data);
},
});
const removeSelectedSlot = useDeleteSelectedSlot();
const handleRemoveSlot = () => {
if (event?.data) {
removeSelectedSlot.mutate({ uid: slotReservationId ?? undefined });
}
};
const handleReserveSlot = () => {
if (event?.data?.id && selectedTimeslot && (selectedDuration || event?.data?.length)) {
reserveSlotMutation.mutate({
slotUtcStartDate: dayjs(selectedTimeslot).utc().format(),
eventTypeId: event.data.id,
slotUtcEndDate: dayjs(selectedTimeslot)
.utc()
.add(selectedDuration || event.data.length, "minutes")
.format(),
});
}
};
const timeslot = useBookerStore((state) => state.selectedTimeslot);
useEffect(() => {
handleReserveSlot();
const interval = setInterval(() => {
handleReserveSlot();
}, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000);
return () => {
handleRemoveSlot();
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event?.data?.id, timeslot]);
return {
selectedTimeslot,
setSelectedTimeslot,
setSlotReservationId,
slotReservationId,
handleReserveSlot,
handleRemoveSlot,
};
};

View File

@ -0,0 +1,19 @@
import { useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useMe } from "./useMe";
export const useTimezone = (
onTimeZoneChange?: (currentTimezone: string) => void,
currentTimezone: string = dayjs.tz.guess()
) => {
const { data: me, isLoading } = useMe();
const preferredTimezone = me?.data?.timeZone ?? currentTimezone;
useEffect(() => {
if (!isLoading && preferredTimezone && onTimeZoneChange && preferredTimezone !== currentTimezone) {
onTimeZoneChange(currentTimezone);
}
}, [currentTimezone, preferredTimezone, onTimeZoneChange, isLoading]);
};

View File

@ -0,0 +1,30 @@
import { useMutation } from "@tanstack/react-query";
import { V2_ENDPOINTS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, UserResponse } from "@calcom/platform-types";
import http from "../lib/http";
type updateTimezoneInput = {
timeZone: string;
};
export const useUpdateUserTimezone = () => {
const pathname = `/${V2_ENDPOINTS.me}`;
const mutation = useMutation<ApiResponse<UserResponse>, unknown, updateTimezoneInput>({
mutationFn: (data) => {
const { timeZone } = data;
return http?.patch(pathname, { timeZone }).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error.message);
});
},
});
return mutation;
};

View File

@ -0,0 +1,20 @@
export { CalProvider } from "./cal-provider";
export { GcalConnect } from "./connect/google/GcalConnect";
export { AvailabilitySettingsPlatformWrapper as AvailabilitySettings } from "./availability";
export { BookerPlatformWrapper as Booker } from "./booker/BookerPlatformWrapper";
export { useIsPlatform } from "./hooks/useIsPlatform";
export { useAtomsContext } from "./hooks/useAtomsContext";
export { useConnectedCalendars } from "./hooks/useConnectedCalendars";
export { useEventTypes } from "./hooks/event-types/public/useEventTypes";
export { useEventType as useEvent } from "./hooks/event-types/public/useEventType";
export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useCancelBooking } from "./hooks/useCancelBooking";
export { useGetBooking } from "./hooks/useGetBooking";
export { useGetBookings } from "./hooks/useGetBookings";
export { useMe } from "./hooks/useMe";
export { OutlookConnect } from "./connect/outlook/OutlookConnect";
export * as Connect from "./connect";
export { BookerEmbed } from "./booker-embed";
export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalendarCredentials";
export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar";
export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar";

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
}
}

View File

@ -0,0 +1,14 @@
const getQueryParam = (paramName: string) => {
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
const searchParams = currentUrl.searchParams;
const paramater = searchParams.get(paramName);
return paramater;
}
return undefined;
};
export default getQueryParam;

View File

@ -0,0 +1,69 @@
import axios from "axios";
import { CAL_API_VERSION_HEADER, X_CAL_CLIENT_ID } from "@calcom/platform-constants";
// Immediately Invoked Function Expression to create simple singleton class like
const http = (function () {
const instance = axios.create({
timeout: 10000,
headers: {},
});
let refreshUrl = "";
return {
instance: instance,
get: instance.get,
post: instance.post,
put: instance.put,
patch: instance.patch,
delete: instance.delete,
responseInterceptor: instance.interceptors.response,
setRefreshUrl: (url: string) => {
refreshUrl = url;
},
getRefreshUrl: () => {
return refreshUrl;
},
setUrl: (url: string) => {
instance.defaults.baseURL = url;
},
getUrl: () => {
return instance.defaults.baseURL;
},
setAuthorizationHeader: (accessToken: string) => {
instance.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`;
},
getAuthorizationHeader: () => {
return instance.defaults.headers.common?.["Authorization"]?.toString() ?? "";
},
setClientIdHeader: (clientId: string) => {
instance.defaults.headers.common[X_CAL_CLIENT_ID] = clientId;
},
getClientIdHeader: () => {
return instance.defaults.headers.common?.[X_CAL_CLIENT_ID]?.toString() ?? "";
},
setVersionHeader: (clientId: string) => {
instance.defaults.headers.common[CAL_API_VERSION_HEADER] = clientId;
},
getVersionHeader: () => {
return instance.defaults.headers.common?.[X_CAL_CLIENT_ID]?.toString() ?? "";
},
refreshTokens: async (refreshUrl: string): Promise<string> => {
const response = await fetch(`${refreshUrl}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: http.getAuthorizationHeader(),
},
});
const res = await response.json();
if (res.accessToken) {
http.setAuthorizationHeader(res.accessToken);
return res.accessToken;
}
return "";
},
};
})();
export default http;

View File

@ -0,0 +1,13 @@
const setQueryParam = (paramName: string, paramValue: string, onParamChange?: () => void) => {
const currentUrl = new URL(window.location.href);
const params = new URLSearchParams(currentUrl.search);
params.set(paramName, paramValue);
currentUrl.search = params.toString();
window.history.replaceState({}, "", currentUrl.href);
!!onParamChange && onParamChange();
};
export default setQueryParam;

View File

@ -0,0 +1,9 @@
export { BookerWebWrapper as Booker } from "./booker";
export { AvailabilitySettingsWebWrapper as AvailabilitySettings } from "./availability/wrappers/AvailabilitySettingsWebWrapper";
export { CalProvider } from "./cal-provider/CalProvider";
export { useIsPlatform } from "./hooks/useIsPlatform";
export { useAtomsContext } from "./hooks/useAtomsContext";
export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useHandleBookEvent } from "./hooks/useHandleBookEvent";
export * as Dialog from "./src/components/ui/dialog";
export { Timezone } from "./timezone";

View File

@ -0,0 +1,70 @@
{
"name": "@calcom/atoms",
"sideEffects": false,
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.0.52",
"scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
"publish": "rm -rf dist && yarn build && npm publish --access public",
"test": "jest"
},
"devDependencies": {
"@calcom/platform-libraries": "0.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.3.1",
"@types/react": "18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^2.2.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19",
"jest": "^29.7.0",
"postcss": "^8.4.38",
"postcss-import": "^16.1.0",
"postcss-prefixer": "^3.0.0",
"postcss-prefixwrap": "1.46.0",
"rollup-plugin-node-builtins": "^2.1.2",
"ts-jest": "^29.1.2",
"typescript": "^4.9.4",
"vite": "^5.0.10",
"vite-plugin-dts": "^3.7.3",
"vite-plugin-inspect": "^0.8.4"
},
"files": [
"dist",
"globals.min.css"
],
"main": "./dist/cal-atoms.umd.cjs",
"module": "./dist/cal-atoms.js",
"exports": {
".": {
"require": "./dist/cal-atoms.umd.cjs",
"import": "./dist/cal-atoms.js",
"types": "./dist/index.d.ts"
},
"./monorepo": {
"import": "./monorepo.ts",
"require": "./monorepo.ts"
},
"./globals.min.css": "./globals.min.css",
"./dist/index.ts": "./index.ts",
"./dist/index.d.ts": "./dist/index.d.ts"
},
"types": "./dist/index.d.ts",
"dependencies": {
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.17.15",
"class-variance-authority": "^0.4.0",
"clsx": "^2.0.0",
"lucide-react": "^0.364.0",
"react-use": "^17.4.2",
"tailwind-merge": "^1.13.2",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6"
}
}

View File

@ -0,0 +1,10 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
"postcss-import": {},
"postcss-prefixwrap": `.calcom-atoms`,
},
};
export default config;

View File

@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import { CALCOM_ATOMS_WRAPPER_CLASS } from "../constants/styles";
export const AtomsWrapper = ({ children }: { children: ReactNode }) => {
return <div className={`${CALCOM_ATOMS_WRAPPER_CLASS} m-0 w-auto p-0`}>{children}</div>;
};

View File

@ -0,0 +1,47 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,106 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as React from "react";
import { Icon } from "@calcom/ui";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<>
<DialogPortal>
<div className="calcom-atoms">
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Icon name="x" className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</div>
</DialogPortal>
</>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,18 @@
import type { LayoutProps } from "@calcom/features/shell/Shell";
import { cn } from "../../lib/utils";
export const Shell = (props: LayoutProps) => {
return (
<div className={cn(props.headerClassName, "flex flex-col px-10 py-4")}>
<div className="mb-6 flex items-center justify-between md:mb-6 md:mt-0 lg:mb-8">
<div className="flex flex-col gap-0.5">
<div>{props.heading}</div>
<div className="text-default text-sm">{props.subtitle}</div>
</div>
<div>{props.CTA}</div>
</div>
<div>{props.children}</div>
</div>
);
};

View File

@ -0,0 +1,111 @@
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Icon } from "@calcom/ui";
import { cn } from "../../lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"ring-offset-background hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"text-foreground/50 hover:text-foreground absolute right-2 top-2 rounded-md p-1 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}>
<Icon name="x" className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,29 @@
import { AtomsWrapper } from "../atoms-wrapper";
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "./toast";
import { useToast } from "./use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<AtomsWrapper>
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<AtomsWrapper key={id}>
<Toast {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
</AtomsWrapper>
);
})}
<ToastViewport />
</ToastProvider>
</AtomsWrapper>
);
}

View File

@ -0,0 +1,187 @@
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "./toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@ -0,0 +1 @@
export const CALCOM_ATOMS_WRAPPER_CLASS = "calcom-atoms";

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,84 @@
const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [
...base.content,
"../../../packages/ui/**/*.{js,ts,jsx,tsx,mdx}",
"../../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
"./**/*.tsx",
],
plugins: [...base.plugins, require("tailwindcss-animate")],
theme: {
...base.theme,
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
...base.theme.container,
},
extend: {
...base.theme.extend,
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
...base.theme.extend.colors,
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
...base.theme.extend.borderRadius,
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
...base.theme.keyframes,
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
...base.theme.animation,
},
},
},
};

View File

@ -0,0 +1,10 @@
import type { TimezoneSelectProps } from "@calcom/ui";
import { TimezoneSelectComponent } from "@calcom/ui";
import useGetCityTimezones from "../hooks/useGetCityTimezones";
export function Timezone(props: TimezoneSelectProps) {
const { isLoading, data } = useGetCityTimezones();
return <TimezoneSelectComponent {...props} data={data?.data} isPending={isLoading} />;
}

View File

@ -0,0 +1,56 @@
{
"extends": "@calcom/tsconfig/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["/*"],
"@/*": ["./src/*"],
"@calcom/lib": ["../../lib"],
"@calcom/lib/*": ["../../lib/*"],
"@calcom/features": ["../../features"],
"@calcom/features/*": ["../../features/*"],
"@calcom/prisma": ["../../prisma"],
"@calcom/dayjs": ["../../dayjs"],
"@calcom/prisma/*": ["../../prisma/*"],
"@calcom/dayjs/*": ["../../dayjs/*"],
"@calcom/platform-constants": ["../constants/index.ts"],
"@calcom/platform-types": ["../types/index.ts"],
"@calcom/platform-utils": ["../constants/index.ts"],
"@calcom/trpc": ["../../trpc"],
"@calcom/app-store": ["../../app-store"],
"@calcom/app-store/*": ["../../app-store/*"],
"@calcom/trpc/*": ["../../trpc/*"]
},
"resolveJsonModule": true,
"outDir": "./dist"
},
"include": [
".",
"../../types/next-auth.d.ts",
"../../types/next.d.ts",
"../../types/@wojtekmaj__react-daterange-picker.d.ts",
"../../types/business-days-plugin.d.ts",
"../../types/window.d.ts",
"../../lib",
"../../features",
"../types",
"../constants",
"../utils",
"../libraries",
"../../prisma",
"../../dayjs",
"../../trpc",
"../../app-store"
],
"exclude": [
"dist",
"build",
"node_modules",
"**/*WebWrapper.tsx",
"**/*.docs.mdx",
"**/*.stories.tsx",
"monorepo.ts",
"booker/export.ts",
"booker/index.ts"
]
}

View File

@ -0,0 +1,7 @@
export interface AtomsGlobalConfigProps {
/**
* API endpoint for the Booker component to fetch data from,
* defaults to https://cal.com
*/
webAppUrl?: string;
}

View File

@ -0,0 +1,59 @@
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { resolve } from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import Inspect from "vite-plugin-inspect";
export default defineConfig({
optimizeDeps: {
include: [
"@calcom/lib",
"@calcom/features",
"@calcom/prisma",
"@calcom/dayjs",
"@calcom/platform-constants",
"@calcom/platform-types",
"@calcom/platform-utils",
],
},
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
build: {
commonjsOptions: {
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
},
lib: {
entry: [resolve(__dirname, "index.ts")],
name: "CalAtoms",
fileName: "cal-atoms",
},
rollupOptions: {
external: ["react", "fs", "path", "os", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
},
resolve: {
alias: {
fs: resolve("../../../node_modules/rollup-plugin-node-builtins"),
path: resolve("../../../node_modules/rollup-plugin-node-builtins"),
os: resolve("../../../node_modules/rollup-plugin-node-builtins"),
"@": path.resolve(__dirname, "./src"),
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/platform-types": path.resolve(__dirname, "../types/index.ts"),
"@calcom/platform-utils": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/web/public/static/locales/en/common.json": path.resolve(
__dirname,
"../../../apps/web/public/static/locales/en/common.json"
),
},
},
});

View File

@ -0,0 +1 @@
this folder contains the constants for the platform

View File

@ -0,0 +1,62 @@
export const BASE_URL = "http://localhost:5555";
export const V2_ENDPOINTS = {
me: "me",
availability: "schedules",
eventTypes: "event-types",
bookings: "bookings",
};
export const SUCCESS_STATUS = "success";
export const ERROR_STATUS = "error";
export const REDIRECT_STATUS = "redirect";
// Client Errors (4xx)
export const BAD_REQUEST = "BAD_REQUEST";
export const UNAUTHORIZED = "UNAUTHORIZED";
export const FORBIDDEN = "FORBIDDEN";
export const NOT_FOUND = "NOT_FOUND";
export const METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED";
export const UNPROCESSABLE_ENTITY = "UNPROCESSABLE_ENTITY";
export const ACCESS_TOKEN_EXPIRED = "ACCESS_TOKEN_IS_EXPIRED";
export const INVALID_ACCESS_TOKEN = "Invalid Access Token.";
// Server Errors (5xx)
export const INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR";
// Custom Errors
export const INVALID_PARAMETER = "INVALID_PARAMETER";
export const MISSING_PARAMETER = "MISSING_PARAMETER";
export const INVALID_API_KEY = "INVALID_API_KEY";
export const RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND";
export const DUPLICATE_RESOURCE = "DUPLICATE_RESOURCE";
export const API_ERROR_CODES = [
BAD_REQUEST,
UNAUTHORIZED,
FORBIDDEN,
NOT_FOUND,
METHOD_NOT_ALLOWED,
UNPROCESSABLE_ENTITY,
INTERNAL_SERVER_ERROR,
INVALID_PARAMETER,
MISSING_PARAMETER,
INVALID_API_KEY,
RESOURCE_NOT_FOUND,
DUPLICATE_RESOURCE,
] as const;
// Request headers
export const X_CAL_SECRET_KEY = "x-cal-secret-key";
export const X_CAL_CLIENT_ID = "x-cal-client-id";
// HTTP status codes
export const HTTP_CODE_TOKEN_EXPIRED = 498;
export const VERSION_2024_06_14 = "2024-06-14";
export const VERSION_2024_06_11 = "2024-06-11";
export const VERSION_2024_04_15 = "2024-04-15";
export const API_VERSIONS = [VERSION_2024_06_14, VERSION_2024_06_11, VERSION_2024_04_15] as const;
export type API_VERSIONS_ENUM = (typeof API_VERSIONS)[number];
export type API_VERSIONS_TYPE = typeof API_VERSIONS;
export const CAL_API_VERSION_HEADER = "cal-api-version";

View File

@ -0,0 +1,16 @@
export const GOOGLE_CALENDAR_TYPE = "google_calendar";
export const GOOGLE_CALENDAR_ID = "google-calendar";
export const OFFICE_365_CALENDAR_TYPE = "office365_calendar";
export const OFFICE_365_CALENDAR_ID = "office365-calendar";
export const GOOGLE_CALENDAR = "google";
export const OFFICE_365_CALENDAR = "office365";
export const APPLE_CALENDAR = "apple";
export const APPLE_CALENDAR_TYPE = "apple_calendar";
export const APPLE_CALENDAR_ID = "apple-calendar";
export const CALENDARS = [GOOGLE_CALENDAR, OFFICE_365_CALENDAR, APPLE_CALENDAR] as const;
export const APPS_TYPE_ID_MAPPING = {
[GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID,
[OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID,
[APPLE_CALENDAR_TYPE]: APPLE_CALENDAR_ID,
} as const;

View File

@ -0,0 +1,3 @@
export * from "./permissions";
export * from "./api";
export * from "./apps";

View File

@ -0,0 +1,11 @@
{
"name": "@calcom/platform-constants",
"version": "0.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc --build --force tsconfig.json",
"build:watch": "tsc --build --force ./tsconfig.json --watch",
"post-install": "yarn build"
}
}

View File

@ -0,0 +1,69 @@
export const EVENT_TYPE_READ = 1; // 2^0
export const EVENT_TYPE_WRITE = 2; // 2^1
export const BOOKING_READ = 4; // 2^2
export const BOOKING_WRITE = 8; // 2^3
export const SCHEDULE_READ = 16; // 2^4
export const SCHEDULE_WRITE = 32; // 2^5
export const APPS_READ = 64; // 2^6
export const APPS_WRITE = 128; // 2^7
export const PROFILE_READ = 256; // 2^8;
export const PROFILE_WRITE = 512; // 2^9;
export const PERMISSIONS = [
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
BOOKING_READ,
BOOKING_WRITE,
SCHEDULE_READ,
SCHEDULE_WRITE,
APPS_READ,
APPS_WRITE,
PROFILE_READ,
PROFILE_WRITE,
] as const;
export const PERMISSION_MAP = {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
BOOKING_READ,
BOOKING_WRITE,
SCHEDULE_READ,
SCHEDULE_WRITE,
APPS_READ,
APPS_WRITE,
PROFILE_READ,
PROFILE_WRITE,
} as const;
export const PERMISSIONS_GROUPED_MAP = {
EVENT_TYPE: {
read: EVENT_TYPE_READ,
write: EVENT_TYPE_WRITE,
key: "eventType",
label: "Event Type",
},
BOOKING: {
read: BOOKING_READ,
write: BOOKING_WRITE,
key: "booking",
label: "Booking",
},
SCHEDULE: {
read: SCHEDULE_READ,
write: SCHEDULE_WRITE,
key: "schedule",
label: "Schedule",
},
APPS: {
read: APPS_READ,
write: APPS_WRITE,
key: "apps",
label: "Apps",
},
PROFILE: {
read: PROFILE_READ,
write: PROFILE_WRITE,
key: "profile",
label: "Profile",
},
} as const;

View File

@ -0,0 +1,11 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"target": "ES5",
"resolveJsonModule": true,
"baseUrl": "./",
"outDir": "./dist"
},
"include": ["."],
"exclude": ["dist", "node_modules"]
}

View File

@ -0,0 +1,3 @@
NEXT_PUBLIC_X_CAL_ID=""
X_CAL_SECRET_KEY=""
NEXT_PUBLIC_CALCOM_API_URL="http://localhost:5555/api/v2"

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.yarn
dev.db

Some files were not shown because too many files have changed in this diff Show More