V2 Main (#3549)
* 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:
co-authored by
Julian Benegas
Alan
Peer Richelsen
zomars
Hariom Balhara
parent
c17e949cd6
commit
436f88a48e
@@ -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?
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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,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,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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }]} />;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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} </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"> {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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { getLayout } from "@calcom/ui/v2/layouts/AdminLayout";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
|
||||
|
||||
function AdminAppsView() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLayout } from "@calcom/ui/v2/layouts/AdminLayout";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout";
|
||||
|
||||
function AdminUsersView() {
|
||||
return (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@calcom/office365video": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/zoomvideo": "*",
|
||||
"@calcom/ui": "*",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user