* Fix breadcrumb colors

* HorizontalTabs

* Team List Item WIP

* Horizontal Tabs

* Cards

* Remove team list item WIP

* Login Page

* Add welcome back i118n

* EventType page work

* Update EventType Icons

* WIP Availability

* Horizontal Tab Work

* Add build command for in root

* Update build DIr/command

* Add Edit Button + change buttons to v2

* Availablitiy page

* Fix IPAD

* Make mobile look a little nicer

* WIP bookingshell

* Remove list items from breaking build

* Mian bulk of Booking Page.

* Few updates to components

* Fix chormatic feedback

* Fix banner

* Fix Empty Screen

* Text area + embded window fixes

* Semi fix avatar

* Troubleshoot container + Active on count

* Improve mobile

* NITS

* Fix padding on input

* Fix icons

* Starting to move event types settings to tabs

* Begin migration to single page form

* Single page tabs

* Limits Page

* Advanced tab

* Add RHF to dependancies

* Most of advanced tab

* Solved RHF mismtach

* Build fixes

* RHF conditionals fixes

* Improved legibility

* Major refactor/organisation into optional V2 UI

* Portal EditLocationModal

* Fix dialoug form

* Update imports

* Auto Animate + custom inputs WIP

* Custom Inputs

* WIP Apps

* Fixing stories imports

* Stripe app

* Remove duplicate dialog

* Remove duplicate dialog

* Fix embed URL

* Fix app toggles + number of active apps

* Fix container padding on disabledBorder prop

* Removes strict

* EventType Team page WIP

* Fix embed

* NIT

* Add Darkmode gray color

* V2 Shell WIP

* Fix headings on shell V2

* Fix mobile layout with V2 shell

* V2 create event type button

* Checked Team Select

* Hidden to happen on save - not on toggle

* Team Attendee Select animation

* Fix scheduling type and remove multi select label

* Fix overflow on teams url

* Even Type move order handles

* Fix Embed TS errors

* Fix TS errors

* Fix Eslint errors

* Fix TS errors for UI

* Fix ESLINT error

* added SidebarCard for promo to v2 and storybook (#3906)

Co-authored-by: Julian Benegas <julianbenegas99@gmail.com>
Co-authored-by: Alan <alannnc@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Tooltip Provider - Wrapper due to dep upgrade

* public event type list darkmode

* V2 Color changes to public booking

* Remove unused component

* Fix typecheck

* Removed extra buttons on create ET dialog

* ET edit page refactoring

* Avoids form wrapping the whole Shell

* Nitpicks

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Julian Benegas <julianbenegas99@gmail.com>
Co-authored-by: Alan <alannnc@gmail.com>
This commit is contained in:
sean-brydon
2022-08-24 14:18:42 -06:00
committed by GitHub
co-authored by Julian Benegas Alan Peer Richelsen zomars Hariom Balhara
parent c17e949cd6
commit 436f88a48e
152 changed files with 11222 additions and 4049 deletions
+4
View File
@@ -20,6 +20,10 @@ module.exports = {
* @see https://github.com/storybookjs/storybook/issues/12844#issuecomment-867544160
*/
config.resolve.roots = [path.resolve(__dirname, "../public"), "node_modules"];
config.resolve.alias = {
...config.resolve.alias,
"@/interfaces": path.resolve(__dirname, "../interfaces"),
};
/**
* Why webpack5... Just why?
+10
View File
@@ -22,3 +22,13 @@ export const parameters = {
Provider: RouterContext.Provider,
},
};
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};
window.getEmbedTheme = () => {
return "auto";
};
+10 -8
View File
@@ -34,15 +34,17 @@
"devDependencies": {
"@babel/core": "^7.18.9",
"@calcom/config": "*",
"@storybook/addon-a11y": "^6.5.9",
"@storybook/addon-actions": "^6.5.9",
"@storybook/addon-essentials": "^6.5.9",
"@storybook/addon-interactions": "^6.5.9",
"@storybook/addon-links": "^6.5.9",
"@storybook/addon-a11y": "^6.5.10",
"@storybook/addon-actions": "^6.5.10",
"@storybook/addon-essentials": "^6.5.10",
"@storybook/addon-interactions": "^6.5.10",
"@storybook/addon-links": "^6.5.10",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/builder-webpack5": "^6.5.9",
"@storybook/manager-webpack5": "^6.5.9",
"@storybook/react": "^6.5.9",
"@storybook/builder-vite": "^0.2.2",
"@storybook/builder-webpack5": "^6.5.10",
"@storybook/manager-webpack5": "^6.5.10",
"@storybook/react": "^6.5.10",
"@tailwindcss/line-clamp": "^0.4.0",
"@types/node": "16.9.1",
"@types/react": "^18.0.9",
"@types/react-dom": "18.0.4",
+1 -1
View File
@@ -1,6 +1,6 @@
import { ComponentMeta } from "@storybook/react";
import Avatar from "@calcom/ui/v2/Avatar";
import Avatar from "@calcom/ui/v2/core/Avatar";
export default {
title: "Avatar",
@@ -1,7 +1,6 @@
import { ComponentMeta } from "@storybook/react";
import Avatar from "@calcom/ui/v2/Avatar";
import AvatarGroup from "@calcom/ui/v2/AvatarGroup";
import AvatarGroup from "@calcom/ui/v2/core/AvatarGroup";
export default {
title: "Avatar/Group",
+1 -1
View File
@@ -1,7 +1,7 @@
import { ComponentMeta } from "@storybook/react";
import { Bell } from "react-feather";
import { Badge } from "@calcom/ui/v2";
import Badge from "@calcom/ui/v2/core/Badge";
export default {
title: "Badge",
+1 -1
View File
@@ -1,7 +1,7 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";
import { Info } from "react-feather";
import { Banner } from "@calcom/ui/v2";
import Banner from "@calcom/ui/v2/core/banner";
export default {
title: "Banner",
@@ -1,6 +1,6 @@
import { ComponentMeta } from "@storybook/react";
import { Breadcrumb, BreadcrumbItem } from "@calcom/ui/v2";
import Breadcrumb, { BreadcrumbItem } from "@calcom/ui/v2/core/Breadcrumb";
export default {
title: "Breadcrumbs",
+48 -45
View File
@@ -1,11 +1,12 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { Trash2 } from "react-feather";
import { Button as ButtonComponent } from "@calcom/ui/v2";
import Button from "@calcom/ui/v2/core/Button";
export default {
title: "Button",
component: ButtonComponent,
component: Button,
argTypes: {
color: {
options: ["primary", "secondary", "minimal", "destructive"],
@@ -24,67 +25,69 @@ export default {
control: { type: "radio" },
},
},
} as ComponentMeta<typeof ButtonComponent>;
} as ComponentMeta<typeof Button>;
const Template: ComponentStory<typeof ButtonComponent> = (args) => <ButtonComponent {...args} />;
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const All = () => (
<div>
<h1>Primary</h1>
<div className="flex space-x-2">
<ButtonComponent aria-label="Button Text">Button Text</ButtonComponent>
<ButtonComponent disabled aria-label="Button Text">
Button Text
</ButtonComponent>
</div>
<h1>Secondary</h1>
<div className="flex space-x-2">
<ButtonComponent color="secondary" aria-label="Button Text">
Button Text
</ButtonComponent>
<ButtonComponent disabled color="secondary" aria-label="Button Text">
Button Text
</ButtonComponent>
<ButtonComponent size="icon" color="secondary" StartIcon={Trash2} aria-label="Button Text" />
</div>
<h1>Minimal</h1>
<div className="flex">
<ButtonComponent color="minimal" aria-label="Button Text">
Button Text
</ButtonComponent>
<ButtonComponent disabled color="minimal" aria-label="Button Text">
Button Text
</ButtonComponent>
<ButtonComponent size="icon" color="minimal" StartIcon={Trash2} aria-label="Button Text" />
</div>
<h1>Destructive</h1>
<ButtonComponent size="icon" color="destructive" StartIcon={Trash2} aria-label="Button Text" />
<h1>Tooltip</h1>
<ButtonComponent
tooltip="Deletes EventTypes"
size="icon"
color="destructive"
StartIcon={Trash2}
aria-label="Button Text"
/>
<TooltipProvider>
<h1>Primary</h1>
<div className="flex space-x-2">
<Button aria-label="Button Text">Button Text</Button>
<Button disabled aria-label="Button Text">
Button Text
</Button>
</div>
<h1>Secondary</h1>
<div className="flex space-x-2">
<Button color="secondary" aria-label="Button Text">
Button Text
</Button>
<Button disabled color="secondary" aria-label="Button Text">
Button Text
</Button>
<Button size="icon" color="secondary" StartIcon={Trash2} aria-label="Button Text" />
</div>
<h1>Minimal</h1>
<div className="flex">
<Button color="minimal" aria-label="Button Text">
Button Text
</Button>
<Button disabled color="minimal" aria-label="Button Text">
Button Text
</Button>
<Button size="icon" color="minimal" StartIcon={Trash2} aria-label="Button Text" />
</div>
<h1>Destructive</h1>
<Button size="icon" color="destructive" StartIcon={Trash2} aria-label="Button Text" />
<h1>Tooltip</h1>
<Button
tooltip="Deletes EventTypes"
size="icon"
color="destructive"
StartIcon={Trash2}
aria-label="Button Text"
/>
</TooltipProvider>
</div>
);
export const Button = Template.bind({});
Button.args = {
export const Default = Template.bind({});
Default.args = {
color: "primary",
children: "Button Text",
};
export const Disabled = Template.bind({});
Disabled.args = {
...Button.args,
...Default.args,
disabled: true,
};
export const Loading = Template.bind({});
Loading.args = {
...Button.args,
...Default.args,
loading: true,
};
export const Icon = Template.bind({});
+19 -13
View File
@@ -1,7 +1,9 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { ComponentMeta } from "@storybook/react";
import { ArrowLeft, ArrowRight, Clipboard, Navigation2, Trash2 } from "react-feather";
import { Button, ButtonGroup } from "@calcom/ui/v2";
import Button from "@calcom/ui/v2/core/Button";
import ButtonGroup from "@calcom/ui/v2/core/ButtonGroup";
export default {
title: "Button Group",
@@ -10,22 +12,26 @@ export default {
export const Default = () => (
<ButtonGroup>
<Button StartIcon={Trash2} size="icon" color="secondary" />
<Button StartIcon={Navigation2} size="icon" color="secondary" />
<Button StartIcon={Clipboard} size="icon" color="secondary" />
<TooltipProvider>
<Button StartIcon={Trash2} size="icon" color="secondary" />
<Button StartIcon={Navigation2} size="icon" color="secondary" />
<Button StartIcon={Clipboard} size="icon" color="secondary" />
</TooltipProvider>
</ButtonGroup>
);
export const Combined = () => (
<div className="flex flex-col space-y-2">
<ButtonGroup combined>
<Button StartIcon={Trash2} size="icon" color="secondary" combined />
<Button StartIcon={Navigation2} size="icon" color="secondary" combined />
<Button StartIcon={Clipboard} size="icon" color="secondary" combined />
</ButtonGroup>
<ButtonGroup combined>
<Button StartIcon={ArrowLeft} size="icon" color="secondary" combined />
<Button StartIcon={ArrowRight} size="icon" color="secondary" combined />
</ButtonGroup>
<TooltipProvider>
<ButtonGroup combined>
<Button StartIcon={Trash2} size="icon" color="secondary" combined />
<Button StartIcon={Navigation2} size="icon" color="secondary" combined />
<Button StartIcon={Clipboard} size="icon" color="secondary" combined />
</ButtonGroup>
<ButtonGroup combined>
<Button StartIcon={ArrowLeft} size="icon" color="secondary" combined />
<Button StartIcon={ArrowRight} size="icon" color="secondary" combined />
</ButtonGroup>
</TooltipProvider>
</div>
);
+27 -1
View File
@@ -1,6 +1,7 @@
import { ComponentMeta } from "@storybook/react";
import { useState } from "react";
import { BaseCardProps, Card } from "@calcom/ui/v2/Card";
import Card, { BaseCardProps } from "@calcom/ui/v2/core/Card";
export default {
title: "Cards",
@@ -51,3 +52,28 @@ export const AppStore = () => {
</div>
);
};
const sidebarCardProps: BaseCardProps = {
variant: "SidebarCard",
thumbnailUrl: "https://img.youtube.com/vi/60HJt8DOVNo/0.jpg",
mediaLink: "https://www.youtube.com/watch?v=60HJt8DOVNo",
title: "Dynamic boooking links",
description: "Booking link that allows people to quickly schedule meetings.",
learnMore: {
href: "https://cal.com/blog/cal-v-1-9",
text: "Learn more",
},
};
export const SidebarCard = () => {
const [visible, setVisible] = useState(true); // save state in localStorage, cookie or db
return (
<header className="w-full max-w-[225px] bg-gray-100 p-3">
{visible && (
<div>
<Card {...sidebarCardProps} actionButton={{ onClick: () => setVisible(false), child: "Dismiss" }} />
</div>
)}
</header>
);
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { ComponentMeta } from "@storybook/react";
import { Checkbox } from "@calcom/ui/v2";
import Checkbox from "@calcom/ui/v2/core/form/Checkbox";
export default {
title: "Checkbox",
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import ColorPicker from "@calcom/ui/v2/colorpicker";
import ColorPicker from "@calcom/ui/v2/core/colorpicker";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
@@ -1,6 +1,6 @@
import { useState } from "react";
import DatePicker from "@calcom/ui/v2/booker/DatePicker";
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
export default {
title: "Datepicker",
@@ -8,10 +8,10 @@ export default {
};
export const Default = () => {
const [selected, setSelected] = useState<Date | undefined>(undefined);
const [selected, setSelected] = useState<Date>(new Date());
return (
<div style={{ width: "455px" }}>
<DatePicker selected={selected} onChange={setSelected} locale="en" />
{/* <DatePicker selected={selected} onChange={setSelected} locale="en" /> */}
</div>
);
};
@@ -1,11 +1,11 @@
import { ComponentMeta } from "@storybook/react";
import { Bell } from "react-feather";
import { EmptyScreen as EmptyScreenPattern } from "@calcom/ui/v2";
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
export default {
title: "pattern/Empty Screen",
component: EmptyScreenPattern,
component: EmptyScreen,
decorators: [
(Story) => (
<div className="flex items-center justify-center">
@@ -13,13 +13,13 @@ export default {
</div>
),
],
} as ComponentMeta<typeof EmptyScreenPattern>;
} as ComponentMeta<typeof EmptyScreen>;
export const EmptyScreenS = () => (
<EmptyScreenPattern
export const Default = () => (
<EmptyScreen
Icon={Bell}
headline="Empty State Header"
description="Ullamco dolor nulla sint nulla occaecat aliquip id elit fugiat et excepteur magna. Nisi tempor anim do tempor irure fugiat ad occaecat. Mollit ea eiusmod pariatur sunt deserunt eu eiusmod. Sit reprehenderit cupidatat consequat commodo in aliqua ea et. Et quis sit enim proident dolor mollit consectetur tempor dolore reprehenderit consequat adipisicing reprehenderit officia. Sint eu sunt fugiat laborum Lorem irure aute nulla et. Do non in enim ipsum ea."
description="Ullamco dolor nulla sint nulla occaecat aliquip id elit fugiat et excepteur magna. Nisi tempor anim do tempor irure fugiat ad occaecat. Mollit ea eiusmod pariatur sunt deserunt eu eiusmod. Sit reprehenderit "
buttonText="Veniam ut ipsum"
buttonOnClick={() => console.log("Button Clicked")}
/>
+1 -1
View File
@@ -1,7 +1,7 @@
import { ComponentMeta } from "@storybook/react";
import { useState } from "react";
import { FormStep } from "@calcom/ui/v2";
import FormStep from "@calcom/ui/v2/core/form/FormStep";
export default {
title: "Form Step",
@@ -1,8 +1,8 @@
// Disabling until we figure out what is happening with the RouterMock.
import { ComponentMeta } from "@storybook/react";
import { HorizontalTabs } from "@calcom/ui/v2/navigation/tabs";
import { HorizontalTabItemProps } from "@calcom/ui/v2/navigation/tabs/HorizontalTabItem";
import { HorizontalTabItemProps } from "@calcom/ui/v2/core/navigation/tabs/HorizontalTabItem";
import HorizontalTabs from "@calcom/ui/v2/core/navigation/tabs/HorizontalTabs";
export default {
title: "Horizontal Tabs",
@@ -14,10 +14,19 @@ const HorizontalTabsPropsDefault: HorizontalTabItemProps[] = [
name: "Tab One",
href: "/tab-one",
},
{
name: "Tab Two",
href: "/tab-two",
},
{
name: "Tab Disabled",
href: "/tab-disabled",
disabled: true,
},
];
export const Default = () => (
<div className="w-full p-4" style={{ backgroundColor: "#F9FAFB" }}>
<div className="w-full p-4">
<HorizontalTabs tabs={HorizontalTabsPropsDefault} />
</div>
);
@@ -25,6 +34,6 @@ export const Default = () => (
Default.parameters = {
nextRouter: {
path: "/[page]",
asPath: "/profile",
asPath: "/tab-one",
},
};
+2 -2
View File
@@ -1,8 +1,8 @@
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { Copy } from "react-feather";
import DatePicker from "@calcom/ui/v2/form/DatePicker";
import { TextAreaField, TextField } from "@calcom/ui/v2/form/fields";
import { TextAreaField, TextField } from "@calcom/ui/v2/core/form/fields";
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
export default {
title: "Inputs",
+1 -3
View File
@@ -1,9 +1,7 @@
import { useState } from "react";
import { Info } from "react-feather";
import { Button } from "@calcom/ui/v2";
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/v2/Dialog";
import { TextField } from "@calcom/ui/v2/form/fields";
import { Button, Dialog, DialogContent, DialogTrigger, TextField } from "@calcom/ui/v2";
export default {
title: "pattern/Modal",
@@ -1,8 +1,7 @@
import { ComponentMeta } from "@storybook/react";
import { Toaster } from "react-hot-toast";
import { Button } from "@calcom/ui/v2";
import showToast from "@calcom/ui/v2/notfications";
import { Button, showToast } from "@calcom/ui/v2";
export default {
title: "Notifcations-Toasts",
@@ -1,8 +1,8 @@
import { ComponentMeta } from "@storybook/react";
import { Search } from "react-feather";
import { Button, PageHeader } from "@calcom/ui/v2";
import { TextField } from "@calcom/ui/v2/form";
import { Button, TextField } from "@calcom/ui/v2";
import PageHeader from "@calcom/ui/v2/core/PageHeader";
export default {
title: "pattern/Page Header",
+2 -2
View File
@@ -1,5 +1,5 @@
import * as Radio from "@calcom/ui/v2/form/radio-area/Radio";
import { RadioField } from "@calcom/ui/v2/form/radio-area/Radio";
import * as Radio from "@calcom/ui/v2/core/form/radio-area/Radio";
import { RadioField } from "@calcom/ui/v2/core/form/radio-area/Radio";
export default {
title: "Radio",
+1 -1
View File
@@ -7,4 +7,4 @@ export default {
component: Select,
} as ComponentMeta<typeof Select>;
export const Single = () => <Select items={[{ value: "Test Test" }]} />;
export const Single = () => <Select options={[{ value: "text" }]} />;
+42 -42
View File
@@ -16,52 +16,52 @@ const TabItemProps = {
info: "60 mins, Zoom",
};
// export const Default = () => (
// <div className="h-20 w-full bg-gray-100 p-4">
// <VerticalTabItem {...TabItemProps}></VerticalTabItem>
// </div>
// );
// export const Active = () => (
// <div className="h-20 w-full bg-gray-100 p-4">
// <VerticalTabItem {...TabItemProps}></VerticalTabItem>
// </div>
// );
export const Default = () => (
<div className="h-20 w-full bg-gray-100 p-4">
<VerticalTabItem {...TabItemProps} />
</div>
);
export const Active = () => (
<div className="h-20 w-full bg-gray-100 p-4">
<VerticalTabItem {...TabItemProps} />
</div>
);
// Mocking next router to show active state
// Active.parameters = {
// nextRouter: {
// path: "/settings/[tab]",
// asPath: "/settings/event",
// },
// };
Active.parameters = {
nextRouter: {
path: "/settings/[tab]",
asPath: "/settings/event",
},
};
// export const Disabled = () => (
// <div className="h-20 w-full bg-gray-100 p-4">
// <VerticalTabItem {...TabItemProps} disabled></VerticalTabItem>
// </div>
// );
export const Disabled = () => (
<div className="h-20 w-full bg-gray-100 p-4">
<VerticalTabItem {...TabItemProps} disabled />
</div>
);
// export const NoIconNoInfo = () => (
// <div className="h-20 w-full bg-gray-100 p-4">
// <VerticalTabItem name="Event Setup" href="/settings/event"></VerticalTabItem>
// </div>
// );
export const NoIconNoInfo = () => (
<div className="h-20 w-full bg-gray-100 p-4">
<VerticalTabItem name="Event Setup" href="/settings/event" />
</div>
);
// export const IconNoInfo = () => (
// <div className="h-20 w-full bg-gray-100 p-4">
// <VerticalTabItem name="Event Setup" href="/settings/event" icon={Link}></VerticalTabItem>
// </div>
// );
export const IconNoInfo = () => (
<div className="h-20 w-full bg-gray-100 p-4">
<VerticalTabItem name="Event Setup" href="/settings/event" icon={Link} />
</div>
);
// export const IconNoInfoActive = () => (
// <div className="h-20 w-full bg-gray-100 p-4">
// <VerticalTabItem name="Event Setup" href="/settings/events" icon={Link}></VerticalTabItem>
// </div>
// );
export const IconNoInfoActive = () => (
<div className="h-20 w-full bg-gray-100 p-4">
<VerticalTabItem name="Event Setup" href="/settings/events" icon={Link} />
</div>
);
// IconNoInfoActive.paramaters = {
// nextRouter: {
// path: "/settings/[tab]",
// asPath: "/settings/events",
// },
// };
IconNoInfoActive.paramaters = {
nextRouter: {
path: "/settings/[tab]",
asPath: "/settings/events",
},
};
@@ -2,7 +2,7 @@
import { ComponentMeta } from "@storybook/react";
import { Calendar, Clock, Grid, Link, RefreshCw, User, Users } from "react-feather";
import { VerticalTabs } from "@calcom/ui/v2/navigation/tabs";
import { VerticalTabs } from "@calcom/ui/v2";
export default {
title: "Vertical Tabs",
+6 -2
View File
@@ -222,7 +222,11 @@ const getEmbedTypeSpecificString = ({
if (!frameworkCodes) {
throw new Error(`No code available for the framework:${embedFramework}`);
}
let uiInstructionStringArg = undefined;
let uiInstructionStringArg: {
apiName: string;
theme: PreviewState["theme"];
brandColor: string;
};
if (embedFramework === "react") {
uiInstructionStringArg = {
apiName: "cal",
@@ -545,7 +549,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, prev
type: "iframe",
Component: forwardRef<
HTMLIFrameElement | HTMLTextAreaElement | null,
{ calLink: string; embedType: EmbedType }
{ calLink: string; embedType: EmbedType; previewState: PreviewState }
>(function Preview({ calLink, embedType }, ref) {
if (ref instanceof Function || !ref) {
return null;
@@ -43,7 +43,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
}, []);
return (
<div className="mt-8 flex flex-col px-4 text-center sm:mt-0 sm:w-1/3 sm:p-5 md:-mb-4 ">
<div className="dark:bg-darkgray-200 mt-8 flex flex-col px-4 text-center sm:mt-0 sm:w-1/3 sm:p-5 md:-mb-4">
<div className="mb-4 text-left text-base">
<span className="text-bookingdarker mb-8 w-1/2 break-words font-semibold text-gray-900 dark:text-white">
{nameOfDay(i18n.language, Number(date.format("d")))}
@@ -87,7 +87,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
<div
className={classNames(
"text-primary-500 mb-2 block rounded-sm border bg-white py-4 font-medium opacity-25 dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 ",
"text-primary-500 dark:bg-darkgray-200 dark:text-darkgray-900 mb-2 block rounded-sm border bg-white py-4 font-medium opacity-25 dark:border-transparent ",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}>
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
@@ -97,7 +97,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<Link href={bookingUrl} prefetch={false}>
<a
className={classNames(
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 text-sm font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand",
"dark:hover:text-darkmodebrandcontrast dark:bg-darkgray-50 dark:hover:border-darkgray-900 mb-2 block rounded-md border-2 bg-white py-4 text-sm font-medium hover:text-white dark:border-transparent dark:text-neutral-200",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">
+1 -1
View File
@@ -33,7 +33,7 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat
};
return selectedTimeZone !== "" ? (
<div className="max-w-80 absolute z-10 w-full rounded-sm rounded border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm dark:border-0 dark:bg-gray-700">
<div className="max-w-80 absolute z-10 w-full rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm dark:border-0 dark:bg-gray-700">
<div className="mb-4 flex">
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
<div className="ml-auto flex items-center">
@@ -29,7 +29,7 @@ import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import DatePicker from "@calcom/ui/booker/DatePicker";
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
import { timeZone as localStorageTimeZone } from "@lib/clock";
// import { timeZone } from "@lib/clock";
@@ -91,7 +91,7 @@ const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
return (
<div className="flex h-full flex-col justify-end">
<button title={t("profile")} onClick={() => router.replace(`${WEBSITE_URL}/${slug}`)}>
<Icon.FiArrowLeft className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white" />
<Icon.FiArrowLeft className="dark:text-darkgray-600 h-4 w-4 text-black transition-opacity hover:cursor-pointer" />
<p className="sr-only">Go Back</p>
</button>
</div>
@@ -210,7 +210,7 @@ const SlotPicker = ({
className={classNames(
"mt-8 w-full sm:mt-0 sm:min-w-[455px]",
selectedDate
? "border-gray-200 sm:w-1/2 sm:border-r sm:p-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
? "sm:dark:border-darkgray-200 border-gray-200 sm:w-1/2 sm:border-r sm:p-4 sm:pr-6 md:w-1/3 "
: "sm:p-4"
)}
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
@@ -273,14 +273,14 @@ function TimezoneDropdown({
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left dark:text-white">
<p className="py-1 text-sm font-medium text-gray-600 dark:text-white">
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<Collapsible.Trigger className="min-w-32 dark:text-darkgray-600 mb-1 -ml-2 px-2 py-1 text-left text-gray-600">
<p className="py-1 text-sm font-medium ">
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
{timeZone}
{isTimeOptionsOpen ? (
<Icon.FiChevronUp className="ml-1 -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiChevronUp className="ml-1 -mt-1 inline-block h-4 w-4 " />
) : (
<Icon.FiChevronDown className="ml-1 -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiChevronDown className="ml-1 -mt-1 inline-block h-4 w-4 " />
)}
</p>
</Collapsible.Trigger>
@@ -412,12 +412,15 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
shouldAlignCentrally ? "mx-auto" : "",
isEmbed
? classNames(maxWidth)
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
: classNames(
"transition-max-width dark:bg- mx-auto my-0 duration-500 ease-in-out md:my-24",
maxWidth
)
)}>
<div
style={availabilityDatePickerEmbedStyles}
className={classNames(
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
isBackgroundTransparent ? "" : "dark:bg-darkgray-300 sm:dark:border-darkgray-300 bg-white",
"border-bookinglightest rounded-md md:border",
isEmbed ? "mx-auto" : maxWidth
)}>
@@ -432,43 +435,45 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
truncateAfter={5}
/>
<div className="mt-4">
<p className="break-words text-sm font-medium text-black dark:text-white">{profile.name}</p>
<div className="mt-2 gap-2 dark:text-gray-100">
<h1 className="text-bookingdark mb-4 break-words text-xl font-semibold dark:text-white">
<p className="dark:text-darkgray-600 break-words text-sm font-medium text-black">
{profile.name}
</p>
<div className="dark:text-darkgray-900 mt-2 gap-2">
<h1 className="text-bookingdark dark:text-darkgray-900 mb-4 break-words text-xl font-semibold">
{eventType.title}
</h1>
<div className="flex flex-col space-y-2">
{eventType?.description && (
<div className="flex py-1 text-sm font-medium text-gray-600 dark:text-white">
<div className="dark:text-darkgray-600 flex py-1 text-sm font-medium text-gray-600">
<div>
<Icon.FiInfo className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
<Icon.FiInfo className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
</div>
<EventTypeDescriptionSafeHTML eventType={eventType} />
</div>
)}
{eventType?.requiresConfirmation && (
<p className="text-gray-600 dark:text-white">
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<p className="dark:text-darkgray-600 text-gray-600">
<Icon.FiCheckSquare className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
{t("requires_confirmation")}
</p>
)}
{eventType.locations.length === 1 && (
<p className="text-gray-600 dark:text-white">
<p className="dark:text-darkgray-600 text-gray-600">
{Object.values(AppStoreLocationType).includes(
eventType.locations[0].type as unknown as AppStoreLocationType
) ? (
<Icon.FiVideo className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiVideo className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
) : (
<Icon.FiMapPin className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiMapPin className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
)}
{locationKeyToString(eventType.locations[0], t)}
</p>
)}
{eventType.locations.length > 1 && (
<div className="flex-warp flex text-gray-600 dark:text-white">
<div className="flex-warp dark:text-darkgray-600 flex text-gray-600">
<div className="mr-[10px] ml-[2px] -mt-1 ">
<Icon.FiMapPin className="inline-block h-4 w-4 text-gray-500" />
<Icon.FiMapPin className="dark:text-darkgray-600 inline-block h-4 w-4 text-gray-500" />
</div>
<p>
{eventType.locations.map((el, i, arr) => {
@@ -484,13 +489,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
</p>
</div>
)}
<p className="text-gray-600 dark:text-white">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-500" />
<p className="dark:text-darkgray-600 text-gray-600">
<Icon.FiClock className="dark:text-darkgray-600 mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-500" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<div className="text-gray-600 dark:text-white">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-500" />
<div className="dark:text-darkgray-600 text-gray-600">
<Icon.FiCreditCard className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
@@ -501,7 +506,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
</div>
)}
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-center text-gray-600 dark:text-white">
<div className="dark:text-darkgray-600 flex items-center text-gray-600">
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 shrink-0 text-gray-500" />
<div>
<p className="mb-1 -ml-2 inline px-2 py-1">
@@ -511,13 +516,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white"
className="w-15 dark:text-darkgray-600 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
<p className="dark:text-darkgray-600 inline text-gray-600 ">
{t("occurrence", {
count: recurringEventCount,
})}
@@ -531,11 +536,11 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
{/* Temp disabled booking?.startTime && rescheduleUid && (
<div>
<p
className="mt-8 text-gray-600 dark:text-white"
className="mt-8 text-gray-600 dark:text-darkgray-600"
data-testid="former_time_p_mobile">
{t("former_time")}
</p>
<p className="text-gray-500 line-through dark:text-white">
<p className="text-gray-500 line-through dark:text-darkgray-600">
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
{typeof booking.startTime === "string" &&
parseDate(dayjs(booking.startTime), i18n)}
@@ -552,7 +557,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
<div className="overflow-hidden sm:flex">
<div
className={
"hidden overflow-hidden border-gray-200 p-5 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
"sm:dark:border-darkgray-200 hidden overflow-hidden border-gray-200 p-5 sm:border-r md:flex md:flex-col " +
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
}>
<UserAvatars
@@ -565,45 +570,45 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
<h2 className="mt-2 break-words text-sm font-medium text-gray-500 dark:text-gray-300">
{profile.name}
</h2>
<h1 className="font-cal mb-6 break-words text-2xl text-gray-900 dark:text-white">
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl text-gray-900 ">
{eventType.title}
</h1>
<div className="flex flex-col space-y-2">
{eventType?.description && (
<div className="flex py-1 text-sm font-medium text-gray-600 dark:text-white">
<div className="dark:text-darkgray-600 flex py-1 text-sm font-medium text-gray-600">
<div>
<Icon.FiInfo className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiInfo className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
</div>
<EventTypeDescriptionSafeHTML eventType={eventType} />
</div>
)}
{eventType?.requiresConfirmation && (
<div className="flex items-center text-sm font-medium text-gray-600 dark:text-white">
<div className="dark:text-darkgray-600 flex items-center text-sm font-medium text-gray-600">
<div>
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
</div>
{t("requires_confirmation")}
</div>
)}
{eventType.locations.length === 1 && (
<p className="py-1 text-sm font-medium text-gray-600 dark:text-white">
<p className="dark:text-darkgray-600 py-1 text-sm font-medium text-gray-600">
{Object.values(AppStoreLocationType).includes(
eventType.locations[0].type as unknown as AppStoreLocationType
) ? (
<Icon.FiVideo className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiVideo className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
) : (
<Icon.FiMapPin className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<Icon.FiMapPin className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
)}
{locationKeyToString(eventType.locations[0], t)}
</p>
)}
{eventType.locations.length > 1 && (
<div className="flex-warp flex items-center font-medium text-gray-600 dark:text-white">
<div className="flex-warp dark:text-darkgray-600 flex items-center font-medium text-gray-600">
<div className="mr-[10px] ml-[2px] -mt-1 ">
<Icon.FiMapPin className="inline-block h-4 w-4 text-gray-500" />
<Icon.FiMapPin className="inline-block h-4 w-4 " />
</div>
<p className="py-1 text-sm font-medium text-gray-600 dark:text-white">
<p className="dark:text-darkgray-600 py-1 text-sm font-medium ">
{eventType.locations.map((el, i, arr) => {
return (
<span key={el.type}>
@@ -617,13 +622,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
</p>
</div>
)}
<p className="py-1 text-sm font-medium text-gray-600 dark:text-white">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-500" />
<p className="dark:text-darkgray-600 py-1 text-sm font-medium text-gray-600">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 " />
{eventType.length} {t("minutes")}
</p>
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-center text-gray-600 dark:text-white">
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 text-gray-500" />
<div className="dark:text-darkgray-600 flex items-center text-gray-600">
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 " />
<div>
<p className="mb-1 -ml-2 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
@@ -632,13 +637,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white"
className="w-15 dark:text-darkgray-600 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
<p className="dark:text-darkgray-600 inline text-gray-600">
{t("occurrence", {
count: recurringEventCount,
})}
@@ -647,8 +652,8 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
</div>
)}
{eventType.price > 0 && (
<p className="-ml-2 px-2 py-1 text-gray-600 dark:text-white">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<p className="dark:text-darkgray-600 -ml-2 px-2 py-1 text-gray-600">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
@@ -666,11 +671,11 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
<div>
<p
className="mt-4 mb-3 text-gray-600 dark:text-white"
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
data-testid="former_time_p_desktop">
{t("former_time")}
</p>
<p className="text-gray-500 line-through dark:text-white">
<p className="text-gray-500 line-through dark:text-darkgray-600">
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
</p>
@@ -450,7 +450,7 @@ const BookingPage = ({
const disableInput = !!rescheduleUid && !!defaultUserValues.email && !!defaultUserValues.name;
const disabledExceptForOwner = disableInput && !loggedInIsOwner;
const inputClassName =
"focus:border-brand block w-full rounded-sm border-gray-300 focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 text-sm";
"dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500";
let isSmsReminderNumberNeeded = false;
@@ -495,11 +495,11 @@ const BookingPage = ({
className={classNames(
"main overflow-hidden",
isEmbed ? "" : "border border-gray-200",
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
"rounded-md dark:border-gray-600 sm:border"
isBackgroundTransparent ? "" : "dark:border-1 dark:bg-darkgray-200 bg-white",
"dark:border-darkgray-300 rounded-md sm:border"
)}>
<div className="sm:flex">
<div className="px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6 sm:dark:border-gray-700">
<div className="sm:dark:border-darkgray-50 dark:text-darkgray-600 px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
<UserAvatars
profile={profile}
users={eventType.users}
@@ -528,23 +528,23 @@ const BookingPage = ({
</p>
)}
{eventType?.description && (
<p className="text-bookinglight mb-2 text-sm dark:text-white">
<Icon.FiInfo className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
<p className="text-bookinglight mb-2 text-sm ">
<Icon.FiInfo className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{eventType.description}
</p>
)}
{eventType?.requiresConfirmation && (
<p className="text-bookinglight mb-2 text-sm dark:text-white">
<Icon.FiClipboard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
<p className="text-bookinglight mb-2 text-sm ">
<Icon.FiClipboard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{t("requires_confirmation")}
</p>
)}
<p className="text-bookinglight mb-2 text-sm dark:text-white">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="text-bookinglight mb-2 text-sm ">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1 text-sm dark:text-white">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1 text-sm ">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
@@ -556,8 +556,8 @@ const BookingPage = ({
</p>
)}
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
<div className="mb-3 text-sm text-gray-600 dark:text-white">
<Icon.FiRefreshCw className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<div className="mb-3 text-sm text-gray-600 ">
<Icon.FiRefreshCw className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
<p className="mb-1 -ml-2 inline px-2 py-1">
{getEveryFreqFor({
t,
@@ -581,7 +581,7 @@ const BookingPage = ({
content={recurringStrings.slice(5).map((aDate, key) => (
<p key={key}>{aDate}</p>
))}>
<p className="text-sm text-gray-600 dark:text-white">
<p className="dark:text-darkgray-600 text-sm">
{t("plus_more", { count: recurringStrings.length - 5 })}
</p>
</Tooltip>
@@ -596,11 +596,11 @@ const BookingPage = ({
)}
{booking?.startTime && rescheduleUid && (
<div>
<p className="mt-8 mb-2 text-sm text-gray-600 dark:text-white" data-testid="former_time_p">
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
{t("former_time")}
</p>
<p className="text-gray-500 line-through dark:text-white">
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
<p className="line-through ">
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
</p>
</div>
@@ -665,9 +665,7 @@ const BookingPage = ({
value={location.type}
defaultChecked={selectedLocation === location.type}
/>
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
{locationLabels[location.type]}
</span>
<span className="text-sm ltr:ml-2 rtl:mr-2 ">{locationLabels[location.type]}</span>
</label>
))}
</div>
@@ -886,6 +884,7 @@ const BookingPage = ({
<div className="flex items-start space-x-2 rtl:space-x-reverse">
<Button
type="submit"
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
loading={mutation.isLoading || recurringMutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
+1 -1
View File
@@ -3,7 +3,7 @@ import { useEffect } from "react";
import { HexColorInput, HexColorPicker } from "react-colorful";
import { isValidHexCode, fallBackHex } from "@calcom/lib/CustomBranding";
import Swatch from "@calcom/ui/v2/Swatch";
import { Swatch } from "@calcom/ui/v2";
type Handler = (event: MouseEvent | Event) => void;
function useEventListener<
@@ -35,17 +35,6 @@ export const CheckedSelect = ({
name={props.name}
placeholder={props.placeholder || t("select")}
isSearchable={false}
formatOptionLabel={({ label, avatar, disabled }) => (
<div className="flex">
<Avatar className="mr-3 h-6 w-6 rounded-full" alt={label} imageSrc={avatar} />
{label}
{disabled && (
<div className="flex-grow">
<Icon.FiCheck className="float-right h-6 w-6 text-neutral-500" />
</div>
)}
</div>
)}
options={options}
value={value}
isMulti
@@ -0,0 +1,120 @@
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
import React, { FC } from "react";
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
import { Button, Select, TextArea, TextField } from "@calcom/ui/v2";
import { useLocale } from "@lib/hooks/useLocale";
interface OptionTypeBase {
label: string;
value: EventTypeCustomInputType;
}
interface Props {
onSubmit: SubmitHandler<IFormInput>;
onCancel: () => void;
selectedCustomInput?: EventTypeCustomInput;
}
type IFormInput = EventTypeCustomInput;
const CustomInputTypeForm: FC<Props> = (props) => {
const { t } = useLocale();
const inputOptions: OptionTypeBase[] = [
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
];
const { selectedCustomInput } = props;
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
const { register, control, handleSubmit } = useForm<IFormInput>({
defaultValues,
});
const selectedInputType = useWatch({ name: "type", control });
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
const onCancel = () => {
props.onCancel();
};
return (
<form onSubmit={handleSubmit(props.onSubmit)}>
<div className="mb-2">
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
{t("input_type")}
</label>
<Controller
name="type"
control={control}
render={({ field }) => (
<Select
id="type"
defaultValue={selectedInputOption}
options={inputOptions}
isSearchable={false}
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
onChange={(option) => option && field.onChange(option.value)}
value={selectedInputOption}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</div>
<TextField
label={t("label")}
type="text"
id="label"
required
className="block w-full rounded-sm border-gray-300 text-sm"
defaultValue={selectedCustomInput?.label}
{...register("label", { required: true })}
/>
{(selectedInputType === EventTypeCustomInputType.TEXT ||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
<TextField
label={t("placeholder")}
type="text"
id="placeholder"
className="block w-full rounded-sm border-gray-300 text-sm"
defaultValue={selectedCustomInput?.placeholder}
{...register("placeholder")}
/>
)}
<div className="flex h-5 items-center">
<input
id="required"
type="checkbox"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 ltr:mr-2 rtl:ml-2"
defaultChecked={selectedCustomInput?.required ?? true}
{...register("required")}
/>
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
{t("is_required")}
</label>
</div>
<input
type="hidden"
id="eventTypeId"
value={selectedCustomInput?.eventTypeId || -1}
{...register("eventTypeId", { valueAsNumber: true })}
/>
<input
type="hidden"
id="id"
value={selectedCustomInput?.id || -1}
{...register("id", { valueAsNumber: true })}
/>
<div className="mt-5 flex justify-end space-x-2 sm:mt-4">
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
{t("cancel")}
</Button>
<Button type="submit">{t("save")}</Button>
</div>
</form>
);
};
export default CustomInputTypeForm;
@@ -0,0 +1,553 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { LocationOptionsToString } from "@calcom/app-store/locations";
import { LocationType } from "@calcom/core/location";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
import { Button } from "@calcom/ui/v2";
import { Form } from "@calcom/ui/v2";
import { QueryCell } from "@lib/QueryCell";
import { linkValueToString } from "@lib/linkValueToString";
import CheckboxField from "@components/ui/form/CheckboxField";
import Select from "@components/ui/form/Select";
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
type OptionTypeBase = {
label: string;
value: LocationType;
disabled?: boolean;
};
type LocationFormValues = {
locationType: LocationType;
locationAddress?: string;
locationLink?: string;
locationPhoneNumber?: string;
displayLocationPublicly?: boolean;
};
interface ISetLocationDialog {
saveLocation: (newLocationType: LocationType, details: { [key: string]: string }) => void;
selection?: OptionTypeBase;
booking?: BookingItem;
defaultValues?: {
type: LocationType;
address?: string | undefined;
link?: string | undefined;
hostPhoneNumber?: string | undefined;
displayLocationPublicly?: boolean | undefined;
}[];
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
isOpenDialog: boolean;
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
}
export const EditLocationDialog = (props: ISetLocationDialog) => {
const {
saveLocation,
selection,
booking,
setShowLocationModal,
isOpenDialog,
defaultValues,
setSelectedLocation,
} = props;
const { t } = useLocale();
const locationsQuery = trpc.useQuery(["viewer.locationOptions"]);
useEffect(() => {
if (selection) {
locationFormMethods.setValue("locationType", selection?.value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection]);
const locationFormSchema = z.object({
locationType: z.string(),
locationAddress: z.string().optional(),
locationLink:
selection?.value === LocationType.Whereby
? z
.string()
.regex(/^http(s)?:\/\/(www\.)?whereby.com\/[a-zA-Z0-9]*/)
.optional()
: selection?.value === LocationType.Around
? z
.string()
.regex(/^http(s)?:\/\/(www\.)?around.co\/[a-zA-Z0-9]*/)
.optional()
: selection?.value === LocationType.Ping
? z
.string()
.regex(/^http(s)?:\/\/(www\.)?ping.gg\/call\/[a-zA-Z0-9]*/)
.optional()
: selection?.value === LocationType.Riverside
? z
.string()
.regex(/^http(s)?:\/\/(www\.)?riverside.fm\/studio\/[a-zA-Z0-9]*/)
.optional()
: z.string().url().optional(),
displayLocationPublicly: z.boolean().optional(),
locationPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
});
const locationFormMethods = useForm<LocationFormValues>({
mode: "onSubmit",
resolver: zodResolver(locationFormSchema),
});
const selectedLocation = useWatch({
control: locationFormMethods.control,
name: "locationType",
});
const LocationOptions =
selectedLocation === LocationType.InPerson ? (
<>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
{t("set_address_place")}
</label>
<div className="mt-1">
<input
type="text"
{...locationFormMethods.register("locationAddress")}
id="address"
required
className="block w-full rounded-md border-gray-300 text-sm"
defaultValue={
defaultValues
? defaultValues.find(
(location: { type: LocationType }) => location.type === LocationType.InPerson
)?.address
: undefined
}
/>
</div>
{!booking && (
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={() => (
<CheckboxField
defaultChecked={
defaultValues
? defaultValues.find((location) => location.type === LocationType.InPerson)
?.displayLocationPublicly
: undefined
}
description={t("display_location_label")}
onChange={(e) =>
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
}
informationIconText={t("display_location_info_badge")}
/>
)}
/>
</div>
)}
</div>
</>
) : selectedLocation === LocationType.Link ? (
<div>
<label htmlFor="link" className="block text-sm font-medium text-gray-700">
{t("set_link_meeting")}
</label>
<div className="mt-1">
<input
type="text"
{...locationFormMethods.register("locationLink")}
required
id="link"
className="block w-full rounded-md border-gray-300 text-sm"
defaultValue={
defaultValues
? defaultValues.find(
(location: { type: LocationType }) => location.type === LocationType.Link
)?.link
: undefined
}
/>
{locationFormMethods.formState.errors.locationLink && (
<p className="mt-1 text-sm text-red-500">{t("url_start_with_https")}</p>
)}
</div>
{!booking && (
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={() => (
<CheckboxField
description={t("display_location_label")}
defaultChecked={
defaultValues
? defaultValues.find((location) => location.type === LocationType.Link)
?.displayLocationPublicly
: undefined
}
onChange={(e) => locationFormMethods.setValue("displayLocationPublicly", e.target.checked)}
informationIconText={t("display_location_info_badge")}
/>
)}
/>
</div>
)}
</div>
) : selectedLocation === LocationType.UserPhone ? (
<div>
<label htmlFor="phonenumber" className="block text-sm font-medium text-gray-700">
{t("set_your_phone_number")}
{locationFormMethods.formState?.errors?.locationPhoneNumber?.message}
</label>
<div className="mt-1">
<PhoneInput<LocationFormValues>
control={locationFormMethods.control}
name="locationPhoneNumber"
required
id="locationPhoneNumber"
placeholder={t("host_phone_number")}
defaultValue={
defaultValues
? defaultValues.find(
(location: { type: LocationType }) => location.type === LocationType.UserPhone
)?.hostPhoneNumber
: undefined
}
/>
{locationFormMethods.formState.errors.locationPhoneNumber && (
<p className="mt-1 text-sm text-red-500">{t("invalid_number")}</p>
)}
</div>
</div>
) : selectedLocation === LocationType.Whereby ? (
<>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
{t("set_whereby_link")}
</label>
<div className="mt-1">
<input
type="text"
{...locationFormMethods.register("locationLink")}
id="wherebylink"
placeholder="https://www.whereby.com/cal"
required
className="block w-full rounded-md border-gray-300 text-sm"
defaultValue={
defaultValues
? defaultValues.find(
(location: { type: LocationType }) => location.type === LocationType.Whereby
)?.address
: undefined
}
/>
</div>
{!booking && (
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={() => (
<CheckboxField
defaultChecked={
defaultValues
? defaultValues.find((location) => location.type === LocationType.Whereby)
?.displayLocationPublicly
: undefined
}
description={t("display_location_label")}
onChange={(e) =>
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
}
informationIconText={t("display_location_info_badge")}
/>
)}
/>
</div>
)}
</div>
</>
) : selectedLocation === LocationType.Around ? (
<>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
{t("set_around_link")}
</label>
<div className="mt-1">
<input
type="text"
{...locationFormMethods.register("locationLink")}
id="aroundlink"
placeholder="https://www.around.co/rick"
required
className="block w-full rounded-md border-gray-300 text-sm"
defaultValue={
defaultValues
? defaultValues.find(
(location: { type: LocationType }) => location.type === LocationType.Around
)?.address
: undefined
}
/>
</div>
{!booking && (
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={() => (
<CheckboxField
defaultChecked={
defaultValues
? defaultValues.find((location) => location.type === LocationType.Around)
?.displayLocationPublicly
: undefined
}
description={t("display_location_label")}
onChange={(e) =>
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
}
informationIconText={t("display_location_info_badge")}
/>
)}
/>
</div>
)}
</div>
</>
) : selectedLocation === LocationType.Ping ? (
<>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
{t("set_ping_link")}
</label>
<div className="mt-1">
<input
type="text"
{...locationFormMethods.register("locationLink")}
id="pinglink"
placeholder="https://www.ping.gg/call/theo"
required
className="block w-full rounded-md border-gray-300 text-sm"
defaultValue={
defaultValues
? defaultValues.find(
(location: { type: LocationType }) => location.type === LocationType.Ping
)?.address
: undefined
}
/>
</div>
{!booking && (
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={() => (
<CheckboxField
defaultChecked={
defaultValues
? defaultValues.find((location) => location.type === LocationType.Ping)
?.displayLocationPublicly
: undefined
}
description={t("display_location_label")}
onChange={(e) =>
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
}
informationIconText={t("display_location_info_badge")}
/>
)}
/>
</div>
)}
</div>
</>
) : selectedLocation === LocationType.Riverside ? (
<>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
{t("set_riverside_link")}
</label>
<div className="mt-1">
<input
type="text"
{...locationFormMethods.register("locationLink")}
id="aroundlink"
placeholder="https://www.riverside.fm/studio/rick"
required
className="block w-full rounded-md border-gray-300 text-sm"
defaultValue={
defaultValues
? defaultValues.find(
(location: { type: LocationType }) => location.type === LocationType.Riverside
)?.address
: undefined
}
/>
</div>
{!booking && (
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={() => (
<CheckboxField
defaultChecked={
defaultValues
? defaultValues.find((location) => location.type === LocationType.Riverside)
?.displayLocationPublicly
: undefined
}
description={t("display_location_label")}
onChange={(e) =>
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
}
informationIconText={t("display_location_info_badge")}
/>
)}
/>
</div>
)}
</div>
</>
) : (
<p className="text-sm">{LocationOptionsToString(selectedLocation, t)}</p>
);
return (
<Dialog open={isOpenDialog}>
<DialogContent asChild>
<div className="inline-block transform rounded-md bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("edit_location")}
</h3>
{!booking && (
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
)}
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left" />
</div>
{booking && (
<>
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
<p className="mb-2 ml-1 text-sm text-black">{linkValueToString(booking.location, t)}</p>
</>
)}
<Form
form={locationFormMethods}
handleSubmit={async (values) => {
const { locationType: newLocation, displayLocationPublicly } = values;
let details = {};
if (newLocation === LocationType.InPerson) {
details = {
address: values.locationAddress,
displayLocationPublicly,
};
}
if (
newLocation === LocationType.Link ||
newLocation === LocationType.Whereby ||
newLocation === LocationType.Around ||
newLocation === LocationType.Riverside ||
newLocation === LocationType.Ping
) {
details = { link: values.locationLink, displayLocationPublicly };
}
if (newLocation === LocationType.UserPhone) {
details = { hostPhoneNumber: values.locationPhoneNumber };
}
saveLocation(newLocation, details);
setShowLocationModal(false);
setSelectedLocation?.(undefined);
locationFormMethods.unregister([
"locationType",
"locationLink",
"locationAddress",
"locationPhoneNumber",
]);
}}>
<QueryCell
query={locationsQuery}
success={({ data: locationOptions }) => {
if (!locationOptions.length) return null;
return (
<Controller
name="locationType"
control={locationFormMethods.control}
render={() => (
<Select
maxMenuHeight={150}
name="location"
defaultValue={selection}
options={
booking
? locationOptions.filter((location) => location.value !== "phone")
: locationOptions
}
isSearchable={false}
className="my-4 block w-full min-w-0 flex-1 rounded-md border border-gray-300 text-sm"
onChange={(val) => {
if (val) {
locationFormMethods.setValue("locationType", val.value);
locationFormMethods.unregister([
"locationLink",
"locationAddress",
"locationPhoneNumber",
]);
locationFormMethods.clearErrors([
"locationLink",
"locationPhoneNumber",
"locationAddress",
]);
setSelectedLocation?.(val);
}
}}
/>
)}
/>
);
}}
/>
{selectedLocation && LocationOptions}
<div className="mt-4 flex justify-end space-x-2">
<Button
onClick={() => {
setShowLocationModal(false);
setSelectedLocation?.(undefined);
locationFormMethods.unregister("locationType");
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<Button type="submit">{t("update")}</Button>
</div>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,322 @@
import autoAnimate from "@formkit/auto-animate";
import { EventTypeCustomInput } from "@prisma/client/";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import {
Button,
CustomInputItem,
DestinationCalendarSelector,
Dialog,
DialogContent,
Label,
showToast,
Switch,
TextField,
Tooltip,
} from "@calcom/ui/v2";
import CustomInputTypeForm from "@components/v2/eventtype/CustomInputTypeForm";
const generateHashedLink = (id: number) => {
const translator = short();
const seed = `${id}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
return uid;
};
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
const utils = trpc.useContext();
const [showEventNameTip, setShowEventNameTip] = useState(false);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
eventType.customInputs.sort((a, b) => a.id - b.id) || []
);
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
const animationRef = useRef(null);
useEffect(() => {
animationRef.current && autoAnimate(animationRef.current);
}, [animationRef]);
const removeCustom = (index: number) => {
formMethods.getValues("customInputs").splice(index, 1);
customInputs.splice(index, 1);
setCustomInputs([...customInputs]);
};
useEffect(() => {
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
}, [eventType.users, hashedUrl, team?.id]);
return (
<div className="flex flex-col space-y-8">
{/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attende calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<Label>{t("add_to_calendar")}</Label>
<div className="w-full">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
<p className="text-sm text-gray-600">{t("select_which_cal")}</p>
</div>
)}
<div className="w-full">
<TextField
label={t("event_name")}
type="text"
placeholder={t("meeting_with_user")}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
type="button"
StartIcon={Icon.FiEdit}
size="icon"
color="minimal"
className="hover:stroke-3 hover:bg-transparent hover:text-black"
onClick={() => setShowEventNameTip((old) => !old)}
/>
}
/>
</div>
<hr />
<div className="">
<div className="flex space-x-3 ">
<Switch
checked={customInputs.length > 0}
onCheckedChange={(e) => {
if (e && customInputs.length === 0) {
// Push a placeholders
setSelectedCustomInput(undefined);
setSelectedCustomInputModalOpen(true);
} else if (!e) {
setCustomInputs([]);
formMethods.setValue("customInputs", []);
}
}}
/>
<div className="flex flex-col">
<Label className="text-sm font-semibold leading-none text-black">{t("additional_inputs")}</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("additional_input_description")}</p>
</div>
</div>
<ul className="my-4" ref={animationRef}>
{customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
<CustomInputItem
key={idx}
question={customInput.label}
type={customInput.type}
required={customInput.required}
editOnClick={() => {
setSelectedCustomInput(customInput);
setSelectedCustomInputModalOpen(true);
}}
deleteOnClick={() => removeCustom(idx)}
/>
))}
</ul>
{customInputs.length > 0 && (
<Button
StartIcon={Icon.FiPlus}
color="minimal"
type="button"
onClick={() => {
setSelectedCustomInput(undefined);
setSelectedCustomInputModalOpen(true);
}}>
Add an input
</Button>
)}
</div>
<hr />
<Controller
name="requiresConfirmation"
defaultValue={eventType.requiresConfirmation}
render={({ field: { value, onChange } }) => (
<div className="flex space-x-3 ">
<Switch name="requireConfirmation" checked={value} onCheckedChange={(e) => onChange(e)} />
<div className="flex flex-col">
<Label className="text-sm font-semibold leading-none text-black">
{t("requires_confirmation")}
</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">
{t("requires_confirmation_description")}
</p>
</div>
</div>
)}
/>
<hr />
<Controller
name="hideCalendarNotes"
control={formMethods.control}
defaultValue={eventType.hideCalendarNotes}
render={({ field: { value, onChange } }) => (
<div className="flex space-x-3 ">
<Switch name="hideCalendarNotes" checked={value} onCheckedChange={(e) => onChange(e)} />
<div className="flex flex-col">
<Label className="text-sm font-semibold leading-none text-black">{t("disable_notes")}</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("disable_notes_description")}</p>
</div>
</div>
)}
/>
<hr />
<Controller
name="hashedLink"
control={formMethods.control}
defaultValue={hashedUrl}
render={({ field: { value, onChange } }) => (
<>
<div className="flex space-x-3 ">
<Switch
name="hashedLinkCheck"
defaultChecked={!!value}
onCheckedChange={(e) => {
setHashedLinkVisible(e);
onChange(e ? hashedUrl : undefined);
}}
/>
<div className="flex flex-col">
<Label className="text-sm font-semibold leading-none text-black">{t("private_link")}</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("private_link_description")}</p>
</div>
</div>
{hashedLinkVisible && (
<div className="">
<TextField
disabled
name="hashedLink"
label={t("private_link_label")}
data-testid="generated-hash-url"
type="text"
hint={t("private_link_hint")}
defaultValue={placeholderHashedLink}
addOnSuffix={
<Tooltip
content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
<Button
color="minimal"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
showToast(t("private_link_copied"), "success");
} else {
showToast(t("enabled_after_update_description"), "warning");
}
}}
className="hover:stroke-3 hover:bg-transparent hover:text-black"
type="button">
<Icon.FiCopy />
</Button>
</Tooltip>
}
/>
</div>
)}
</>
)}
/>
{showEventNameTip && (
<Dialog open={showEventNameTip} onOpenChange={setShowEventNameTip}>
<DialogContent
title={t("custom_event_name")}
description={t("custom_event_name_description")}
type="creation"
actionText="Create"
// Set event name back to what it was on close
actionOnClose={() => formMethods.setValue("eventName", eventType.eventName ?? "")}
actionOnClick={() => setShowEventNameTip(false)}>
<TextField
label={t("event_name")}
type="text"
placeholder={t("meeting_with_user")}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
/>
<div className="mt-1 text-gray-500">
<p>{`{HOST} = ${t("your_name")}`}</p>
<p>{`{ATTENDEE} = ${t("attendee_name")}`}</p>
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
<p>{`{LOCATION} = ${t("event_location")}`}</p>
</div>
</DialogContent>
</Dialog>
)}
<Controller
name="customInputs"
control={formMethods.control}
defaultValue={eventType.customInputs.sort((a, b) => a.id - b.id) || []}
render={() => (
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
<DialogContent
type="creation"
Icon={Icon.FiPlus}
title={t("add_new_custom_input_field")}
useOwnActionButtons
description={t("this_input_will_shown_booking_this_event")}>
<CustomInputTypeForm
selectedCustomInput={selectedCustomInput}
onSubmit={(values) => {
const customInput: EventTypeCustomInput = {
id: -1,
eventTypeId: -1,
label: values.label,
placeholder: values.placeholder,
required: values.required,
type: values.type,
};
if (selectedCustomInput) {
selectedCustomInput.label = customInput.label;
selectedCustomInput.placeholder = customInput.placeholder;
selectedCustomInput.required = customInput.required;
selectedCustomInput.type = customInput.type;
} else {
setCustomInputs(customInputs.concat(customInput));
formMethods.setValue("customInputs", customInputs.concat(customInput));
}
setSelectedCustomInputModalOpen(false);
}}
onCancel={() => {
setSelectedCustomInputModalOpen(false);
}}
/>
</DialogContent>
</Dialog>
)}
/>
</div>
);
};
@@ -0,0 +1,160 @@
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import React, { useState } from "react";
import { useFormContext } from "react-hook-form";
import { Controller } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { SelectGifInput } from "@calcom/app-store/giphy/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, Switch, TextField } from "@calcom/ui/v2";
const AppCard = ({
logo,
name,
description,
switchOnClick,
switchChecked,
children,
}: {
logo: string;
name: string;
description: React.ReactNode;
switchChecked?: boolean;
switchOnClick?: (e: boolean) => void;
children?: React.ReactNode;
}) => {
return (
<div className="mb-4 rounded-md border border-gray-200 p-8">
<div className="flex w-full">
<img src={logo} alt={name} className="mr-3 h-auto w-[42px] rounded-sm" />
<div className="flex flex-col">
<span className="font-semibold leading-none text-black">{name}</span>
<p className="pt-2 text-sm font-normal text-gray-600">{description}</p>
</div>
<div className="ml-auto">
<Switch onCheckedChange={switchOnClick} checked={switchChecked} />
</div>
</div>
<hr className="my-6" />
{children}
</div>
);
};
export const EventAppsTab = ({
hasPaymentIntegration,
currency,
eventType,
hasGiphyIntegration,
}: Pick<
EventTypeSetupInfered,
"eventType" | "hasPaymentIntegration" | "hasGiphyIntegration" | "currency"
>) => {
const formMethods = useFormContext<FormValues>();
const [showGifSelection, setShowGifSelection] = useState(
hasGiphyIntegration && !!eventType.metadata["giphyThankYouPage"]
);
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
const getCurrencySymbol = (locale: string, currency: string) =>
(0)
.toLocaleString(locale, {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.replace(/\d/g, "")
.trim();
const { t } = useLocale();
return (
<div className="before:border-0">
{/* TODO:Strip isnt fully setup yet */}
{hasPaymentIntegration && (
<AppCard
name="Stripe"
switchChecked={requirePayment}
switchOnClick={(e) => {
if (!e) {
formMethods.setValue("price", 0);
setRequirePayment(false);
} else {
setRequirePayment(true);
}
}}
description={
<>
<div className="">
{t("require_payment")} (0.5% +{" "}
<IntlProvider locale="en">
<FormattedNumber value={0.1} style="currency" currency={currency} />
</IntlProvider>{" "}
{t("commission_per_transaction")})
</div>
</>
}
logo="/api/app-store/stripepayment/icon.svg">
<>
{recurringEventDefined ? (
<Alert severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<div className="block items-center sm:flex">
<Controller
defaultValue={eventType.price}
control={formMethods.control}
name="price"
render={({ field }) => (
<TextField
label=""
addOnLeading={<>{getCurrencySymbol("en", currency)}</>}
{...field}
step="0.01"
min="0.5"
type="number"
required
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
placeholder="Price"
onChange={(e) => {
field.onChange(e.target.valueAsNumber * 100);
}}
value={field.value > 0 ? field.value / 100 : undefined}
/>
)}
/>
</div>
)
)}
</>
</AppCard>
)}
{hasGiphyIntegration && (
<AppCard
name="Giphy"
description={t("confirmation_page_gif")}
logo="/api/app-store/giphy/icon.svg"
switchOnClick={(e) => {
if (!e) {
setShowGifSelection(false);
formMethods.setValue("giphyThankYouPage", "");
} else {
setShowGifSelection(true);
}
}}
switchChecked={showGifSelection}>
{showGifSelection && (
<SelectGifInput
defaultValue={eventType.metadata["giphyThankYouPage"] as string}
onChange={(url: string) => {
formMethods.setValue("giphyThankYouPage", url);
}}
/>
)}
</AppCard>
)}
</div>
);
};
@@ -0,0 +1,252 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useState } from "react";
import { useFormContext, Controller, useWatch } from "react-hook-form";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PeriodType } from "@calcom/prisma/client";
import { Select, Switch, Label } from "@calcom/ui/v2";
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType } = props;
const PERIOD_TYPES = [
{
type: "ROLLING" as const,
suffix: t("into_the_future"),
},
{
type: "RANGE" as const,
prefix: t("within_date_range"),
},
{
type: "UNLIMITED" as const,
prefix: t("indefinitely_into_future"),
},
];
const periodType =
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const watchPeriodType = useWatch({
control: formMethods.control,
name: "periodType",
defaultValue: periodType?.type,
});
return (
<div>
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
<div className="w-full">
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
defaultValue={eventType.beforeEventBuffer || 0}
render={({ field: { onChange, value } }) => {
const beforeBufferOptions = [
{
label: t("event_buffer_default"),
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
onChange={(val) => {
if (val) onChange(val.value);
}}
defaultValue={
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
}
options={beforeBufferOptions}
/>
);
}}
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
defaultValue={eventType.afterEventBuffer || 0}
render={({ field: { onChange, value } }) => {
const afterBufferOptions = [
{
label: t("event_buffer_default"),
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
onChange={(val) => {
if (val) onChange(val.value);
}}
defaultValue={
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
}
options={afterBufferOptions}
/>
);
}}
/>
</div>
<div className="w-full">
<Label htmlFor="minimumBookingNotice">{t("after_event")} </Label>
<Controller
name="minimumBookingNotice"
control={formMethods.control}
defaultValue={eventType.minimumBookingNotice || 0}
render={({ field: { onChange, value } }) => {
const minimumBookingOptions = [
{
label: t("event_buffer_default"),
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
onChange={(val) => {
if (val) onChange(val.value);
}}
defaultValue={
minimumBookingOptions.find((option) => option.value === value) || minimumBookingOptions[0]
}
options={minimumBookingOptions}
/>
);
}}
/>
</div>
</div>
<hr className="my-8" />
<div className="">
<fieldset className="block flex-col sm:flex">
<div className="flex space-x-3">
<Controller
name="periodType"
control={formMethods.control}
defaultValue={periodType?.type !== "UNLIMITED" ? "ROLLING" : "UNLIMITED"}
render={({ field: { value } }) => (
<Switch
checked={value !== "UNLIMITED"}
onCheckedChange={(bool) =>
formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")
}
/>
)}
/>
<div className="">
<Label className="text-sm font-semibold leading-none text-black">Limit Future Bookings</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">
Limit how far in the future people can book a time
</p>
</div>
</div>
<div className="mt-4 lg:ml-14">
<Controller
name="periodType"
control={formMethods.control}
defaultValue={periodType?.type}
render={() => (
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"mb-2 flex flex-wrap items-center text-sm",
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item>
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex ">
<input
type="number"
className="block w-16 rounded-md border-gray-300 py-3 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<select
id=""
className="block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
{...formMethods.register("periodCountCalendarDays")}
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
<option value="1">{t("calendar_days")}</option>
<option value="0">{t("business_days")}</option>
</select>
</div>
)}
{period.type === "RANGE" && (
<div className="inline-flex space-x-2 ltr:ml-2 rtl:mr-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
endDate,
});
}}
/>
)}
/>
</div>
)}
{period.suffix ? (
<span className="ltr:ml-2 rtl:mr-2">&nbsp;{period.suffix}</span>
) : null}
</div>
);
})}
</RadioGroup.Root>
)}
/>
</div>
</fieldset>
</div>
</div>
);
};
@@ -0,0 +1,27 @@
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import RecurringEventController from "@components/v2/eventtype/RecurringEventController";
export const EventRecurringTab = ({
eventType,
hasPaymentIntegration,
}: Pick<EventTypeSetupInfered, "eventType" | "hasPaymentIntegration">) => {
const formMethods = useFormContext<FormValues>();
const requirePayment = eventType.price > 0;
const [recurringEventDefined, setRecurringEventDefined] = useState(
eventType.recurringEvent?.count !== undefined
);
return (
<div className="">
<RecurringEventController
paymentEnabled={hasPaymentIntegration && requirePayment}
onRecurringEventDefined={setRecurringEventDefined}
recurringEvent={eventType.recurringEvent}
formMethods={formMethods}
/>
</div>
);
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,149 @@
import { SchedulingType } from "@prisma/client/";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useMemo } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui";
import { Avatar, Button, Label, Select, CheckedTeamSelect } from "@calcom/ui/v2";
interface IMemberToValue {
id: number | null;
name: string | null;
username: string | null;
email: string;
}
const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
value: `${id || ""}`,
label: `${name || ""}`,
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
email,
});
export const EventTeamTab = ({
eventType,
team,
teamMembers,
currentUserMembership,
}: Pick<EventTypeSetupInfered, "eventType" | "teamMembers" | "team" | "currentUserMembership">) => {
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
const schedulingTypeOptions: {
value: SchedulingType;
label: string;
// description: string;
}[] = [
{
value: SchedulingType.COLLECTIVE,
label: t("collective"),
// description: t("collective_description"),
},
{
value: SchedulingType.ROUND_ROBIN,
label: t("round_robin"),
// description: t("round_robin_description"),
},
];
const teamMembersToValues = useMemo(() => {
return teamMembers.map(mapUserToValue);
}, [teamMembers]);
return (
<div>
{team && (
<div className="space-y-3">
<div className="flex flex-col pb-8">
<Label>{t("scheduling_type")}</Label>
<Controller
name="schedulingType"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<Select
options={schedulingTypeOptions}
value={schedulingTypeOptions.find((opt) => opt.value === value)}
className="w-full"
onChange={(val) => {
onChange(val?.value);
}}
/>
)}
/>
</div>
<div className="flex flex-col">
<Label>{t("team")}</Label>
<Controller
name="users"
control={formMethods.control}
defaultValue={eventType.users.map((user) => user.id.toString())}
render={({ field: { onChange, value } }) => (
<CheckedTeamSelect
isDisabled={false}
onChange={(options) => onChange(options.map((user) => user.value))}
value={value
.map(
(userId) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
teamMembers.map(mapUserToValue).find((member) => member.value === userId)!
)
.filter(Boolean)}
controlShouldRenderValue={false}
options={teamMembersToValues}
placeholder={t("add_attendees")}
/>
)}
/>
</div>
</div>
)}
</div>
);
};
const TeamProfileCard = ({
id,
name,
hasPermsToDelete,
email,
username,
}: {
id: number;
name: string | null;
email: string;
username: string | null;
hasPermsToDelete: boolean;
}) => {
const formMethods = useFormContext<FormValues>();
const avatar = `${WEBAPP_URL}/${username}/avatar.png`;
return (
<div className="flex items-center border border-gray-300 px-6 py-4 first:rounded-t-md last:rounded-b-md only:rounded-md">
<Avatar imageSrc={avatar} gravatarFallbackMd5="ajsdkwakhsd1231" size="md" alt="Team Image" />
<div className="flex flex-col pl-3">
<div className="">
<span className="pr-2 text-sm font-semibold leading-none text-black">{name}</span>
</div>
<p className="text-sm font-normal leading-normal text-gray-700">{email}</p>
</div>
{hasPermsToDelete && (
<div className="ml-auto">
<Button
type="button"
size="icon"
color="destructive"
StartIcon={Icon.FiTrash}
onClick={() => {
formMethods.setValue(
"users",
formMethods.getValues("users").filter((user) => user !== id.toString())
);
}}
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,278 @@
import { useRouter } from "next/router";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useEffect, useMemo, useState } from "react";
import { Loader } from "react-feather";
import { UseFormReturn } from "react-hook-form";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Icon } from "@calcom/ui/Icon";
import {
Button,
ButtonGroup,
Tooltip,
showToast,
VerticalTabItemProps,
VerticalTabs,
HorizontalTabs,
Switch,
} from "@calcom/ui/v2";
import { Dialog } from "@calcom/ui/v2/core/Dialog";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/v2/core/Dropdown";
import Shell from "@calcom/ui/v2/core/Shell";
import { ClientSuspense } from "@components/ClientSuspense";
type Props = {
children: React.ReactNode;
eventType: EventTypeSetupInfered["eventType"];
currentUserMembership: EventTypeSetupInfered["currentUserMembership"];
team: EventTypeSetupInfered["team"];
disableBorder?: boolean;
enabledAppsNumber: number;
formMethods: UseFormReturn<FormValues>;
};
function EventTypeSingleLayout({
children,
eventType,
currentUserMembership,
team,
disableBorder,
enabledAppsNumber,
formMethods,
}: Props) {
const utils = trpc.useContext();
const router = useRouter();
const { t } = useLocale();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
const deleteMutation = trpc.useMutation("viewer.eventTypes.delete", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.eventTypes"]);
showToast(t("event_type_deleted_successfully"), "success");
setDeleteDialogOpen(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setDeleteDialogOpen(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
// Define tab navigation here
const EventTypeTabs = useMemo(() => {
const navigation = [
{
name: "event_setup_tab_title",
tabName: "setup",
icon: Icon.FiLink,
info: `${eventType.length} Mins`, // TODO: Get this from props
},
{
name: "availability",
tabName: "availability",
icon: Icon.FiCalendar,
info: `Working Hours`, // TODO: Get this from props
},
{
name: "event_limit_tab_title",
tabName: "limits",
icon: Icon.FiClock,
info: `event_limit_tab_description`,
},
{
name: "event_advanced_tab_title",
tabName: "advanced",
icon: Icon.FiSliders,
info: `event_advanced_tab_description`,
},
{
name: "recurring",
tabName: "recurring",
icon: Icon.FiRotateCcw,
info: `recurring_event_description`,
},
{
name: "apps",
tabName: "apps",
icon: Icon.FiGrid,
info: `${enabledAppsNumber} Active`,
},
// TODO: After V2 workflow page has been completed
// {
// name: "workflows",
// tabName: "workflows",
// icon: Icon.FiCloudLightning,
// info: `X Active`,
// },
] as VerticalTabItemProps[];
// If there is a team put this navigation item within the tabs
if (team)
navigation.splice(2, 0, {
name: "scheduling_type",
tabName: "team",
icon: Icon.FiUsers,
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
});
return navigation;
}, [eventType, enabledAppsNumber, team]);
useEffect(() => {
// Default to the first in the list
if (!router.query.tabName) {
router.push({
query: {
...router.query,
tabName: EventTypeTabs[0].tabName,
},
});
}
}, [EventTypeTabs, router]);
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
return (
<Shell
title={t("event_type_title", { eventTypeTitle: eventType.title })}
heading={eventType.title}
subtitle={eventType.description || ""}
CTA={
<div className="flex items-center justify-end">
<div className="hidden lg:flex lg:items-center">
<p className="pr-2">{t("hide_from_profile")}</p>
<Switch
defaultChecked={formMethods.getValues("hidden")}
onCheckedChange={(e) => {
formMethods.setValue("hidden", e);
}}
/>
</div>
{/* TODO: Figure out why combined isnt working - works in storybook */}
<ButtonGroup combined containerProps={{ className: "px-4 border-gray-300 hidden lg:block" }}>
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
<Tooltip content={t("preview")}>
<Button
color="secondary"
target="_blank"
size="icon"
href={permalink}
rel="noreferrer"
StartIcon={Icon.FiExternalLink}
combined
/>
</Tooltip>
<Button
color="secondary"
size="icon"
StartIcon={Icon.FiLink}
combined
tooltip={t("copy_link")}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Link copied!", "success");
}}
/>
<Button color="secondary" size="icon" StartIcon={Icon.FiCode} combined />
<Button
color="secondary"
size="icon"
StartIcon={Icon.FiTrash}
combined
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}
/>
</ButtonGroup>
<Dropdown>
<DropdownMenuTrigger className="focus:ring-brand-900 block h-[36px] w-auto justify-center rounded-md border border-gray-200 bg-transparent text-gray-700 focus:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-1 lg:hidden">
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button
color="minimal"
StartIcon={Icon.FiExternalLink}
target="_blank"
href={permalink}
rel="noreferrer">
{t("preview")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Button color="minimal" StartIcon={Icon.FiLink}>
{t("copy_link")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Button
color="minimal"
StartIcon={Icon.FiTrash}
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}>
{t("delete")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
<div className="border-l-2 border-gray-300" />
<Button className="ml-4 lg:ml-0" type="submit" form="event-type-form">
{t("save")}
</Button>
</div>
}>
<ClientSuspense fallback={<Loader />}>
<div className="flex flex-col xl:flex-row xl:space-x-8">
<div className="hidden xl:block">
<VerticalTabs tabs={EventTypeTabs} />
</div>
<div className="p-2 md:p-0 xl:hidden">
<HorizontalTabs tabs={EventTypeTabs} />
</div>
<div className="w-full ltr:mr-2 rtl:ml-2">
<div
className={classNames(
"mt-4 rounded-md border-neutral-200 bg-white sm:mx-0 xl:mt-0",
disableBorder ? "border-0 xl:-mt-4 " : "p-2 sm:p-10 md:border md:p-6"
)}>
{children}
</div>
</div>
</div>
</ClientSuspense>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_event_type")}
confirmBtnText={t("confirm_delete_event_type")}
loadingText={t("confirm_delete_event_type")}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: eventType.id });
}}>
{t("delete_event_type_description") as string}
</ConfirmationDialogContent>
</Dialog>
</Shell>
);
}
export { EventTypeSingleLayout };
@@ -0,0 +1,134 @@
import type { FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Alert } from "@calcom/ui/Alert";
import { Label, Switch, Select } from "@calcom/ui/v2";
type RecurringEventControllerProps = {
recurringEvent: RecurringEvent | null;
formMethods: UseFormReturn<FormValues>;
paymentEnabled: boolean;
onRecurringEventDefined: (value: boolean) => void;
};
export default function RecurringEventController({
recurringEvent,
formMethods,
paymentEnabled,
onRecurringEventDefined,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
/* Just yearly-0, monthly-1 and weekly-2 */
const recurringEventFreqOptions = Object.entries(Frequency)
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
.map(([key, value]) => ({
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
value: value.toString(),
}));
return (
<div className="block items-start sm:flex">
<div className={!paymentEnabled ? "w-full" : ""}>
{paymentEnabled ? (
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
) : (
<>
<div className="flex space-x-3 ">
<Switch
name="requireConfirmation"
checked={recurringEventState !== null}
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("recurringEvent", null);
setRecurringEventState(null);
} else {
const newVal = recurringEvent || {
interval: 1,
count: 12,
freq: Frequency.WEEKLY,
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}
}}
/>
<div className="flex flex-col">
<Label className="text-sm font-semibold leading-none text-black">
{t("recurring_event")}
</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">
{t("recurring_event_description")}
</p>
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
<div className="flex items-center">
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 block min-w-0 rounded-md text-sm"
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="mr-2 text-neutral-900">{t("max")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
defaultValue={recurringEventState.count}
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="mr-2 text-neutral-900">
{t("occurrence", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import classNames from "classnames";
import React from "react";
import { LOGO } from "@calcom/lib/constants";
import Loader from "@components/Loader";
import { HeadSeo } from "@components/seo/head-seo";
interface Props {
title: string;
description: string;
footerText?: React.ReactNode | string;
showLogo?: boolean;
heading?: string;
loading?: boolean;
}
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
return (
<div className="flex min-h-screen flex-col justify-center bg-gray-100 py-12 sm:px-6 lg:px-8">
<HeadSeo title={props.title} description={props.description} />
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
{props.showLogo && (
// eslint-disable-next-line @next/next/no-img-element
<img className="mx-auto h-6" src={LOGO} alt="Cal.com Logo" />
)}
{props.heading && (
<h2 className="font-cal mt-6 text-center text-3xl text-neutral-900">{props.heading}</h2>
)}
</div>
{props.loading && (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
<Loader />
</div>
)}
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="border-1 mx-2 rounded-md border-gray-200 bg-white px-4 py-8 sm:px-10">
{props.children}
</div>
<div className="mt-4 text-center text-sm text-neutral-600">{props.footerText}</div>
</div>
</div>
);
}
+11 -2
View File
@@ -1,8 +1,17 @@
import { DefaultSeoProps } from "next-seo";
import { DefaultSeoProps, NextSeoProps } from "next-seo";
import { SEO_IMG_DEFAULT, SEO_IMG_OGIMG } from "@calcom/lib/constants";
import { HeadSeoProps } from "@components/seo/head-seo";
export type HeadSeoProps = {
title: string;
description: string;
siteName?: string;
name?: string;
url?: string;
username?: string;
canonical?: string;
nextSeoProps?: NextSeoProps;
};
const seoImages = {
default: SEO_IMG_DEFAULT,
+1 -1
View File
@@ -5,7 +5,7 @@ import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { isIpInBanlist } from "@calcom/lib/getIP";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
const V2_WHITELIST = ["/settings/admin"];
const V2_WHITELIST = ["/settings/admin", "/availability", "/bookings", "/event-types"];
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;
+1
View File
@@ -42,6 +42,7 @@
"@calcom/tsconfig": "*",
"@calcom/ui": "*",
"@daily-co/daily-js": "^0.26.0",
"@formkit/auto-animate": "^1.0.0-beta.1",
"@glidejs/glide": "^3.5.2",
"@heroicons/react": "^1.0.6",
"@hookform/error-message": "^2.0.0",
+16 -9
View File
@@ -37,7 +37,7 @@ import { AvatarSSR } from "@components/ui/AvatarSSR";
import { ssrInit } from "@server/lib/ssr";
const EventTypeDescription = dynamic(() => import("@components/eventtype/EventTypeDescription"));
const EventTypeDescription = dynamic(() => import("@calcom/ui/v2/modules/event-types/EventTypeDescription"));
const HeadSeo = dynamic(() => import("@components/seo/head-seo"));
const CryptoSection = dynamic<CryptoSectionProps>(
() => import("@calcom/features/ee/web3/components/CryptoSection")
@@ -64,16 +64,16 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
</div>
</div>
) : (
<ul className="space-y-3">
<ul>
{eventTypes.map((type, index) => (
<li
key={index}
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-gray-800 dark:hover:border-neutral-600">
className="hover:border-brand dark:bg-darkgray-100 group relative rounded-sm border border-neutral-200 bg-white dark:border-neutral-700 dark:hover:border-neutral-600">
<Icon.FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
<Link href={getUsernameSlugLink({ users: props.users, slug: type.slug })}>
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
<div className="flex-shrink">
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<p className="dark:text-darkgray-700 text-sm font-semibold text-neutral-900">{type.title}</p>
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1 self-center">
@@ -126,12 +126,17 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
/>
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<div className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "max-w-3xl" : "")}>
<div
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed ? "max-w-3xl" : "",
"dark:bg-darkgray-50"
)}>
<main
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed
? " border-bookinglightest rounded-md border bg-white dark:bg-neutral-900 sm:dark:border-gray-600"
? " border-bookinglightest dark:bg-darkgray-50 rounded-md border bg-white sm:dark:border-gray-600"
: "",
"max-w-3xl py-24 px-4"
)}>
@@ -144,10 +149,12 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
)}
</h1>
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
<p className="dark:text-darkgray-600 text-s text-neutral-500">{user.bio}</p>
</div>
)}
<div className="space-y-3" data-testid="event-types">
<div
className="rounded-md border border-neutral-200 dark:border-neutral-700 dark:hover:border-neutral-600"
data-testid="event-types">
{user.away ? (
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="p-8 text-center text-gray-400 dark:text-white">
@@ -164,7 +171,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
<div
key={type.id}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
className="hover:border-brand group relative rounded border border-neutral-200 bg-white hover:bg-white dark:border-neutral-700 dark:bg-gray-800 dark:hover:border-neutral-600">
className="hover:border-brand dark:bg-darkgray-100 group relative border-b border-neutral-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-white dark:border-neutral-700 dark:hover:border-neutral-600">
<Icon.FiArrowRight className="absolute right-4 top-4 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
<Link
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Type(props: AvailabilityPageProps) {
</main>
</div>
) : props.isDynamic && !props.profile.allowDynamicBooking ? (
<div className="h-screen dark:bg-neutral-900">
<div className="dark:bg-darkgray-50 h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
+2 -2
View File
@@ -66,7 +66,7 @@ class MyDocument extends Document<Props> {
crossOrigin="anonymous"
/>
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
Persist the embed mode in sessionStorage because query param might get lost during browsing.
*/}
<script
@@ -77,7 +77,7 @@ class MyDocument extends Document<Props> {
</Head>
{/* Keep the embed hidden till parent initializes and gives it the appropriate styles */}
<body className="bg-gray-100 dark:bg-neutral-900">
<body className="dark:bg-darkgray-50 bg-gray-100">
<Main />
<NextScript />
{/* In case of Embed we want background to be transparent so that it merges into the website seamlessly. Also, we keep the body hidden here and embed logic would take care of showing the body when it's ready */}
+2 -2
View File
@@ -9,8 +9,8 @@ import { isPasswordValid } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import WizardForm from "@calcom/ui/v2/WizardForm";
import { TextField, PasswordField, EmailField } from "@calcom/ui/v2/form/fields";
import { TextField, EmailField, PasswordField } from "@calcom/ui/v2";
import WizardForm from "@calcom/ui/v2/core/WizardForm";
const StepDone = () => {
const { t } = useLocale();
+9 -9
View File
@@ -11,15 +11,15 @@ import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
import useTheme from "@calcom/lib/hooks/useTheme";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/v2/core";
import EventTypeDescription from "@calcom/ui/v2/modules/event-types/EventTypeDescription";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import { HeadSeo } from "@components/seo/head-seo";
import Team from "@components/team/screens/Team";
import Avatar from "@components/ui/Avatar";
@@ -43,12 +43,12 @@ function TeamPage({ team }: TeamPageProps) {
}, [telemetry, router.asPath]);
const eventTypes = (
<ul className="space-y-3">
<ul className="">
{team.eventTypes.map((type) => (
<li
key={type.id}
className={classNames(
"hover:border-brand group relative rounded-sm border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600",
"hover:border-brand dark:bg-darkgray-200 group relative border-b border-neutral-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-white dark:border-neutral-700 dark:hover:border-neutral-600",
isEmbed ? "" : "bg-white"
)}>
<Icon.FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
@@ -83,7 +83,7 @@ function TeamPage({ team }: TeamPageProps) {
return (
<div>
<HeadSeo title={teamName} description={teamName} />
<div className="rounded-md bg-gray-100 px-4 pt-24 pb-12 dark:bg-gray-900">
<div className="dark:bg-darkgray-50 h-screen rounded-md bg-gray-100 px-4 pt-24 pb-12">
<div className="max-w-96 mx-auto mb-8 text-center">
<Avatar
alt={teamName}
@@ -95,14 +95,14 @@ function TeamPage({ team }: TeamPageProps) {
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl">
{eventTypes}
<div className="mx-auto max-w-3xl ">
<div className="dark:border-darkgray-300 rounded-md border">{eventTypes}</div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200 dark:border-gray-700" />
<div className="dark:border-darkgray-300 w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-100 px-2 text-sm text-gray-500 dark:bg-gray-900 dark:text-white">
<span className="dark:bg-darkgray-50 bg-gray-100 px-2 text-sm text-gray-500 dark:text-white">
{t("or")}
</span>
</div>
+243
View File
@@ -0,0 +1,243 @@
import classNames from "classnames";
import { GetServerSidePropsContext } from "next";
import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { Alert } from "@calcom/ui/Alert";
import { Icon } from "@calcom/ui/Icon";
import { Button, EmailField, Form, PasswordField } from "@calcom/ui/v2";
import SAMLLogin from "@calcom/ui/v2/modules/auth/SAMLLogin";
import { ErrorCode, getSession } from "@lib/auth";
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@lib/saml";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import AddToHomescreen from "@components/AddToHomescreen";
import TwoFactor from "@components/auth/TwoFactor";
import AuthContainer from "@components/ui/AuthContainer";
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
import { ssrInit } from "@server/lib/ssr";
interface LoginValues {
email: string;
password: string;
totpCode: string;
csrfToken: string;
}
export default function Login({
csrfToken,
isGoogleLoginEnabled,
isSAMLLoginEnabled,
hostedCal,
samlTenantID,
samlProductID,
}: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const router = useRouter();
const form = useForm<LoginValues>();
const { formState } = form;
const { isSubmitting } = formState;
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = {
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
[ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
const telemetry = useTelemetry();
let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "";
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
// If not absolute URL, make it absolute
if (!/^https?:\/\//.test(callbackUrl)) {
callbackUrl = `${WEBAPP_URL}/${callbackUrl}`;
}
const safeCallbackUrl = getSafeRedirectUrl(callbackUrl);
callbackUrl = safeCallbackUrl || "";
const LoginFooter = (
<span className="text-gray-600">
{t("dont_have_an_account")}{" "}
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("create_an_account")}
</a>
</span>
);
const TwoFactorFooter = (
<Button
onClick={() => {
setTwoFactorRequired(false);
form.setValue("totpCode", "");
}}
StartIcon={Icon.FiArrowLeft}
color="minimal">
{t("go_back")}
</Button>
);
return (
<>
<AuthContainer
title={t("login")}
description={t("login")}
showLogo
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
footerText={twoFactorRequired ? TwoFactorFooter : LoginFooter}>
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setErrorMessage(null);
telemetry.event(telemetryEventTypes.login, collectPageParameters());
const res = await signIn<"credentials">("credentials", {
...values,
callbackUrl,
redirect: false,
});
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
// we're logged in! let's do a hard refresh to the desired url
else if (!res.error) router.push(callbackUrl);
// reveal two factor input if required
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
// fallback if error not found
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
}}
data-testid="login-form">
<div>
<input
defaultValue={csrfToken || undefined}
type="hidden"
hidden
{...form.register("csrfToken")}
/>
</div>
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
<EmailField
id="email"
label={t("email_address")}
defaultValue={router.query.email as string}
placeholder="john.doe@example.com"
required
{...form.register("email")}
/>
<div className="relative">
<div className="absolute right-0 -top-[2px]">
<Link href="/auth/forgot-password">
<a tabIndex={-1} className="text-sm font-medium text-gray-600">
{t("forgot_password")}
</a>
</Link>
</div>
<PasswordField
id="password"
type="password"
autoComplete="current-password"
required
{...form.register("password")}
/>
</div>
</div>
{twoFactorRequired && <TwoFactor />}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<div className="pb-8">
<Button type="submit" color="primary" disabled={isSubmitting} className="w-full">
{twoFactorRequired ? t("submit") : t("sign_in")}
</Button>
</div>
</Form>
<hr />
{!twoFactorRequired && (
<>
{isGoogleLoginEnabled && (
<div className="mt-8">
<Button
color="secondary"
className="w-full"
data-testid="google"
onClick={async (e) => {
e.preventDefault();
// track Google logins. Without personal data/payload
telemetry.event(telemetryEventTypes.googleLogin, collectPageParameters());
await signIn("google");
}}>
{t("signin_with_google")}
</Button>
</div>
)}
{isSAMLLoginEnabled && (
<SAMLLogin
email={form.getValues("email")}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
hostedCal={hostedCal}
setErrorMessage={setErrorMessage}
/>
)}
</>
)}
</AuthContainer>
<AddToHomescreen />
</>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
const session = await getSession({ req });
const ssr = await ssrInit(context);
if (session) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
const userCount = await prisma.user.count();
if (userCount === 0) {
// Proceed to new onboarding to create first admin user
return {
redirect: {
destination: "/auth/setup",
permanent: false,
},
};
}
return {
props: {
csrfToken: await getCsrfToken(context),
trpcState: ssr.dehydrate(),
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
isSAMLLoginEnabled,
hostedCal,
samlTenantID,
samlProductID,
},
};
}
@@ -0,0 +1,188 @@
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { DEFAULT_SCHEDULE, availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { BadgeCheckIcon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
import { Button, Switch, Schedule, Form, TextField, showToast } from "@calcom/ui/v2";
import { QueryCell } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import EditableHeading from "@components/ui/EditableHeading";
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const form = useForm({
defaultValues: {
schedule: props.availability || DEFAULT_SCHEDULE,
isDefault: !!props.isDefault,
timeZone: props.timeZone,
},
});
const updateMutation = trpc.useMutation("viewer.availability.schedule.update", {
onSuccess: async ({ schedule }) => {
await utils.invalidateQueries(["viewer.availability.schedule"]);
await router.push("/availability");
showToast(
t("availability_updated_successfully", {
scheduleName: schedule.name,
}),
"success"
);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
return (
<Form
form={form}
handleSubmit={async (values) => {
updateMutation.mutate({
scheduleId: parseInt(router.query.schedule as string, 10),
name: props.schedule.name,
...values,
});
}}
className="-mx-5 flex flex-col sm:mx-0 xl:flex-row">
<div className="flex-1">
<div className="divide-y rounded-md border border-gray-200 bg-white px-4 py-5 sm:p-6">
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
<Schedule name="schedule" />
</div>
<div className="space-x-2 pt-4 text-right sm:pt-2">
<Button color="secondary" href="/availability" tabIndex={-1}>
{t("cancel")}
</Button>
<Button>{t("save")}</Button>
</div>
</div>
<div className="min-w-40 col-span-3 ml-2 space-y-2 lg:col-span-1">
{props.isDefault ? (
<span className="flex items-center">
<BadgeCheckIcon className="mr-1 h-4 w-4" /> {t("default")}
</span>
) : (
<Controller
name="isDefault"
render={({ field: { onChange, value } }) => (
<Switch label={t("set_to_default")} onCheckedChange={onChange} checked={value} />
)}
/>
)}
<div className="xl:max-w-80 w-full">
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
{t("timezone")}
</label>
<div className="mt-1">
<Controller
name="timeZone"
render={({ field: { onChange, value } }) => (
<TimezoneSelect
value={value}
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
)}
/>
</div>
</div>
<div className="pt-6">
<TextField
name="aciveOn"
label={t("active_on")}
disabled
value={t("nr_event_type_other", { count: props.schedule.eventType?.length })}
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 text-sm"
/>
</div>
<div className="mt-6 rounded-md border border-gray-200 px-4 py-5 sm:p-6">
<h3 className="text-base font-medium leading-6 text-gray-900">
{t("something_doesnt_look_right")}
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Button href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Button>
</div>
</div>
</div>
</div>
</Form>
);
}
const querySchema = z.object({
schedule: stringOrNumber,
});
export default function Availability() {
const router = useRouter();
const { i18n } = useLocale();
const { schedule: scheduleId } = router.isReady ? querySchema.parse(router.query) : { schedule: -1 };
const query = trpc.useQuery(["viewer.availability.schedule", { scheduleId }], { enabled: router.isReady });
const [name, setName] = useState<string>();
return (
<div>
<QueryCell
query={query}
success={({ data }) => {
return (
<Shell
heading={<EditableHeading title={name || data.schedule.name} onChange={setName} />}
subtitle={data.schedule.availability.map((availability) => (
<span key={availability.id}>
{availabilityAsString(availability, i18n.language)}
<br />
</span>
))}>
<AvailabilityForm
{...{ ...data, schedule: { ...data.schedule, name: name || data.schedule.name } }}
/>
</Shell>
);
}}
/>
</div>
);
}
export const getStaticProps: GetStaticProps = (ctx) => {
const params = querySchema.safeParse(ctx.params);
if (!params.success) return { notFound: true };
return {
props: {
schedule: params.data.schedule,
},
revalidate: 10, // seconds
};
};
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [],
fallback: "blocking",
};
};
+72
View File
@@ -0,0 +1,72 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { NewScheduleButton, EmptyScreen, showToast } from "@calcom/ui/v2";
import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import { ScheduleListItem } from "@components/availability/ScheduleListItem";
import SkeletonLoader from "@components/availability/SkeletonLoader";
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.availability.list"]);
showToast(t("schedule_deleted_successfully"), "success");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
return (
<>
{schedules.length === 0 ? (
<div className="flex justify-center">
<EmptyScreen
Icon={Icon.FiClock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
buttonRaw={<NewScheduleButton />}
/>
</div>
) : (
<div className="-mx-4 mb-16 overflow-hidden rounded-md border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="schedules">
{schedules.map((schedule) => (
<ScheduleListItem
key={schedule.id}
schedule={schedule}
deleteFunction={deleteMutation.mutate}
isDeleting={deleteMutation.isLoading}
/>
))}
</ul>
</div>
)}
</>
);
}
const WithQuery = withQuery(["viewer.availability.list"]);
export default function AvailabilityPage() {
const { t } = useLocale();
return (
<div>
<Shell
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={<NewScheduleButton />}
customLoader={<SkeletonLoader />}>
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
</Shell>
</div>
);
}
@@ -0,0 +1,117 @@
import { useEffect, useState } from "react";
import dayjs, { Dayjs } from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Shell from "@calcom/ui/Shell";
import { QueryCell } from "@lib/QueryCell";
import Loader from "@components/Loader";
type User = inferQueryOutput<"viewer.me">;
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const [loading, setLoading] = useState(true);
const [availability, setAvailability] = useState<{ end: string; start: string; title?: string }[]>([]);
const [selectedDate, setSelectedDate] = useState(dayjs());
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? "0" + h : h;
const ms = m < 10 ? "0" + m : m;
return `${hs}:${ms}`;
}
useEffect(() => {
const fetchAvailability = (date: Dayjs) => {
const dateFrom = date.startOf("day").utc().format();
const dateTo = date.endOf("day").utc().format();
setLoading(true);
fetch(`/api/availability/${user.username}?dateFrom=${dateFrom}&dateTo=${dateTo}`)
.then((res) => {
return res.json();
})
.then((availableIntervals) => {
setAvailability(availableIntervals.busy);
})
.catch((e) => {
console.error(e);
})
.finally(() => {
setLoading(false);
});
};
fetchAvailability(selectedDate);
}, [user.username, selectedDate]);
return (
<div className="max-w-xl overflow-hidden rounded-md bg-white shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none p-0"
defaultValue={selectedDate.format("YYYY-MM-DD")}
onChange={(e) => {
if (e.target.value) setSelectedDate(dayjs(e.target.value));
}}
/>
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{loading ? (
<Loader />
) : availability.length > 0 ? (
availability.map((slot) => (
<div key={slot.start} className="overflow-hidden rounded-md bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="font-medium text-neutral-800" title={slot.start}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="font-medium text-neutral-800" title={slot.end}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
</div>
</div>
))
) : (
<div className="overflow-hidden rounded-md bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
)}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
};
export default function Troubleshoot() {
const query = trpc.useQuery(["viewer.me"]);
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} subtitle={t("troubleshoot_description")}>
<QueryCell query={query} success={({ data }) => <AvailabilityView user={data} />} />
</Shell>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { Fragment } from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/Alert";
import { Icon } from "@calcom/ui/Icon";
import { Button, EmptyScreen } from "@calcom/ui/v2";
import BookingLayout from "@calcom/ui/v2/core/layouts/BookingLayout";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
const validStatuses = ["upcoming", "recurring", "past", "cancelled"] as const;
const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: "upcoming_bookings",
recurring: "recurring_bookings",
past: "past_bookings",
cancelled: "cancelled_bookings",
};
const querySchema = z.object({
status: z.enum(validStatuses),
});
export default function Bookings() {
const router = useRouter();
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
const { t } = useLocale();
const query = trpc.useInfiniteQuery(["viewer.bookings", { status, limit: 10 }], {
// first render has status `undefined`
enabled: router.isReady,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {
query.fetchNextPage();
}
});
const isEmpty = !query.data?.pages[0]?.bookings.length;
// Get all recurring events of the series with the same recurringEventId
const defineRecurrentBookings = (
booking: BookingOutput,
groupedBookings: Record<string, BookingOutput[]>
) => {
let recurringBookings = undefined;
if (booking.recurringEventId !== null) {
recurringBookings = groupedBookings[booking.recurringEventId];
}
return { recurringBookings };
};
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status === "cancelled") {
if (!booking.recurringEventId) {
return true;
}
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
shownBookings[booking.recurringEventId].push(booking);
return false;
}
shownBookings[booking.recurringEventId] = [booking];
}
return true;
};
return (
<BookingLayout
heading={t("bookings")}
subtitle={t("bookings_description")}
customLoader={<SkeletonLoader />}>
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<div className="flex w-full flex-1 flex-col">
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "loading" || query.status === "idle") && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<div className="pt-4 xl:mx-6 xl:pt-0">
<p className="pb-3 text-xs font-medium leading-4 text-gray-500">Today</p>
<div className="overflow-hidden rounded-md border border-gray-200">
<table className="w-full">
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
listingStatus={status}
{...defineRecurrentBookings(booking, shownBookings)}
{...booking}
/>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center">
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
</div>
</BookingLayout>
);
}
export const getStaticProps: GetStaticProps = (ctx) => {
const params = querySchema.safeParse(ctx.params);
if (!params.success) return { notFound: true };
return {
props: {
status: params.data.status,
},
};
};
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: validStatuses.map((status) => ({
params: { status },
locale: "en",
})),
fallback: "blocking",
};
};
@@ -0,0 +1,464 @@
import { EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { parseRecurringEvent } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Form } from "@calcom/ui/form/fields";
import { Button, showToast } from "@calcom/ui/v2";
import { asStringOrThrow } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import { LocationObject, LocationType } from "@lib/location";
import { inferSSRProps } from "@lib/types/inferSSRProps";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings
import { EventAdvancedTab } from "@components/v2/eventtype/EventAdvancedTab";
import { EventAppsTab } from "@components/v2/eventtype/EventAppsTab";
import { EventLimitsTab } from "@components/v2/eventtype/EventLimitsTab";
import { EventRecurringTab } from "@components/v2/eventtype/EventRecurringTab";
import { EventSetupTab } from "@components/v2/eventtype/EventSetupTab";
import { EventTeamTab } from "@components/v2/eventtype/EventTeamTab";
import { EventTypeSingleLayout } from "@components/v2/eventtype/EventTypeSingleLayout";
import { getTranslation } from "@server/lib/i18n";
export type FormValues = {
title: string;
eventTitle: string;
smartContractAddress: string;
eventName: string;
slug: string;
length: number;
description: string;
disableGuests: boolean;
requiresConfirmation: boolean;
recurringEvent: RecurringEvent | null;
schedulingType: SchedulingType | null;
price: number;
currency: string;
hidden: boolean;
hideCalendarNotes: boolean;
hashedLink: string | undefined;
locations: {
type: LocationType;
address?: string;
link?: string;
hostPhoneNumber?: string;
displayLocationPublicly?: boolean;
}[];
customInputs: EventTypeCustomInput[];
users: string[];
schedule: number;
periodType: PeriodType;
periodDays: number;
periodCountCalendarDays: "1" | "0";
periodDates: { startDate: Date; endDate: Date };
seatsPerTimeSlot: number | null;
minimumBookingNotice: number;
beforeBufferTime: number;
afterBufferTime: number;
slotInterval: number | null;
destinationCalendar: {
integration: string;
externalId: string;
};
successRedirectUrl: string;
giphyThankYouPage: string;
};
const querySchema = z.object({
tabName: z
.enum(["setup", "availability", "apps", "limits", "recurring", "team", "advanced"])
.optional()
.default("setup"),
});
export type EventTypeSetupInfered = inferSSRProps<typeof getServerSideProps>;
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
const { eventType, locationOptions, team, teamMembers } = props;
const router = useRouter();
const { tabName } = querySchema.parse(router.query);
const updateMutation = trpc.useMutation("viewer.eventTypes.update", {
onSuccess: async ({ eventType }) => {
showToast(
t("event_type_updated_successfully", {
eventTypeTitle: eventType.title,
}),
"success"
);
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: You are not able to update this event`;
}
if (err.data?.code === "PARSE_ERROR") {
message = `${err.data.code}: ${err.message}`;
}
if (message) {
showToast(message, "error");
}
},
});
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const formMethods = useForm<FormValues>({
defaultValues: {
title: eventType.title,
locations: eventType.locations || [],
recurringEvent: eventType.recurringEvent || null,
description: eventType.description ?? undefined,
schedule: eventType.schedule?.id,
hidden: eventType.hidden,
periodDates: {
startDate: periodDates.startDate,
endDate: periodDates.endDate,
},
schedulingType: eventType.schedulingType,
},
});
const tabMap = {
setup: (
<EventSetupTab
eventType={eventType}
locationOptions={locationOptions}
team={team}
teamMembers={teamMembers}
/>
),
/* TODO: Actually make this tab */
availability: null,
team: (
<EventTeamTab
eventType={eventType}
teamMembers={teamMembers}
team={team}
currentUserMembership={props.currentUserMembership}
/>
),
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
recurring: (
<EventRecurringTab eventType={eventType} hasPaymentIntegration={props.hasPaymentIntegration} />
),
apps: (
<EventAppsTab
currency={props.currency}
eventType={eventType}
hasPaymentIntegration={props.hasPaymentIntegration}
hasGiphyIntegration={props.hasGiphyIntegration}
/>
),
} as const;
return (
<EventTypeSingleLayout
enabledAppsNumber={[props.hasGiphyIntegration, props.hasPaymentIntegration].filter(Boolean).length}
eventType={eventType}
team={team}
formMethods={formMethods}
disableBorder={tabName === "apps"}
currentUserMembership={props.currentUserMembership}>
<Form
form={formMethods}
id="event-type-form"
handleSubmit={async (values) => {
const {
periodDates,
periodCountCalendarDays,
smartContractAddress,
giphyThankYouPage,
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
recurringEvent,
locations,
...input
} = values;
updateMutation.mutate({
...input,
locations,
recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
id: eventType.id,
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
seatsPerTimeSlot,
metadata: {
...(smartContractAddress ? { smartContractAddress } : {}),
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
},
});
}}
className="space-y-6">
{tabMap[tabName]}
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button href="/event-types" color="secondary" tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" data-testid="update-eventtype" disabled={updateMutation.isLoading}>
{t("update")}
</Button>
</div>
</Form>
</EventTypeSingleLayout>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, query } = context;
const session = await getSession({ req });
const typeParam = parseInt(asStringOrThrow(query.type));
if (Number.isNaN(typeParam)) {
return {
notFound: true,
};
}
if (!session?.user?.id) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
username: true,
id: true,
avatar: true,
email: true,
plan: true,
locale: true,
});
const rawEventType = await prisma.eventType.findFirst({
where: {
AND: [
{
OR: [
{
users: {
some: {
id: session.user.id,
},
},
},
{
team: {
members: {
some: {
userId: session.user.id,
},
},
},
},
{
userId: session.user.id,
},
],
},
{
id: typeParam,
},
],
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
eventName: true,
availability: true,
customInputs: true,
timeZone: true,
periodType: true,
metadata: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
recurringEvent: true,
hideCalendarNotes: true,
disableGuests: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true,
hashedLink: true,
successRedirectUrl: true,
team: {
select: {
id: true,
slug: true,
members: {
where: {
accepted: true,
},
select: {
role: true,
user: {
select: userSelect,
},
},
},
},
},
users: {
select: userSelect,
},
schedulingType: true,
schedule: {
select: {
id: true,
},
},
userId: true,
price: true,
currency: true,
destinationCalendar: true,
seatsPerTimeSlot: true,
},
});
if (!rawEventType) {
return {
notFound: true,
};
}
const credentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
},
select: {
id: true,
type: true,
key: true,
userId: true,
appId: true,
},
});
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
const { locations, metadata, ...restEventType } = rawEventType;
const eventType = {
...restEventType,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
locations: locations as unknown as LocationObject[],
metadata: (metadata || {}) as JSONObject,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
: false,
};
const hasGiphyIntegration = !!credentials.find((credential) => credential.type === "giphy_other");
// backwards compat
if (eventType.users.length === 0 && !eventType.team) {
const fallbackUser = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: userSelect,
});
if (!fallbackUser) throw Error("The event type doesn't have user and no fallback user was found");
eventType.users.push(fallbackUser);
}
const currentUser = eventType.users.find((u) => u.id === session.user.id);
const t = await getTranslation(currentUser?.locale ?? "en", "common");
const integrations = getApps(credentials);
const locationOptions = getLocationOptions(integrations, t);
const hasPaymentIntegration = !!credentials.find((credential) => credential.type === "stripe_payment");
const currency =
(credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData)
?.default_currency || "usd";
type Availability = typeof eventType["availability"];
const getAvailability = (availability: Availability) =>
availability?.length
? availability.map((schedule) => ({
...schedule,
startTime: new Date(new Date().toDateString() + " " + schedule.startTime.toTimeString()).valueOf(),
endTime: new Date(new Date().toDateString() + " " + schedule.endTime.toTimeString()).valueOf(),
}))
: null;
const availability = getAvailability(eventType.availability) || [];
availability.sort((a, b) => a.startTime - b.startTime);
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
availability,
});
const teamMembers = eventTypeObject.team
? eventTypeObject.team.members.map((member) => {
const user = member.user;
user.avatar = `${CAL_URL}/${user.username}/avatar.png`;
return user;
})
: [];
// Find the current users memebership so we can check role to enable/disable deletion.
// Sets to null if no membership is found - this must mean we are in a none team event type
const currentUserMembership =
eventTypeObject.team?.members.find((el) => el.user.id === session.user.id) ?? null;
return {
props: {
session,
eventType: eventTypeObject,
locationOptions,
availability,
team: eventTypeObject.team || null,
teamMembers,
hasPaymentIntegration,
hasGiphyIntegration,
currency,
currentUserMembership,
},
};
};
export default EventTypePage;
+640
View File
@@ -0,0 +1,640 @@
import { UserPlan } from "@prisma/client";
import { Trans } from "next-i18next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, useEffect, useState } from "react";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Alert } from "@calcom/ui/Alert";
import Badge from "@calcom/ui/Badge";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog } from "@calcom/ui/Dialog";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Button, Tooltip, Switch, showToast } from "@calcom/ui/v2";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/v2/core/Dropdown";
import Shell from "@calcom/ui/v2/core/Shell";
import CreateEventTypeButton from "@calcom/ui/v2/modules/event-types/CreateEventType";
import { withQuery } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import { TRPCClientError } from "@trpc/react";
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
type ConnectedCalendars = inferQueryOutput<"viewer.connectedCalendars">["connectedCalendars"][number];
interface EventTypeListHeadingProps {
profile: EventTypeGroupProfile;
membershipCount: number;
}
type EventTypeGroup = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"][number];
type EventType = EventTypeGroup["eventTypes"][number];
interface EventTypeListProps {
group: EventTypeGroup;
groupIndex: number;
readOnly: boolean;
types: EventType[];
}
const Item = ({
type,
group,
readOnly,
connectedCalendars,
}: {
type: EventType;
group: EventTypeGroup;
readOnly: boolean;
connectedCalendars: ConnectedCalendars[] | undefined;
}) => {
const { t } = useLocale();
const isCalendarConnectedMissing = connectedCalendars?.length && !type.team && !type.destinationCalendar;
return (
<Link href={`/event-types/${type.id}`}>
<a
className={classNames(
"flex-grow truncate text-sm ",
type.$disabled && "pointer-events-none cursor-not-allowed opacity-30"
)}
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
<div>
<span
className="truncate font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
{type.title}
</span>
<small
className="hidden font-normal leading-4 text-gray-600 sm:inline"
data-testid={"event-type-slug-" + type.id}>{`/${group.profile.slug}/${type.slug}`}</small>
{type.hidden && (
<span className="rtl:mr-2inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ltr:ml-2">
{t("hidden") as string}
</span>
)}
{!!isCalendarConnectedMissing && (
<span className="rtl:mr-2inline items-center rounded-sm bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 ltr:ml-2">
{t("missing_connected_calendar") as string}
</span>
)}
{readOnly && (
<span className="rtl:mr-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2">
{t("readonly") as string}
</span>
)}
</div>
<EventTypeDescription eventType={type} />
</a>
</Link>
);
};
const MemoizedItem = React.memo(Item);
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const utils = trpc.useContext();
const mutation = trpc.useMutation("viewer.eventTypeOrder", {
onError: async (err) => {
console.error(err.message);
await utils.cancelQuery(["viewer.eventTypes"]);
await utils.invalidateQueries(["viewer.eventTypes"]);
},
onSettled: async () => {
await utils.invalidateQueries(["viewer.eventTypes"]);
},
});
const setHiddenMutation = trpc.useMutation("viewer.eventTypes.update", {
onError: async (err) => {
console.error(err.message);
await utils.cancelQuery(["viewer.eventTypes"]);
await utils.invalidateQueries(["viewer.eventTypes"]);
},
onSettled: async () => {
await utils.invalidateQueries(["viewer.eventTypes"]);
},
});
function moveEventType(index: number, increment: 1 | -1) {
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
utils.cancelQuery(["viewer.eventTypes"]);
utils.setQueryData(["viewer.eventTypes"], (data) => {
// tRPC is very strict with the return signature...
if (!data)
return {
eventTypeGroups: [],
profiles: [],
viewer: { canAddEvents: false, plan: UserPlan.FREE },
};
return {
...data,
eventTypesGroups: [
...data.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...data.eventTypeGroups.slice(groupIndex + 1),
],
};
});
mutation.mutate({
ids: newList.map((type) => type.id),
});
}
async function deleteEventTypeHandler(id: number) {
const payload = { id };
deleteMutation.mutate(payload);
}
// inject selection data into url for correct router history
const openModal = (group: EventTypeGroup, type: EventType) => {
const query = {
...router.query,
dialog: "new-eventtype",
eventPage: group.profile.slug,
title: type.title,
slug: type.slug,
description: type.description,
length: type.length,
type: type.schedulingType,
teamId: group.teamId,
};
if (!group.teamId) {
delete query.teamId;
}
router.push(
{
pathname: router.pathname,
query,
},
undefined,
{ shallow: true }
);
};
const deleteMutation = trpc.useMutation("viewer.eventTypes.delete", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.eventTypes"]);
showToast(t("event_type_deleted_successfully"), "success");
setDeleteDialogOpen(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setDeleteDialogOpen(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
const [isNativeShare, setNativeShare] = useState(true);
useEffect(() => {
if (!navigator.share) {
setNativeShare(false);
}
}, []);
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
return (
<div className="mb-16 flex overflow-hidden rounded-md border border-gray-200 bg-white">
<ul className="w-full divide-y divide-neutral-200" data-testid="event-types">
{types.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${CAL_URL}/${embedLink}`;
return (
<li
key={type.id}
className={classNames(type.$disabled && "select-none")}
data-disabled={type.$disabled ? 1 : 0}>
<div
className={classNames(
"flex items-center justify-between hover:bg-neutral-50",
type.$disabled && "hover:bg-white"
)}>
<div
className={classNames(
"group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6",
type.$disabled && "hover:bg-white"
)}>
{types.length > 1 && !type.$disabled && (
<>
<button
className="invisible absolute left-1/2 -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[36px] sm:ml-0 sm:flex"
onClick={() => moveEventType(index, -1)}>
<Icon.FiArrowUp className="h-5 w-5" />
</button>
<button
className="invisible absolute left-1/2 mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[36px] sm:ml-0 sm:flex"
onClick={() => moveEventType(index, 1)}>
<Icon.FiArrowDown className="h-5 w-5" />
</button>
</>
)}
<MemoizedItem
type={type}
group={group}
readOnly={readOnly}
connectedCalendars={connectedCalendarsQuery.data?.connectedCalendars}
/>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{type.users?.length > 1 && (
<AvatarGroup
border="border-2 border-white"
className={classNames("relative top-1 right-3", type.$disabled && " opacity-30")}
size={8}
truncateAfter={4}
items={type.users.map((organizer) => ({
alt: organizer.name || "",
image: `${WEBAPP_URL}/${organizer.username}/avatar.png`,
title: organizer.name || "",
}))}
/>
)}
<div
className={classNames(
"flex justify-between space-x-2 rtl:space-x-reverse ",
type.$disabled && "pointer-events-none cursor-not-allowed"
)}>
<Tooltip content={t("show_eventtype_on_profile") as string}>
<div className="self-center border-r-2 border-gray-300 pr-2">
<Switch
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
</Tooltip>
<Tooltip content={t("preview") as string}>
<Button
color="minimal"
size="icon"
href={calLink}
StartIcon={Icon.FiExternalLink}
disabled={type.$disabled}
/>
</Tooltip>
<Tooltip content={t("copy_link") as string}>
<Button
color="minimal"
size="icon"
StartIcon={Icon.FiLink}
disabled={type.$disabled}
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
/>
</Tooltip>
</div>
<Dropdown>
<DropdownMenuTrigger
className="focus:ring-brand-900 flex h-[36px] w-[36px] justify-center rounded-md bg-transparent text-gray-700 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-1"
data-testid={"event-type-options-" + type.id}>
<Icon.FiMoreHorizontal className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button
type="button"
href={"/event-types/" + type.id}
color="minimal"
disabled={type.$disabled}
StartIcon={Icon.FiEdit2}>
{t("edit") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
className={classNames("w-full rounded-none")}
data-testid={"event-type-duplicate-" + type.id}
disabled={type.$disabled}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<EmbedButton
color="minimal"
size="sm"
type="button"
StartIcon={Icon.FiCode}
className={classNames(
"w-full rounded-none",
type.$disabled && " pointer-events-none cursor-not-allowed opacity-30"
)}
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) && (
<DropdownMenuItem>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
color="destructive"
StartIcon={Icon.FiTrash}
disabled={type.$disabled}
className="w-full rounded-none">
{t("delete") as string}
</Button>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</div>
</div>
<div className="mr-5 flex flex-shrink-0 sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button
type="button"
size="icon"
color="minimal"
className={classNames(type.$disabled && " opacity-30")}
StartIcon={Icon.FiMoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent portalled>
<DropdownMenuItem className="outline-none">
<Link href={calLink}>
<a target="_blank">
<Button
color="minimal"
StartIcon={Icon.FiExternalLink}
className="w-full rounded-none">
{t("preview") as string}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
className="w-full rounded-none text-left"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiClipboard}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}>
{t("copy_link") as string}
</Button>
</DropdownMenuItem>
{isNativeShare ? (
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiUpload}
onClick={() => {
navigator
.share({
title: t("share"),
text: t("share_event"),
url: calLink,
})
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
}}>
{t("share") as string}
</Button>
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="outline-none">
<Button
type="button"
href={"/event-types/" + type.id}
color="minimal"
className="w-full"
StartIcon={Icon.FiEdit}>
{t("edit") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem className="outline-none">
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
color="destructive"
StartIcon={Icon.FiTrash}
className="w-full rounded-none">
{t("delete") as string}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
</li>
);
})}
</ul>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_event_type")}
confirmBtnText={t("confirm_delete_event_type")}
loadingText={t("confirm_delete_event_type")}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}>
{t("delete_event_type_description") as string}
</ConfirmationDialogContent>
</Dialog>
</div>
);
};
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => {
console.log(profile.slug);
return (
<div className="mb-4 flex">
<Link href="/settings/teams">
<a>
<Avatar
alt={profile?.name || ""}
imageSrc={`${WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
size={8}
className="mt-1 inline ltr:mr-2 rtl:ml-2"
/>
</a>
</Link>
<div>
<Link href="/settings/teams">
<a className="font-bold">{profile?.name || ""}</a>
</Link>
{membershipCount && (
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
<Link href="/settings/teams">
<a>
<Badge variant="gray">
<Icon.FiUsers className="mr-1 -mt-px inline h-3 w-3" />
{membershipCount}
</Badge>
</a>
</Link>
</span>
)}
{profile?.slug && (
<Link href={`${CAL_URL}/${profile.slug}`}>
<a className="block text-xs text-neutral-500">{`${CAL_URL?.replace("https://", "")}/${
profile.slug
}`}</a>
</Link>
)}
</div>
</div>
);
};
const CreateFirstEventTypeView = () => {
const { t } = useLocale();
return (
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("new_event_type_heading")}
description={t("new_event_type_description")}
/>
);
};
const CTA = () => {
const query = trpc.useQuery(["viewer.eventTypes"]);
if (!query.data) return null;
return (
<CreateEventTypeButton canAddEvents={query.data.viewer.canAddEvents} options={query.data.profiles} />
);
};
const WithQuery = withQuery(["viewer.eventTypes"]);
const EventTypesPage = () => {
const { t } = useLocale();
return (
<div>
<Head>
<title>Home | Cal.com</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell
heading={t("event_types_page_title") as string}
subtitle={t("event_types_page_subtitle") as string}
CTA={<CTA />}>
<WithQuery
customLoader={<SkeletonLoader />}
success={({ data }) => (
<>
{data.viewer.plan === "FREE" && !data.viewer.canAddEvents && (
<Alert
severity="warning"
title={<>{t("plan_upgrade")}</>}
message={
<Trans i18nKey="plan_upgrade_instructions">
You can
<a href="/api/upgrade" className="underline">
upgrade here
</a>
.
</Trans>
}
className="mb-4"
/>
)}
{data.eventTypeGroups.map((group, index) => (
<Fragment key={group.profile.slug}>
{/* hide list heading when there is only one (current user) */}
{(data.eventTypeGroups.length !== 1 || group.teamId) && (
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
/>
)}
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
</Fragment>
))}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
<EmbedDialog />
</>
)}
/>
</Shell>
</div>
);
};
export default EventTypesPage;
+1 -1
View File
@@ -1,4 +1,4 @@
import { getLayout } from "@calcom/ui/v2/layouts/AdminLayout";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
function AdminAppsView() {
return (
@@ -4,7 +4,7 @@ import { useRef } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { TextField } from "@calcom/ui/form/fields";
import { getLayout } from "@calcom/ui/v2/layouts/AdminLayout";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
function AdminView() {
const { t } = useLocale();
+1 -1
View File
@@ -1,4 +1,4 @@
import { getLayout } from "@calcom/ui/v2/layouts/AdminLayout";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
function AdminAppsView() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { getLayout } from "@calcom/ui/v2/layouts/AdminLayout";
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
function AdminUsersView() {
return (
+23 -6
View File
@@ -157,6 +157,7 @@
"checkbox": "Checkbox",
"is_required": "Is required",
"required": "Required",
"optional": "Optional",
"input_type": "Input type",
"rejected": "Rejected",
"unconfirmed": "Unconfirmed",
@@ -210,7 +211,7 @@
"go_back_login": "Go back to the login page",
"error_during_login": "An error occurred when logging you in. Head back to the login screen and try again.",
"request_password_reset": "Request Password Reset",
"forgot_password": "Forgot Password",
"forgot_password": "Forgot Password?",
"forgot": "Forgot?",
"done": "Done",
"all_done": "All done!",
@@ -227,6 +228,7 @@
"set_up_later": "Set up later",
"current_time": "Current time",
"welcome": "Welcome",
"welcome_back": "Welcome back",
"welcome_to_calcom": "Welcome to Cal.com",
"welcome_instructions": "Tell us what to call you and let us know what timezone you’re in. You’ll be able to edit this later.",
"connect_caldav": "Connect to CalDav Server",
@@ -550,6 +552,7 @@
"plan_upgrade_instructions": "You can <1>upgrade here</1>.",
"event_types_page_title": "Event Types",
"event_types_page_subtitle": "Create events to share for people to book on your calendar.",
"new": "New",
"new_event_type_btn": "New event type",
"new_event_type_heading": "Create your first event type",
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
@@ -598,6 +601,7 @@
"create_first_team_and_invite_others": "Create your first team and invite other users to work together with you.",
"create_team_to_get_started": "Create a team to get started",
"teams": "Teams",
"team": "Team",
"team_billing": "Team Billing",
"upgrade_to_flexible_pro_title": "We've changed billing for teams",
"upgrade_to_flexible_pro_message": "There are members in your team without a seat. Upgrade your pro plan to cover missing seats.",
@@ -633,6 +637,7 @@
"event_name_tooltip": "The name that will appear in calendars",
"meeting_with_user": "Meeting with {ATTENDEE}",
"additional_inputs": "Additional Inputs",
"additional_input_description":"Require scheduler to input additional inputs prior the booking is confirmed",
"label": "Label",
"placeholder": "Placeholder",
"type": "Type",
@@ -646,7 +651,9 @@
"starting": "Starting",
"disable_guests": "Disable Guests",
"disable_guests_description": "Disable adding additional guests while booking.",
"private_link": "Generate private URL",
"private_link": "Generate private link",
"private_link_label": "Private link",
"private_link_hint": "Your private link will regenerate after each use",
"copy_private_link": "Copy private link",
"private_link_description": "Generate a private URL to share without exposing your Cal username",
"invitees_can_schedule": "Invitees can schedule",
@@ -677,7 +684,7 @@
"add_new_event_type": "Add a new event type",
"new_event_type_to_book_description": "Create a new event type for people to book times with.",
"length": "Length",
"minimum_booking_notice": "Minimum booking notice",
"minimum_booking_notice": "Minimum Notice",
"slot_interval": "Time-slot intervals",
"slot_interval_default": "Use event length (default)",
"delete_event_type_description": "Are you sure you want to delete this event type? Anyone who you've shared this link with will no longer be able to book using it.",
@@ -848,7 +855,7 @@
"edit_booking": "Edit booking",
"reschedule_booking": "Reschedule booking",
"former_time": "Former time",
"confirmation_page_gif": "Gif for confirmation page",
"confirmation_page_gif": "Add a GIF to your confirmation page",
"search": "Search",
"impersonate": "Impersonate",
"user_impersonation_heading": "User Impersonation",
@@ -957,11 +964,11 @@
"current": "Current",
"premium": "premium",
"standard": "standard",
"new": "New",
"confirm_username_change_dialog_title": "Confirm username change",
"change_username_standard_to_premium": "As you are changing from a standard to a premium username, you will be taken to the checkout to upgrade.",
"change_username_premium_to_standard": "As you are changing from a premium to a standard username, you will be taken to the checkout to downgrade.",
"go_to_stripe_billing": "Go to billing",
"stripe_description": "Require payment for bookings (0.5% + €0.10 commission per transaction)",
"trial_expired": "Your trial has expired",
"remove_app": "Remove App",
"yes_remove_app": "Yes, remove app",
@@ -1023,6 +1030,7 @@
"close": "Close",
"pro_feature_teams": "This is a Pro feature. Upgrade to Pro to see your team's availability.",
"pro_feature_workflows": "This is a Pro feature. Upgrade to Pro to automate your event notifications and reminders with Workflows.",
"show_eventtype_on_profile":"Show on Profile",
"embed": "Embed",
"new_username": "New username",
"current_username": "Current username",
@@ -1038,5 +1046,14 @@
"set_ping_link": "Set Ping link",
"missing_connected_calendar": "No default calendar connected",
"connect_your_calendar_and_link": "You can connect your calendar from <1>here</1>.",
"default_calendar_selected": "Default calendar"
"default_calendar_selected": "Default calendar",
"hide_from_profile":"Hide from profile",
"event_setup_tab_title":"Event Setup",
"event_limit_tab_title":"Limits",
"event_limit_tab_description":"How often you can be booked",
"event_advanced_tab_description":"Calendar settings & more...",
"event_advanced_tab_title":"Advanced",
"select_which_cal":"Select which calendar to add bookings to",
"custom_event_name":"Custom event name",
"custom_event_name_description":"Create customised event names to display on calendar event"
}
+2 -2
View File
@@ -32,7 +32,7 @@ html.todesktop img{
cursor: default !important;
}
html.todesktop,
html.todesktop,
html.todesktop div {
cursor: default !important;
}
@@ -207,7 +207,7 @@ button[role="switch"][data-state="checked"] span {
}
.react-multi-email > [type="text"] {
@apply focus:border-brand block w-full rounded-[2px] border-gray-300 text-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white;
@apply dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500 dark:text-white;
}
.react-multi-email [data-tag] {
+1
View File
@@ -30,6 +30,7 @@
"docs-build": "turbo run build --scope=\"@calcom/docs\" --include-dependencies",
"docs-dev": "turbo run dev --scope=\"@calcom/docs\"",
"docs-start": "turbo run start --scope=\"@calcom/docs\"",
"build-storybook": "turbo run build --scope=\"@calcom/storybook\"",
"dx": "turbo run dx",
"embed-tests-quick": "turbo run embed-tests-quick",
"embed-tests": "turbo run embed-tests",
@@ -8,8 +8,8 @@ import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/v2/Button";
import { Form, TextField } from "@calcom/ui/v2/form/fields";
import Button from "@calcom/ui/v2/core/Button";
import { Form, TextField } from "@calcom/ui/v2/core/form/fields";
const formSchema = z.object({
api_key: z.string(),
+1
View File
@@ -19,6 +19,7 @@
"@calcom/office365video": "*",
"@calcom/trpc": "*",
"@calcom/zoomvideo": "*",
"@calcom/ui": "*",
"lodash": "^4.17.21"
},
"devDependencies": {
+75 -53
View File
@@ -11,14 +11,23 @@ module.exports = {
theme: {
fontFamily: {
cal: ['"Cal Sans"', "sans-serif"],
mono: ["Roboto Mono", "monospace"],
sans: ['"Inter var"', "sans-serif"],
},
extend: {
colors: {
/* your primary brand color */
brandcontrast: "var(--brand-text-color)",
darkmodebrand: "var(--brand-color-dark-mode)",
darkmodebrandcontrast: "var(--brand-text-color-dark-mode)",
bookinglightest: "var(--booking-lightest-color)",
bookinglighter: "var(--booking-lighter-color)",
bookinglight: "var(--booking-light-color)",
bookingmedian: "var(--booking-median-color)",
bookingdark: "var(--booking-dark-color)",
bookingdarker: "var(--booking-darker-color)",
bookinghighlight: "var(--booking-highlight-color)",
black: "#111111",
brand: {
// TODO: To be shared between Storybook for v2 UI components and web
// Figure out a way to automate this for self hosted users
// Goto https://javisperez.github.io/tailwindcolorshades to generate your brand color
50: "#d1d5db",
@@ -33,28 +42,17 @@ module.exports = {
900: "#080c13",
DEFAULT: "var(--brand-color)",
},
brandcontrast: "var(--brand-text-color)",
darkmodebrand: "var(--brand-color-dark-mode)",
darkmodebrandcontrast: "var(--brand-text-color-dark-mode)",
bookinglightest: "var(--booking-lightest-color)",
bookinglighter: "var(--booking-lighter-color)",
bookinglight: "var(--booking-light-color)",
bookingmedian: "var(--booking-median-color)",
bookingdark: "var(--booking-dark-color)",
bookingdarker: "var(--booking-darker-color)",
bookinghighlight: "var(--booking-highlight-color)",
black: "#111111",
gray: {
50: "#F8F8F8",
100: "#F5F5F5",
200: "#E1E1E1",
300: "#CFCFCF",
400: "#ACACAC",
500: "#888888",
600: "#494949",
700: "#3E3E3E",
800: "#313131",
900: "#292929",
50: "#F9FAFB",
100: "#F3F4F6",
200: "#E5E7EB",
300: "#D1D5DB",
400: "#9CA3AF",
500: "#6B7280",
600: "#4B5563",
700: "#374151",
800: "#1F2937",
900: "#111827",
},
neutral: {
50: "#F8F8F8",
@@ -93,40 +91,64 @@ module.exports = {
900: "#223B41",
},
red: {
50: "#FEF2F2",
100: "#FEE2E2",
200: "#FECACA",
300: "#FCA5A5",
400: "#F87171",
500: "#EF4444",
600: "#DC2626",
700: "#B91C1C",
800: "#991B1B",
900: "#7F1D1D",
50: "#FFF5F5",
100: "#FFE3E2",
200: "#FFC9C9",
300: "#FEA8A8",
400: "#FF8787",
500: "#FF6B6B",
600: "#FA5352",
700: "#F03E3F",
800: "#E03130",
900: "#C92B2B",
},
orange: {
50: "#FFF7ED",
100: "#FFEDD5",
200: "#FED7AA",
300: "#FDBA74",
400: "#FB923C",
500: "#F97316",
600: "#EA580C",
700: "#C2410C",
800: "#9A3412",
900: "#7C2D12",
50: "#FFF4E5",
100: "#FFE8CC",
200: "#FFD8A8",
300: "#FFBF78",
400: "#FFA94E",
500: "#FF922B",
600: "#FD7E14",
700: "#F76706",
800: "#E8580C",
900: "#D94810",
},
green: {
50: "#ECFDF5",
100: "#D1FAE5",
200: "#A7F3D0",
300: "#6EE7B7",
400: "#34D399",
500: "#10B981",
600: "#059669",
700: "#047857",
800: "#065F46",
900: "#064E3B",
50: "#EBFCEE",
100: "#D2F9D9",
200: "#B1F2BA",
300: "#8CE99A",
400: "#69DB7C",
500: "#51CF66",
600: "#40C057",
700: "#36B24D",
800: "#2F9E44",
900: "#2B8A3E",
},
blue: {
50: "#E7F5FF",
100: "#D0EBFF",
200: "#A4D8FF",
300: "#74C0FC",
400: "#4DABF7",
500: "#339AF0",
600: "#238BE6",
700: "#1C7ED7",
800: "#1971C2",
900: "#1763AB",
},
darkgray: {
50: "#101010",
100: "#1c1c1c",
200: "#2b2b2b",
300: "#444444",
400: "#575757",
500: "#767676",
600: "#a5a5a5",
700: "#d6d6d6",
800: "#e8e8e8",
900: "#f3f4f6",
},
},
fontFamily: {
@@ -11,7 +11,7 @@ import { ClipboardCopyIcon } from "@calcom/ui/Icon";
import Switch from "@calcom/ui/Switch";
import { Tooltip } from "@calcom/ui/Tooltip";
import { Form, TextField } from "@calcom/ui/form/fields";
import DatePicker from "@calcom/ui/v2/form/DatePicker";
import { DatePicker } from "@calcom/ui/v2";
import LicenseRequired from "../../common/components/LicenseRequired";
import type { TApiKeys } from "./ApiKeyListItem";
@@ -1,11 +1,10 @@
import { Trans } from "react-i18next";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Icon, BadgeCheckIcon } from "@calcom/ui/Icon";
import { useLocale } from "@lib/hooks/useLocale";
export default function LicenseBanner() {
const { t } = useLocale();
/*
@@ -1,10 +1,9 @@
import dayjs from "@calcom/dayjs";
import { TRIAL_LIMIT_DAYS } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import Button from "@calcom/ui/Button";
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale";
import useMeQuery from "@lib/hooks/useMeQuery";
const TrialBanner = () => {
const { t } = useLocale();
const query = useMeQuery();
@@ -6,7 +6,7 @@ import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Select from "@calcom/ui/form/Select";
import TimezoneSelect, { ITimezone } from "@calcom/ui/form/TimezoneSelect";
import { Avatar } from "@calcom/ui/v2";
import DatePicker from "@calcom/ui/v2/form/DatePicker";
import DatePicker from "@calcom/ui/v2/core/form/DatePicker";
import LicenseRequired from "../../common/components/LicenseRequired";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
@@ -7,8 +7,8 @@ import { CAL_URL } from "@calcom/lib/constants";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Select from "@calcom/ui/form/Select";
import TimezoneSelect, { ITimezone } from "@calcom/ui/form/TimezoneSelect";
import Avatar from "@calcom/ui/v2/Avatar";
import DatePicker from "@calcom/ui/v2/form/DatePicker";
import { Avatar } from "@calcom/ui/v2";
import DatePicker from "@calcom/ui/v2/core/form/DatePicker";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
+20
View File
@@ -0,0 +1,20 @@
type BrowserInfo = {
url: string;
path: string;
referrer: string;
title: string;
query: string;
};
export const getBrowserInfo = (): Partial<BrowserInfo> => {
if (!process.browser) {
return {};
}
return {
url: window.document.location?.href ?? undefined,
path: window.document.location?.pathname ?? undefined,
referrer: window.document?.referrer ?? undefined,
title: window.document.title ?? undefined,
query: window.document.location?.search,
};
};
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
const useResponsive = () => {
const [width, setWidth] = useState(window.innerWidth);
const handleWindowSizeChange = () => {
setWidth(window.innerWidth);
};
useEffect(() => {
window.addEventListener("resize", handleWindowSizeChange);
return () => {
window.removeEventListener("resize", handleWindowSizeChange);
};
}, []);
return {
isSm: width <= 640,
isMd: width > 640 && width <= 768,
isLg: width > 768 && width <= 1024,
isXl: width > 1024 && width <= 1280,
is2xl: width > 1280 && width < 1536,
};
};
export default useResponsive;
+39
View File
@@ -0,0 +1,39 @@
import { DefaultSeoProps, NextSeoProps } from "next-seo";
import { SEO_IMG_DEFAULT, SEO_IMG_OGIMG } from "@calcom/lib/constants";
export type HeadSeoProps = {
title: string;
description: string;
siteName?: string;
name?: string;
url?: string;
username?: string;
canonical?: string;
nextSeoProps?: NextSeoProps;
};
const seoImages = {
default: SEO_IMG_DEFAULT,
ogImage: SEO_IMG_OGIMG,
};
export const getSeoImage = (key: keyof typeof seoImages): string => {
return seoImages[key];
};
export const seoConfig: {
headSeo: Required<Pick<HeadSeoProps, "siteName">>;
defaultNextSeo: DefaultSeoProps;
} = {
headSeo: {
siteName: "Cal.com",
},
defaultNextSeo: {
twitter: {
handle: "@calcom",
site: "@calcom",
cardType: "summary_large_image",
},
},
} as const;
+1
View File
@@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
import { CollectOpts, EventHandler } from "next-collect";
import { useCollector } from "next-collect/client";
// Importing types so we're not directly importing next/server
import type { NextRequest, NextResponse } from "next/server";
import { CONSOLE_URL } from "./constants";
@@ -52,6 +52,13 @@ export const availabilityRouter = createProtectedRouter()
name: true,
availability: true,
timeZone: true,
eventType: {
select: {
_count: true,
id: true,
eventName: true,
},
},
},
});
if (!schedule || schedule.userId !== user.id) {
+2 -2
View File
@@ -3,9 +3,9 @@
"display": "React Library",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2015"],
"lib": ["dom", "dom.iterable", "esnext"],
"module": "ESNext",
"target": "ES6",
"target": "ES5",
"jsx": "react-jsx"
},
"include": [".", "../types/next-auth.d.ts"],
+1 -2
View File
@@ -28,10 +28,9 @@ import Dropdown, {
} from "@calcom/ui/Dropdown";
import { CollectionIcon, Icon } from "@calcom/ui/Icon";
import Loader from "@calcom/ui/Loader";
import { HeadSeo } from "@calcom/ui/v2/core/head-seo";
import { useViewerI18n } from "@calcom/web/components/I18nLanguageHandler";
import { HeadSeo } from "@components/seo/head-seo";
/* TODO: Get this from endpoint */
import pkg from "../../apps/web/package.json";
import ErrorBoundary from "./ErrorBoundary";
+3
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"scripts": {
"lint": "eslint .",
"type-check": "tsc --pretty --noEmit",
"lint:fix": "eslint . --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/ui.json"
},
@@ -14,12 +15,14 @@
"@calcom/lib": "*",
"@calcom/trpc": "*",
"@radix-ui/react-dialog": "^0.1.0",
"@radix-ui/react-portal": "^0.1.0",
"@radix-ui/react-select": "^0.1.1",
"downshift": "^6.1.7",
"next": "^12.2.5",
"react": "^18.2.0",
"react-colorful": "^5.5.1",
"react-feather": "^2.0.10",
"react-hook-form": "^7.33.1",
"react-icons": "^4.4.0",
"react-query": "^3.39.2",
"react-select": "^5.4.0"
+7 -1
View File
@@ -3,6 +3,12 @@
"compilerOptions": {
"resolveJsonModule": true
},
"include": ["."],
"include": [
"../../apps/web/next-env.d.ts",
"../types/*.d.ts",
"../types/next-auth.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": ["dist", "build", "node_modules"]
}
-9
View File
@@ -1,9 +0,0 @@
import React from "react";
import classNames from "@calcom/lib/classNames";
type Props = { children: React.ReactNode; combined?: boolean };
export default function ButtonGroup({ children, combined = false }: Props) {
return <div className={classNames("flex", !combined && "space-x-2")}>{children}</div>;
}
-66
View File
@@ -1,66 +0,0 @@
import { ReactNode } from "react";
import classNames from "@calcom/lib/classNames";
import Button from "./Button";
export type BaseCardProps = {
image: string;
variant: keyof typeof cardTypeByVariant;
imageProps?: JSX.IntrinsicElements["img"];
title: string;
description: string;
containerProps?: JSX.IntrinsicElements["div"];
actionButton?: {
href: string;
child: ReactNode;
};
};
const cardTypeByVariant = {
AppStore: {
image: "w-10 h-auto",
card: "p-5 w-64",
},
ProfileCard: {
image: "w-9 h-auto rounded-full mb-4s",
card: "w-80 p-4 hover:bg-gray-100",
actionButton: "",
},
};
export function Card({
image,
title,
description,
variant,
actionButton,
containerProps,
imageProps,
}: BaseCardProps) {
return (
<div
className={classNames(
containerProps?.className,
cardTypeByVariant[variant].card,
"border-1 rounded-md border-gray-200 bg-white"
)}
{...containerProps}>
<img
src={image}
// Stops eslint complaining - not smart enough to realise it comes from ...imageProps
alt={imageProps?.alt}
className={classNames(imageProps?.className, cardTypeByVariant[variant].image, "mb-4")}
{...imageProps}
/>
<span className="text-base font-bold leading-5 text-gray-900 ">{title}</span>
<p className="pt-1 text-sm font-normal leading-[18px] text-gray-500">{description}</p>
{variant === "AppStore" && (
<Button color="secondary" href={actionButton?.href} size="lg" className="mt-10 w-full">
{/* Force it to be centered as this usecase of a button is off - doesnt meet normal sizes */}
<div className="mx-auto">{actionButton?.child}</div>
</Button>
)}
</div>
);
}
@@ -9,7 +9,7 @@ import { Maybe } from "@trpc/server";
export type AvatarProps = {
className?: string;
size: "sm" | "lg";
size: "sm" | "md" | "lg";
imageSrc?: Maybe<string>;
title?: string;
alt: string;
@@ -17,9 +17,15 @@ export type AvatarProps = {
accepted?: boolean;
};
const sizesPropsBySize = {
sm: "w-6",
md: "w-8",
lg: "w-16",
};
export default function Avatar(props: AvatarProps) {
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
const rootClass = classNames("rounded-full", props.size === "sm" ? "w-6" : "w-16", "h-auto");
const rootClass = classNames("rounded-full", sizesPropsBySize[props.size], "h-auto");
const avatar = (
<AvatarPrimitive.Root className={classNames("relative inline-block overflow-hidden ")}>
<AvatarPrimitive.Image src={imageSrc ?? undefined} alt={alt} className={rootClass} />
@@ -8,7 +8,7 @@ export const badgeClassNameByVariant = {
orange: "bg-orange-100 text-orange-800",
success: "bg-green-100 text-green-800",
green: "bg-green-100 text-green-800",
gray: "bg-gray-200 text-gray-800",
gray: "bg-gray-200 text-gray-800 dark:bg-transparent dark:text-darkgray-800",
blue: "bg-blue-100 text-blue-800",
red: "bg-red-100 text-red-800",
error: "bg-red-100 text-red-800",
@@ -67,3 +67,5 @@ export const BreadcrumbContainer = () => {
setBreadcrumbs(allBreadcrumbs);
}, [router.asPath]);
};
export default Breadcrumb;
@@ -22,7 +22,7 @@ export type ButtonBaseProps = {
/** Action that happens when the button is clicked */
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
/**Left aligned icon*/
StartIcon?: Icon;
StartIcon?: Icon | React.ElementType;
/**Right aligned icon */
EndIcon?: Icon;
shallow?: boolean;
@@ -42,7 +42,7 @@ const variantClassName = {
"border border-transparent text-white bg-brand-500 hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500",
secondary: "border border-gray-200 text-brand-900 bg-white hover:bg-gray-100",
minimal:
"text-gray-700 bg-transparent hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-gray-100 focus:ring-brand-900",
"text-gray-700 bg-transparent hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-gray-100 focus:ring-brand-900 dark:text-darkgray-900",
destructive:
"text-gray-700 bg-transparent hover:bg-red-100 hover:text-red-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-100 focus:ring-red-700",
};
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import classNames from "@calcom/lib/classNames";
type Props = { children: React.ReactNode; combined?: boolean; containerProps?: JSX.IntrinsicElements["div"] };
export default function ButtonGroup({ children, combined = false, containerProps }: Props) {
return (
<div
{...containerProps}
className={classNames("flex", !combined && "space-x-2", containerProps?.className)}>
{children}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import Link from "next/link";
import { ReactNode } from "react";
import React from "react";
import classNames from "@calcom/lib/classNames";
import { Badge } from "./Badge";
import Button from "./Button";
export type BaseCardProps = {
image?: string;
variant: keyof typeof cardTypeByVariant;
imageProps?: JSX.IntrinsicElements["img"];
title: string;
description: ReactNode;
containerProps?: JSX.IntrinsicElements["div"];
actionButton?: {
href?: string;
child: ReactNode;
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
};
learnMore?: {
href: string;
text: string;
};
mediaLink?: string;
thumbnailUrl?: string;
};
const cardTypeByVariant = {
AppStore: {
image: "w-10 h-auto",
card: "p-5 w-64",
title: "text-base",
description: "text-sm leading-[18px] text-gray-500 font-normal",
},
ProfileCard: {
image: "w-9 h-auto rounded-full mb-4s",
card: "w-80 p-4 hover:bg-gray-100",
title: "text-base",
description: "text-sm leading-[18px] text-gray-500 font-normal",
},
SidebarCard: {
image: "w-9 h-auto rounded-full mb-4s",
card: "w-full p-3 border border-gray-200",
title: "text-sm font-cal",
description: "text-xs text-gray-600 line-clamp-2",
},
};
export function Card({
image,
title,
description,
variant,
actionButton,
containerProps,
imageProps,
mediaLink,
thumbnailUrl,
learnMore,
}: BaseCardProps) {
return (
<div
className={classNames(
containerProps?.className,
cardTypeByVariant[variant].card,
"border-1 rounded-md border-gray-200 bg-white"
)}
{...containerProps}>
{image && (
<img
src={image}
// Stops eslint complaining - not smart enough to realise it comes from ...imageProps
alt={imageProps?.alt}
className={classNames(imageProps?.className, cardTypeByVariant[variant].image, "mb-4")}
{...imageProps}
/>
)}
<h5
title={title}
className={classNames(
cardTypeByVariant[variant].title,
"line-clamp-1 font-bold leading-5 text-gray-900"
)}>
{title}
</h5>
{description && (
<p
title={description.toString()}
className={classNames(cardTypeByVariant[variant].description, "pt-1")}>
{description}
</p>
)}
{variant === "SidebarCard" && (
<a
target="_blank"
rel="noreferrer"
href={mediaLink}
className="group relative my-3 flex aspect-video items-center overflow-hidden rounded">
<div className="absolute inset-0 bg-black bg-opacity-50 transition-opacity group-hover:bg-opacity-40" />
<svg
className="absolute top-1/2 left-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 transform rounded-full text-white shadow-lg hover:-mt-px"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M16 32C24.8366 32 32 24.8366 32 16C32 7.16344 24.8366 0 16 0C7.16344 0 0 7.16344 0 16C0 24.8366 7.16344 32 16 32Z"
fill="white"
/>
<path
d="M12.1667 8.5L23.8334 16L12.1667 23.5V8.5Z"
fill="#111827"
stroke="#111827"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<img alt="play feature video" src={thumbnailUrl} />
</a>
)}
{variant === "AppStore" && (
<Button color="secondary" href={actionButton?.href} size="lg" className="mt-10 w-full">
{/* Force it to be centered as this usecase of a button is off - doesnt meet normal sizes */}
<div className="mx-auto">{actionButton?.child}</div>
</Button>
)}
{variant === "SidebarCard" && (
<div className="mt-2 flex items-center justify-between">
{learnMore && (
<Link href={learnMore.href}>
<a target="_blank" rel="noreferrer" className="text-xs font-medium">
{learnMore.text}
</a>
</Link>
)}
<button
className="p-0 text-xs font-normal text-gray-600 hover:text-gray-800"
color="minimal"
onClick={actionButton?.onClick}>
{actionButton?.child}
</button>
</div>
)}
</div>
);
}
export default Card;
@@ -62,14 +62,17 @@ export function Dialog(props: DialogProps) {
}
type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]> & {
size?: "xl" | "lg";
type?: "creation" | "confirmation";
type: "creation" | "confirmation";
title?: string;
description?: string;
description?: string | undefined;
closeText?: string;
actionDisabled?: boolean;
actionText?: string;
Icon?: Icon;
// If this is set it allows you to overide the action buttons. Usefull if you need to use formcontext
useOwnActionButtons?: boolean;
actionOnClick?: () => void;
actionOnClose?: () => void;
};
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
@@ -110,15 +113,25 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
</div>
</div>
)}
<DialogFooter>
<DialogClose asChild>
{/* This will require the i18n string passed in */}
<Button color="minimal">{props.closeText ?? "Close"}</Button>
</DialogClose>
<Button color="primary" disabled={props.actionDisabled} onClick={props.actionOnClick}>
{props.actionText}
</Button>
</DialogFooter>
{!props.useOwnActionButtons && (
<DialogFooter>
<DialogClose asChild>
{/* This will require the i18n string passed in */}
<Button color="minimal" onClick={props.actionOnClose}>
{props.closeText ?? "Close"}
</Button>
</DialogClose>
{props.actionOnClick ? (
<Button color="primary" disabled={props.actionDisabled} onClick={props.actionOnClick}>
{props.actionText}
</Button>
) : (
<Button color="primary" type="submit" disabled={props.actionDisabled}>
{props.actionText}
</Button>
)}
</DialogFooter>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
@@ -22,7 +22,7 @@ export default function EmptyScreen({
}) {
return (
<>
<div className="min-h-80 my-6 flex max-w-[640px] flex-col items-center justify-center rounded-sm border border-dashed p-7 lg:p-20">
<div className="min-h-80 my-6 flex w-full flex-col items-center justify-center rounded-sm border border-dashed p-7 lg:p-20">
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-200 dark:bg-white">
<Icon className="inline-block h-10 w-10 text-white dark:bg-gray-900 dark:text-gray-600" />
</div>
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
import { SVGComponent } from "@calcom/types/SVGComponent";
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Icon: SVGComponent;
}
export default function LinkIconButton(props: LinkIconButtonProps) {
return (
<div className="-ml-2">
<button
type="button"
{...props}
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
<props.Icon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{props.children}
</button>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import React, { Fragment, ReactNode } from "react";
import ReactDOM from "react-dom";
export const Portal = (props: { children: React.ReactNode }) => {
const portalContainer = document.getElementById("Portal");
// This should never be the case as it lives in _document.tsx
if (!portalContainer) return null;
return ReactDOM.createPortal(<Fragment>{props.children}</Fragment>, portalContainer);
};
@@ -27,14 +27,16 @@ import Dropdown, {
} from "@calcom/ui/Dropdown";
import { CollectionIcon, Icon } from "@calcom/ui/Icon";
/* TODO: Migate this */
import { HeadSeo } from "../../../apps/web/components/seo/head-seo";
/* TODO: Get this from endpoint */
import pkg from "../../../apps/web/package.json";
import ErrorBoundary from "../ErrorBoundary";
import { KBarContent, KBarRoot, KBarTrigger } from "../Kbar";
import Logo from "../Logo";
import pkg from "../../../../apps/web/package.json";
import ErrorBoundary from "../../ErrorBoundary";
import { KBarRoot, KBarContent, KBarTrigger } from "../../Kbar";
import Logo from "../../Logo";
import Tips from "../modules/tips/Tips";
import Card from "./Card";
import HeadSeo from "./head-seo";
/* TODO: Migate this */
export const ONBOARDING_INTRODUCED_AT = dayjs("September 1 2021").toISOString();
@@ -406,7 +408,7 @@ const navigation: NavigationItemType[] = [
},
{
name: "settings",
href: "/v2/settings",
href: "/settings",
icon: Icon.FiSettings,
},
];
@@ -443,7 +445,9 @@ const NavigationItem: React.FC<{
const router = useRouter();
const current = isChild ? item.href === router.asPath : router.asPath.startsWith(item.href);
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
if (!shouldDisplayNavigationItem) return null;
return (
<Fragment>
<Link href={item.href}>
@@ -561,6 +565,8 @@ function SideBarContainer() {
}
function SideBar() {
const [visible, setVisible] = useState(true);
const { t } = useLocale();
return (
<aside className="hidden w-14 flex-col border-r border-gray-100 bg-gray-50 px-2 md:flex lg:w-56 lg:flex-shrink-0 lg:px-4">
<div className="flex h-0 flex-1 flex-col overflow-y-auto pt-3 pb-4 lg:pt-5">
@@ -582,6 +588,9 @@ function SideBar() {
</Link>
<Navigation />
</div>
<Tips />
<TrialBanner />
<div data-testid="user-dropdown-trigger">
<span className="hidden lg:inline">
@@ -598,10 +607,33 @@ function SideBar() {
function MainContainer(props: LayoutProps) {
return (
<main className="relative z-0 flex flex-1 flex-row overflow-y-auto bg-white focus:outline-none">
<main className="relative z-0 flex flex-1 flex-col overflow-y-auto bg-white focus:outline-none lg:px-12 lg:py-8">
{/* show top navigation for md and smaller (tablet and phones) */}
<TopNavContainer />
<ErrorBoundary>{props.children}</ErrorBoundary>
<ErrorBoundary>
{props.heading && (
<div
className={classNames(props.large && "bg-gray-100 py-8", "flex items-center px-2 pt-4 md:p-0")}>
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
<div className="mb-4 w-full">
<>
{props.heading && (
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-black">
{props.heading}
</h1>
)}
{props.subtitle && (
<p className="text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
)}
</>
</div>
{props.CTA && <div className="mb-4 flex-shrink-0">{props.CTA}</div>}
</div>
)}
<div className={classNames("", props.flexChildrenContainer && "flex flex-1 flex-col")}>
{props.children}
</div>
</ErrorBoundary>
{/* show bottom navigation for md and smaller (tablet and phones) */}
<MobileNavigationContainer />
<LicenseBanner />

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