Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de446db434 | ||
|
|
81546412bf | ||
|
|
459530fba3 | ||
|
|
42766b42c0 | ||
|
|
38b3563350 | ||
|
|
37159914cf | ||
|
|
268bf68532 | ||
|
|
b02c7e01d5 | ||
|
|
45d10a3bc4 | ||
|
|
9250b20c1d | ||
|
|
d9da311141 | ||
|
|
89a52dad21 | ||
|
|
2a1acc1294 | ||
|
|
8302990f84 | ||
|
|
5a03b61d63 | ||
|
|
6b6f239936 | ||
|
|
9c2748e8e9 | ||
|
|
bb5cc33737 | ||
|
|
5616a64827 | ||
|
|
33530ad8c8 | ||
|
|
fe14839f1b | ||
|
|
15123c1aa5 | ||
|
|
71ee9c72fe | ||
|
|
5503539856 | ||
|
|
6548f01efa | ||
|
|
518a4484d6 | ||
|
|
3535a25a59 | ||
|
|
06cc6741e6 | ||
|
|
ed1478e1ba | ||
|
|
854c05d673 | ||
|
|
bcf5fb18c5 | ||
|
|
3f822e814b | ||
|
|
4fd25c7215 | ||
|
|
f42bed2a55 | ||
|
|
469c205033 | ||
|
|
31551f3b9e | ||
|
|
69925af17c | ||
|
|
2502868ebc |
@@ -1,8 +1,7 @@
|
||||
<!-- PROJECT LOGO -->
|
||||
<p align="center">
|
||||
<a href="https://github.com/calcom/cal.com">
|
||||
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
||||
@@ -37,7 +36,7 @@
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||
<a href="https://github.com/orgs/calcom/projects/9"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||
@@ -310,7 +309,7 @@ Please see our [contributing guide](/CONTRIBUTING.md).
|
||||
|
||||
### Good First Issues
|
||||
|
||||
We have a list of [help wanted](https://github.com/orgs/calcom/projects/1/views/25) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||
We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||
|
||||
## Integrations
|
||||
|
||||
|
||||
@@ -14,14 +14,13 @@ Cal.com uses [TailwindCSS](https://tailwindcss.com) as a replacement for traditi
|
||||
|
||||
CSS files should be stored in the `styles` directory within the codebase.
|
||||
|
||||
Within the `styles` directory, you will find two CSS files, `fonts.css` and `global.css`. We suggest not to add to these files, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
|
||||
Within the `styles` directory, you will find a single CSS file, `global.css`. We suggest not to add to this file, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
|
||||
|
||||
## Adding new stylesheets
|
||||
Firstly, create the CSS file inside the `styles` directory.
|
||||
|
||||
Then, open the `pages/_app.tsx` file, and you will see the following two lines:
|
||||
```javascript
|
||||
import "../styles/fonts.css";
|
||||
import "../styles/globals.css";
|
||||
```
|
||||
|
||||
|
||||
@@ -639,7 +639,7 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
<div className="flex items-start">
|
||||
{embeds.map((embed, index) => (
|
||||
<button
|
||||
className="mr-2 w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100"
|
||||
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100 ltr:mr-2 rtl:ml-2"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
@@ -914,7 +914,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-start",
|
||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
||||
embedType === "floating-popup" ? "space-x-2 rtl:space-x-reverse" : "hidden"
|
||||
)}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
@@ -1010,7 +1010,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<div className="mb-4 flex items-center justify-start space-x-2">
|
||||
<div className="mb-4 flex items-center justify-start space-x-2 rtl:space-x-reverse">
|
||||
<Switch
|
||||
checked={previewState.hideEventTypeDetails}
|
||||
onCheckedChange={(checked) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useViewerI18n() {
|
||||
@@ -21,12 +22,16 @@ export function useViewerI18n() {
|
||||
*/
|
||||
const I18nLanguageHandler = (): null => {
|
||||
const { i18n } = useTranslation("common");
|
||||
const locale = useViewerI18n().data?.locale;
|
||||
const locale = useViewerI18n().data?.locale || "en";
|
||||
|
||||
useEffect(() => {
|
||||
if (locale && i18n.language && i18n.language !== locale) {
|
||||
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
const dir = getDirFromLang(locale);
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
document.documentElement.setAttribute("dir", dir);
|
||||
}, [locale, i18n]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
List,
|
||||
ShellSubHeading,
|
||||
showToast,
|
||||
SkeletonLoader,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
Switch,
|
||||
} from "@calcom/ui";
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function IntegrationListItem(props: {
|
||||
<ListItemText component="p">{props.description}</ListItemText>
|
||||
{/* Alert error that key stopped working. */}
|
||||
{props.invalidCredential && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
|
||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||
{t("invalid_credential")}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { Props } from "./pages/AvailabilityPage";
|
||||
|
||||
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return locations.length ? (
|
||||
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
|
||||
{locations.map((location) => {
|
||||
@@ -16,19 +19,19 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
return (
|
||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Icon.FiLink className="dark:text-darkgray-600 mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 " />
|
||||
<Icon.FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={locationKeyToString(location)}>
|
||||
<p className="truncate">{locationKeyToString(location)}</p>
|
||||
<Tooltip content={t(locationKeyToString(location) ?? "")}>
|
||||
<p className="truncate">{t(locationKeyToString(location) ?? "")}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
||||
<div className="flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
|
||||
@@ -95,7 +95,7 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
<div>
|
||||
<Icon.FiInfo
|
||||
className={classNames(
|
||||
"mr-[10px] ml-[2px] inline-block h-4 w-4",
|
||||
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
|
||||
)}
|
||||
/>
|
||||
@@ -112,7 +112,7 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<Icon.FiCheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
</div>
|
||||
{requiresConfirmationText}
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
)}>
|
||||
<Icon.FiClock
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 mr-[10px] ml-[2px] inline-block",
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "mt-[2px]"
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -22,8 +23,9 @@ import {
|
||||
showToast,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
ActionType,
|
||||
TableActions,
|
||||
} from "@calcom/ui";
|
||||
import { ActionType, TableActions } from "@calcom/ui";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
@@ -48,6 +50,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.status === BookingStatus.REJECTED) {
|
||||
@@ -112,6 +115,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
@@ -206,7 +220,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showRecordingsButtons = booking.location === "integrations:daily" && isPast && isConfirmed;
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -220,7 +234,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
/>
|
||||
|
||||
{showRecordingsButtons && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={user?.timeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent>
|
||||
@@ -342,7 +363,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<span> </span>
|
||||
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Badge className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex" variant="orange">
|
||||
<Badge className="hidden ltr:ml-2 ltr:mr-2 rtl:ml-2 sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -377,6 +398,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
|
||||
@@ -221,7 +221,7 @@ function TimezoneDropdown({
|
||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
|
||||
<p className="flex items-center text-sm font-medium">
|
||||
<Icon.FiGlobe className="min-h-4 min-w-4 mr-[10px] ml-[2px] -mt-[2px] inline-block" />
|
||||
<Icon.FiGlobe className="min-h-4 min-w-4 ml-[2px] -mt-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{timeZone}
|
||||
{isTimeOptionsOpen ? (
|
||||
<Icon.FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||
@@ -368,7 +368,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<Icon.FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
@@ -393,7 +393,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<Icon.FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
@@ -414,7 +414,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<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" />
|
||||
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -501,7 +501,7 @@ const BookingPage = ({
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<Icon.FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
@@ -513,7 +513,7 @@ const BookingPage = ({
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
<Icon.FiRefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
@@ -524,7 +524,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
||||
<Icon.FiCalendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<div className="text-sm font-medium">
|
||||
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
||||
{!rescheduleUid &&
|
||||
@@ -552,7 +552,7 @@ const BookingPage = ({
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<Icon.FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -560,7 +560,7 @@ const BookingPage = ({
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiUser
|
||||
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
||||
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
@@ -623,7 +623,7 @@ const BookingPage = ({
|
||||
/>
|
||||
{bookingForm.formState.errors.email && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("email_validation_error")}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -664,8 +664,8 @@ const BookingPage = ({
|
||||
value={location.type}
|
||||
defaultChecked={i === 0}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
|
||||
{locationKeyToString(location)}
|
||||
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
|
||||
{t(locationKeyToString(location) ?? "")}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -689,7 +689,7 @@ const BookingPage = ({
|
||||
{selectedLocationType === LocationType.Phone
|
||||
? t("phone_number")
|
||||
: selectedLocationType === LocationType.AttendeeInPerson
|
||||
? t("Address")
|
||||
? t("address")
|
||||
: ""}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -716,7 +716,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
{bookingForm.formState.errors.phone && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -800,6 +800,7 @@ const BookingPage = ({
|
||||
{input.options && input.type === EventTypeCustomInputType.RADIO && (
|
||||
<div className="flex">
|
||||
<Group
|
||||
name={`customInputs.${input.id}`}
|
||||
required={input.required}
|
||||
onValueChange={(e) => {
|
||||
bookingForm.setValue(`customInputs.${input.id}`, e);
|
||||
@@ -808,9 +809,9 @@ const BookingPage = ({
|
||||
{input.options.map((option, i) => (
|
||||
<RadioField
|
||||
label={option.label}
|
||||
key={`option.${i}.radio`}
|
||||
key={`option.${input.id}.${i}.radio`}
|
||||
value={option.label}
|
||||
id={`option.${i}.radio`}
|
||||
id={`option.${input.id}.${i}.radio`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -833,7 +834,7 @@ const BookingPage = ({
|
||||
/>
|
||||
{bookingForm.formState.errors?.customInputs?.[input.id] && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -913,7 +914,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
{bookingForm.formState.errors.smsReminderNumber && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -354,7 +354,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
|
||||
@@ -149,7 +149,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ml-4">-</span>
|
||||
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -164,7 +164,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
<hr />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||
<Icon.FiGlobe className="mr-2" />
|
||||
<Icon.FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -134,8 +134,8 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
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">
|
||||
<div className="mt-5 flex justify-end space-x-2 rtl:space-x-reverse sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import type { CustomInputParsed, EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import type { CustomInputParsed, EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
@@ -7,10 +7,11 @@ import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
@@ -36,7 +37,19 @@ const generateHashedLink = (id: number) => {
|
||||
return uid;
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
|
||||
const getRandomId = (length = 8) => {
|
||||
return (
|
||||
-1 *
|
||||
parseInt(
|
||||
Math.ceil(Math.random() * Date.now())
|
||||
.toPrecision(length)
|
||||
.toString()
|
||||
.replace(".", "")
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
@@ -51,7 +64,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
|
||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||
|
||||
const removeCustom = (index: number) => {
|
||||
@@ -64,6 +76,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||
}, [eventType.users, hashedUrl, team?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventType.customInputs) {
|
||||
setCustomInputs(eventType.customInputs.sort((a, b) => a.id - b.id));
|
||||
}
|
||||
}, [eventType.customInputs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/**
|
||||
@@ -239,13 +257,30 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
defaultValue={eventType.successRedirectUrl || ""}
|
||||
{...formMethods.register("successRedirectUrl")}
|
||||
/>
|
||||
<div className="mt-2 flex">
|
||||
<Checkbox
|
||||
description={t("disable_success_page")}
|
||||
// Disable if it's not Self Hosted or if the redirect url is not set
|
||||
disabled={!IS_SELF_HOSTED || !formMethods.watch("successRedirectUrl")}
|
||||
{...formMethods.register("metadata.disableSuccessPage")}
|
||||
/>
|
||||
{/*TODO: Extract it out into a component when used more than once*/}
|
||||
{!IS_SELF_HOSTED && (
|
||||
<Link href="https://cal.com/pricing">
|
||||
<a target="_blank">
|
||||
<Badge variant="orange" className="ml-2">
|
||||
Platform Only
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<SettingsToggle
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("private_link")}
|
||||
@@ -374,7 +409,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<Controller
|
||||
name="customInputs"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.customInputs.sort((a, b) => a.id - b.id) || []}
|
||||
defaultValue={customInputs}
|
||||
render={() => (
|
||||
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
|
||||
<DialogContent
|
||||
@@ -386,13 +421,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
selectedCustomInput={selectedCustomInput}
|
||||
onSubmit={(values) => {
|
||||
const customInput: CustomInputParsed = {
|
||||
id: -1,
|
||||
id: getRandomId(),
|
||||
eventTypeId: -1,
|
||||
label: values.label,
|
||||
placeholder: values.placeholder,
|
||||
required: values.required,
|
||||
type: values.type,
|
||||
options: values.options,
|
||||
hasToBeCreated: true,
|
||||
};
|
||||
if (selectedCustomInput) {
|
||||
selectedCustomInput.label = customInput.label;
|
||||
@@ -400,6 +436,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
selectedCustomInput.required = customInput.required;
|
||||
selectedCustomInput.type = customInput.type;
|
||||
selectedCustomInput.options = customInput.options || undefined;
|
||||
selectedCustomInput.hasToBeCreated = false;
|
||||
// Update by id
|
||||
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
|
||||
customInputs[inputIndex] = selectedCustomInput;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||
@@ -9,7 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Button, EmptyScreen, ErrorBoundary, Icon } from "@calcom/ui";
|
||||
|
||||
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
|
||||
type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||
EventTypeAppCardComponentProps["eventType"];
|
||||
|
||||
function AppCardWrapper({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PeriodType } from "@calcom/prisma/client";
|
||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, Icon, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const minimumBookingNoticeType = useRef<DurationType>(findDurationType(eventType.minimumBookingNotice));
|
||||
@@ -197,7 +197,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="120"
|
||||
className="mr-2 mb-0 h-[38px] rounded-[4px]"
|
||||
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
{...formMethods.register("minimumBookingNoticeInDurationType", {
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onMinimumNoticeChange(event.target.value),
|
||||
@@ -327,7 +327,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="inline-flex space-x-2 ltr:ml-2 rtl:mr-2 rtl:space-x-reverse">
|
||||
<div className="inline-flex space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
@@ -347,7 +347,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? <span className="ltr:ml-2 rtl:mr-2"> {period.suffix}</span> : null}
|
||||
{period.suffix ? (
|
||||
<span className="ltr:ml-2 ltr:mr-2 rtl:ml-2"> {period.suffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -410,7 +412,9 @@ const BookingLimits = () => {
|
||||
.map(([key, bookingAmount]) => {
|
||||
const bookingLimitKey = key as BookingLimitsKey;
|
||||
return (
|
||||
<div className="mb-2 flex items-center space-x-2 text-sm" key={bookingLimitKey}>
|
||||
<div
|
||||
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse"
|
||||
key={bookingLimitKey}>
|
||||
<Input
|
||||
id={`${bookingLimitKey}-limit`}
|
||||
type="number"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { EventTypeSetupInfered } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
|
||||
import RecurringEventController from "./RecurringEventController";
|
||||
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
|
||||
const requirePayment = stripeAppData.price > 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import { MultiValue } from "react-select";
|
||||
@@ -23,7 +23,7 @@ type OptionTypeBase = {
|
||||
};
|
||||
|
||||
export const EventSetupTab = (
|
||||
props: Pick<EventTypeSetupInfered, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
@@ -169,7 +169,7 @@ export const EventSetupTab = (
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
|
||||
{location[eventLocationType.defaultValueVariable] || eventLocationType.label}
|
||||
{t(location[eventLocationType.defaultValueVariable] || eventLocationType.label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
@@ -342,7 +342,7 @@ export const EventSetupTab = (
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation ? { value: selectedLocation.value, label: selectedLocation.label } : undefined
|
||||
selectedLocation ? { value: selectedLocation.value, label: t(selectedLocation.label) } : undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { SchedulingType } from "@prisma/client/";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar, Button, Icon, Label, Select } from "@calcom/ui";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
|
||||
interface IMemberToValue {
|
||||
id: number | null;
|
||||
@@ -26,8 +26,7 @@ export const EventTeamTab = ({
|
||||
eventType,
|
||||
team,
|
||||
teamMembers,
|
||||
currentUserMembership,
|
||||
}: Pick<EventTypeSetupInfered, "eventType" | "teamMembers" | "team" | "currentUserMembership">) => {
|
||||
}: Pick<EventTypeSetupProps, "eventType" | "teamMembers" | "team">) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -102,48 +101,3 @@ export const EventTeamTab = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { TbWebhook } from "react-icons/tb";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Button, Dialog, DialogContent, EmptyScreen, Icon, showToast } from "@ca
|
||||
export const EventTeamWebhooksTab = ({
|
||||
eventType,
|
||||
team,
|
||||
}: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
|
||||
}: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo, useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { TbWebhook } from "react-icons/tb";
|
||||
@@ -37,9 +37,9 @@ import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
eventType: EventTypeSetupInfered["eventType"];
|
||||
currentUserMembership: EventTypeSetupInfered["currentUserMembership"];
|
||||
team: EventTypeSetupInfered["team"];
|
||||
eventType: EventTypeSetupProps["eventType"];
|
||||
currentUserMembership: EventTypeSetupProps["currentUserMembership"];
|
||||
team: EventTypeSetupProps["team"];
|
||||
disableBorder?: boolean;
|
||||
enabledAppsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function RecurringEventController({
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -89,7 +89,7 @@ export default function RecurringEventController({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("for_a_maximum_of")}</p>
|
||||
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -105,7 +105,7 @@ export default function RecurringEventController({
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="mr-2 text-neutral-900">
|
||||
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
|
||||
@@ -82,7 +82,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
|
||||
@@ -177,7 +177,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
@@ -186,13 +186,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ShieldCheckIcon } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-white" />
|
||||
<Icon.ShieldCheckIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
|
||||
@@ -62,7 +62,7 @@ export function CalendarSwitch(props: {
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="flex space-x-2 py-1">
|
||||
<div className="flex space-x-2 py-1 rtl:space-x-reverse">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
|
||||
@@ -90,7 +90,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
|
||||
@@ -170,7 +170,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
@@ -179,13 +179,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { APP_NAME, LOGO } from "@calcom/lib/constants";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
import Credits from "../../../../packages/ui/v2/core/Credits";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -23,6 +24,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mb-auto h-4" src={LOGO} alt={`${APP_NAME} Logo`} />
|
||||
)}
|
||||
|
||||
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
|
||||
{props.heading && <h2 className="font-cal text-center text-3xl text-neutral-900">{props.heading}</h2>}
|
||||
</div>
|
||||
@@ -35,7 +37,10 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
<div className="border-1 mx-2 rounded-md border-gray-200 bg-white px-4 py-10 sm:px-10">
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="mt-8 text-center text-sm text-neutral-600">{props.footerText}</div>
|
||||
<div className="mt-8 text-center text-sm text-neutral-600">
|
||||
{props.footerText}
|
||||
<Credits />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={idx} className="-mr-2 inline-block">
|
||||
<li key={idx} className="-ltr:mr-2 inline-block rtl:ml-2">
|
||||
{item.href ? (
|
||||
<Link href={item.href}>
|
||||
<a>{avatar}</a>
|
||||
|
||||
@@ -11,17 +11,7 @@ import { User } from "@calcom/prisma/client";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
StarIconSolid,
|
||||
} from "@calcom/ui";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogHeader, Icon, Input, Label } from "@calcom/ui";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
UPGRADE = "UPGRADE",
|
||||
@@ -240,7 +230,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
isInputUsernamePremium ? "text-orange-400" : "",
|
||||
usernameIsAvailable ? "" : ""
|
||||
)}>
|
||||
{isInputUsernamePremium ? <StarIconSolid className="mt-[2px] w-6" /> : <></>}
|
||||
{isInputUsernamePremium ? <Icon.StarIconSolid className="mt-[2px] w-6" /> : <></>}
|
||||
{!isInputUsernamePremium && usernameIsAvailable ? <Icon.FiCheck className="mt-2 w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,17 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogHeader, Icon, Input, Label } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
@@ -77,10 +87,9 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
const ActionButtons = () => {
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="flex flex-row">
|
||||
<div className="rlt:space-x-reverse mt-px flex flex-row space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid="update-username-btn">
|
||||
{t("update")}
|
||||
@@ -88,7 +97,6 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="mx-2"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
@@ -110,26 +118,20 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-2 flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
<TextField
|
||||
ref={usernameRef}
|
||||
name="username"
|
||||
value={inputUsernameValue}
|
||||
addOnLeading={
|
||||
<>{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/</>
|
||||
}
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 h-6 rounded-md rounded-l-none",
|
||||
"mb-0 mt-0 h-6 rounded-md ltr:rounded-l-none rtl:rounded-r-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
@@ -141,18 +143,18 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
data-testid="username-input"
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-0 flex flex-row">
|
||||
<div className="absolute right-[2px] top-6 flex flex-row">
|
||||
<span className={classNames("mx-2 py-2")}>
|
||||
{usernameIsAvailable ? <Icon.FiCheck className="mt-[2px] w-6" /> : <></>}
|
||||
{usernameIsAvailable ? <Icon.FiCheck className="w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:inline">
|
||||
<div className="mt-5 hidden md:inline">
|
||||
<ActionButtons />
|
||||
</div>
|
||||
</div>
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">{t("username_already_taken")}</p>}
|
||||
|
||||
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
|
||||
@@ -109,7 +109,7 @@ function Select<
|
||||
IndicatorSeparator: () => null,
|
||||
Input: InputComponent,
|
||||
}}
|
||||
className={classNames("text-sm", className)}
|
||||
className={classNames("border-0 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import parser from "accept-language-parser";
|
||||
import { IncomingMessage } from "http";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Maybe } from "@calcom/trpc/server";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
@@ -17,6 +16,7 @@ export function getLocaleFromHeaders(req: IncomingMessage): string {
|
||||
}
|
||||
|
||||
export const getOrSetUserLocaleFromHeaders = async (req: IncomingMessage): Promise<string> => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const session = await getSession({ req });
|
||||
const preferredLocale = getLocaleFromHeaders(req);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -27,7 +27,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
|
||||
import prisma from "@calcom/prisma";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { BadgeCheckIcon, Icon } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import { EmbedProps } from "@lib/withEmbedSsr";
|
||||
@@ -139,7 +139,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
|
||||
{nameOrUsername}
|
||||
{user.verified && (
|
||||
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
||||
<Icon.BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
||||
)}
|
||||
</h1>
|
||||
<p className="dark:text-darkgray-600 text-s text-neutral-500">{user.bio}</p>
|
||||
|
||||
@@ -19,7 +19,6 @@ import { seoConfig } from "@lib/config/next-seo.config";
|
||||
|
||||
import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
|
||||
import "../styles/fonts.css";
|
||||
import "../styles/globals.css";
|
||||
|
||||
function MyApp(props: AppProps) {
|
||||
@@ -32,7 +31,6 @@ function MyApp(props: AppProps) {
|
||||
}
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<AppProviders {...props}>
|
||||
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
||||
import Script from "next/script";
|
||||
|
||||
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
|
||||
function toRunBeforeReactOnClient() {
|
||||
@@ -47,7 +49,7 @@ class MyDocument extends Document<Props> {
|
||||
|
||||
render() {
|
||||
const { locale } = this.props.__NEXT_DATA__;
|
||||
const dir = locale === "ar" || locale === "he" ? "rtl" : "ltr";
|
||||
const dir = getDirFromLang(locale);
|
||||
return (
|
||||
<Html lang={locale} dir={dir}>
|
||||
<Head>
|
||||
|
||||
@@ -138,7 +138,7 @@ const providers: Provider[] = [
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: "USER",
|
||||
role: "INACTIVE_ADMIN",
|
||||
belongsToActiveTeam: hasActiveTeams,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetcher } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
const getAccessLinkSchema = z.union([
|
||||
z.object({
|
||||
download_link: z.string(),
|
||||
expires: z.number(),
|
||||
}),
|
||||
z.object({}),
|
||||
]);
|
||||
|
||||
const requestQuery = z.object({
|
||||
recordingId: z.string(),
|
||||
});
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<z.infer<typeof getAccessLinkSchema> | void>
|
||||
) {
|
||||
const { recordingId } = requestQuery.parse(req.query);
|
||||
const session = await getSession({ req });
|
||||
|
||||
// Check if user belong to active team
|
||||
if (!session?.user?.belongsToActiveTeam) {
|
||||
return res.status(403);
|
||||
}
|
||||
try {
|
||||
const response = await fetcher(`/recordings/${recordingId}/access-link`).then(getAccessLinkSchema.parse);
|
||||
|
||||
if ("download_link" in response && response.download_link) {
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
|
||||
return res.status(400);
|
||||
} catch (err) {
|
||||
res.status(500);
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
InstalledAppsLayout,
|
||||
List,
|
||||
ShellSubHeading,
|
||||
SkeletonLoader,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function Login({
|
||||
{...register("email")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 -top-[6px] z-10">
|
||||
<div className="absolute -top-[6px] z-10 ltr:right-0 rtl:left-0">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a tabIndex={-1} className="text-sm font-medium text-gray-600">
|
||||
{t("forgot")}
|
||||
|
||||
@@ -97,7 +97,7 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
<TextField
|
||||
addOnLeading={
|
||||
!longWebsiteUrl && (
|
||||
<span className="items-centerpx-3 inline-flex rounded-none text-sm text-gray-500">
|
||||
<span className="inline-flex items-center rounded-none px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -174,7 +174,6 @@ const querySchema = z.object({
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
allRemainingBookings,
|
||||
isSuccessBookingPage,
|
||||
@@ -636,7 +635,7 @@ export default function Success(props: SuccessProps) {
|
||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||
: "")
|
||||
}>
|
||||
<a className="mr-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
||||
<a className="h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 ltr:mr-2 rtl:ml-2 dark:border-neutral-700 dark:text-white">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
@@ -1051,6 +1050,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs),
|
||||
};
|
||||
|
||||
if (eventType.metadata?.disableSuccessPage && eventType.successRedirectUrl) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: eventType.successRedirectUrl,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: eventType.team?.name || eventType.users[0]?.name || null,
|
||||
email: eventType.team ? null : eventType.users[0].email || null,
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function Bookings() {
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p>
|
||||
{/* <p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p> */}
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PeriodType, Prisma, SchedulingType } from "@prisma/client";
|
||||
import { PeriodType, SchedulingType } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils";
|
||||
import { EventLocationType, LocationObject } from "@calcom/core/location";
|
||||
import { parseBookingLimit, parseRecurringEvent, validateBookingLimitOrder } from "@calcom/lib";
|
||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||
import { EventLocationType } from "@calcom/core/location";
|
||||
import { validateBookingLimitOrder } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getEventTypeById from "@calcom/lib/getEventTypeById";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { trpc, RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Form, showToast } from "@calcom/ui";
|
||||
|
||||
@@ -41,7 +38,6 @@ import { EventTeamWebhooksTab } from "@components/eventtype/EventTeamWebhooksTab
|
||||
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
||||
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type FormValues = {
|
||||
@@ -110,27 +106,24 @@ const querySchema = z.object({
|
||||
.default("setup"),
|
||||
});
|
||||
|
||||
export type EventTypeSetupInfered = inferSSRProps<typeof getServerSideProps>;
|
||||
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||
|
||||
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const { tabName } = querySchema.parse(router.query);
|
||||
|
||||
const { data: eventTypeApps } = trpc.viewer.apps.useQuery({
|
||||
extendsFeature: "EventType",
|
||||
});
|
||||
|
||||
const { eventType: dbEventType, locationOptions, team, teamMembers } = props;
|
||||
// TODO: It isn't a good idea to maintain state using setEventType. If we want to connect the SSR'd data to tRPC, we should useQuery(["viewer.eventTypes.get"]) with initialData
|
||||
// Due to this change, when Form is saved, there is no way to propagate that info to eventType (e.g. disabling stripe app doesn't allow recurring tab to be enabled without refresh).
|
||||
const [eventType, setEventType] = useState(dbEventType);
|
||||
|
||||
const router = useRouter();
|
||||
const { tabName } = querySchema.parse(router.query);
|
||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership } = props;
|
||||
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onSuccess: async ({ eventType: newEventType }) => {
|
||||
setEventType({ ...eventType, slug: newEventType.slug });
|
||||
onSuccess: async () => {
|
||||
showToast(
|
||||
t("event_type_updated_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
@@ -138,6 +131,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
"success"
|
||||
);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
let message = "";
|
||||
if (err instanceof HttpError) {
|
||||
@@ -241,14 +237,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
/>
|
||||
),
|
||||
availability: <AvailabilityTab isTeamEvent={!!team} />,
|
||||
team: (
|
||||
<EventTeamTab
|
||||
eventType={eventType}
|
||||
teamMembers={teamMembers}
|
||||
team={team}
|
||||
currentUserMembership={props.currentUserMembership}
|
||||
/>
|
||||
),
|
||||
team: <EventTeamTab eventType={eventType} teamMembers={teamMembers} team={team} />,
|
||||
limits: <EventLimitsTab eventType={eventType} />,
|
||||
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
|
||||
recurring: <EventRecurringTab eventType={eventType} />,
|
||||
@@ -272,7 +261,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
isUpdateMutationLoading={updateMutation.isLoading}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
currentUserMembership={props.currentUserMembership}>
|
||||
currentUserMembership={currentUserMembership}>
|
||||
<Form
|
||||
form={formMethods}
|
||||
id="event-type-form"
|
||||
@@ -337,6 +326,18 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypePageWrapper = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { data, isLoading } = trpc.viewer.eventTypes.get.useQuery(
|
||||
{ id: props.type },
|
||||
{
|
||||
initialData: props.initialData,
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading || !data) return null;
|
||||
return <EventTypePage {...data} />;
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, query } = context;
|
||||
const session = await getSession({ req });
|
||||
@@ -358,244 +359,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
};
|
||||
}
|
||||
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
name: true,
|
||||
username: true,
|
||||
id: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
defaultScheduleId: 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,
|
||||
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,
|
||||
bookingLimits: true,
|
||||
successRedirectUrl: true,
|
||||
currency: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
members: {
|
||||
where: {
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
user: {
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
try {
|
||||
const res = await getEventTypeById({ eventTypeId: typeParam, userId: session.user.id, prisma });
|
||||
return {
|
||||
props: {
|
||||
session,
|
||||
type: typeParam,
|
||||
trpcState: ssr.dehydrate(),
|
||||
initialData: {
|
||||
eventType: res.eventType,
|
||||
locationOptions: res.locationOptions,
|
||||
team: res.team,
|
||||
teamMembers: res.teamMembers,
|
||||
currentUserMembership: res.currentUserMembership,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: userSelect,
|
||||
},
|
||||
schedulingType: true,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
userId: true,
|
||||
price: true,
|
||||
destinationCalendar: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
webhooks: {
|
||||
select: {
|
||||
id: true,
|
||||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
active: true,
|
||||
eventTriggers: true,
|
||||
secret: true,
|
||||
eventTypeId: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!rawEventType) {
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
app: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { locations, metadata, ...restEventType } = rawEventType;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {})!;
|
||||
const apps = newMetadata.apps || {};
|
||||
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
|
||||
newMetadata.apps = {
|
||||
...apps,
|
||||
stripe: {
|
||||
...getStripeAppData(eventTypeWithParsedMetadata, true),
|
||||
currency:
|
||||
(
|
||||
credentials.find((integration) => integration.type === "stripe_payment")
|
||||
?.key as unknown as StripeData
|
||||
)?.default_currency || "usd",
|
||||
},
|
||||
giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true),
|
||||
rainbow: getEventTypeAppData(eventTypeWithParsedMetadata, "rainbow", true),
|
||||
};
|
||||
|
||||
// TODO: How to extract metadata schema from _EventTypeModel to be able to parse it?
|
||||
// const parsedMetaData = _EventTypeModel.parse(newMetadata);
|
||||
const parsedMetaData = newMetadata;
|
||||
|
||||
const parsedCustomInputs = (rawEventType.customInputs || []).map((input) => customInputSchema.parse(input));
|
||||
|
||||
const eventType = {
|
||||
...restEventType,
|
||||
schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null,
|
||||
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
|
||||
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
|
||||
locations: locations as unknown as LocationObject[],
|
||||
metadata: parsedMetaData,
|
||||
customInputs: parsedCustomInputs,
|
||||
};
|
||||
|
||||
// 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 = await getEnabledApps(credentials);
|
||||
const locationOptions = getLocationOptions(integrations, t);
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
});
|
||||
|
||||
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,
|
||||
team: eventTypeObject.team || null,
|
||||
teamMembers,
|
||||
currentUserMembership,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default EventTypePage;
|
||||
export default EventTypePageWrapper;
|
||||
|
||||
@@ -78,7 +78,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
className="hidden font-normal leading-4 text-gray-600 sm:inline"
|
||||
data-testid={"event-type-slug-" + type.id}>{`/${group.profile.slug}/${type.slug}`}</small>
|
||||
{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">
|
||||
<span className="rtl:ml-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2 ltr:mr-2">
|
||||
{t("readonly") as string}
|
||||
</span>
|
||||
)}
|
||||
@@ -394,7 +394,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-9 mr-5 flex sm:hidden">
|
||||
<div className="min-w-9 mx-5 flex sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button type="button" size="icon" color="secondary" StartIcon={Icon.FiMoreHorizontal} />
|
||||
@@ -531,7 +531,7 @@ const EventTypeListHeading = ({
|
||||
<a className="font-bold">{profile?.name || ""}</a>
|
||||
</Link>
|
||||
{membershipCount && teamId && (
|
||||
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
|
||||
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 ltr:mr-2 rtl:ml-2">
|
||||
<Link href={`/settings/teams/${teamId}/members`}>
|
||||
<a>
|
||||
<Badge variant="gray">
|
||||
|
||||
@@ -21,7 +21,7 @@ function AdminView() {
|
||||
const enteredUsername = usernameRef.current?.value.toLowerCase();
|
||||
signIn("impersonation-auth", { username: enteredUsername });
|
||||
}}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<TextField
|
||||
containerClassName="w-full"
|
||||
name={t("user_impersonation_heading")}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getSettingsLayout as getLayout,
|
||||
Icon,
|
||||
Meta,
|
||||
SkeletonLoader,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
@@ -177,7 +177,9 @@ const AppearanceView = () => {
|
||||
<div className="flex w-full text-sm">
|
||||
<div className="mr-1 flex-grow">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 font-semibold">{t("disable_cal_branding", { appName: APP_NAME })}</p>
|
||||
<p className="font-semibold ltr:mr-2 rtl:ml-2">
|
||||
{t("disable_cal_branding", { appName: APP_NAME })}
|
||||
</p>
|
||||
<Badge variant="gray">{t("pro")}</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 text-gray-600">{t("removes_cal_branding", { appName: APP_NAME })}</p>
|
||||
|
||||
@@ -156,7 +156,7 @@ const CalendarsView = () => {
|
||||
)
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="mb-1 space-x-2">
|
||||
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
|
||||
<Link href={"/apps/" + item.integration.slug}>
|
||||
{item.integration.name || item.integration.title}
|
||||
</Link>
|
||||
|
||||
@@ -82,7 +82,7 @@ const ConferencingLayout = () => {
|
||||
app.logo && <img className="h-10 w-10" src={app.logo} alt={app.title} />
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="mb-1 space-x-2">
|
||||
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
|
||||
<h3 className="truncate text-sm font-medium text-neutral-900">{app.title}</h3>
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{app.description}</ListItemText>
|
||||
|
||||
@@ -340,7 +340,7 @@ const ProfileForm = ({
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={emailMd5} size="lg" />
|
||||
<div className="ml-4">
|
||||
<div className="ltr:ml-4 rtl:mr-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
|
||||
@@ -49,7 +49,7 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
<Link href={`/team/${team.slug}/${type.slug}`}>
|
||||
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
|
||||
<div className="flex-shrink">
|
||||
<div className="flex flex-wrap items-center space-x-2">
|
||||
<div className="flex flex-wrap items-center space-x-2 rtl:space-x-reverse">
|
||||
<h2 className="dark:text-darkgray-700 text-sm font-semibold text-gray-700">{type.title}</h2>
|
||||
</div>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
|
||||
@@ -14,8 +14,11 @@ function Teams() {
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={
|
||||
<Button type="button" href={`${WEBAPP_URL}/settings/teams/new?returnTo=/teams`}>
|
||||
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-white group-hover:text-black ltr:mr-2 rtl:ml-2" />
|
||||
<Button
|
||||
size="fab"
|
||||
StartIcon={Icon.FiPlus}
|
||||
type="button"
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
}>
|
||||
|
||||
@@ -1390,7 +1390,7 @@
|
||||
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
|
||||
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
|
||||
"invalid_credential": "لا! يبدو أن الصلاحية انتهت أو تم إلغاؤها. يرجى إعادة التثبيت مرة أخرى.",
|
||||
"choose_common_schedule_team_event": "اختر جدولاً زمنياً مشتركاً",
|
||||
"choose_common_schedule_team_event": "اختر جدولًا زمنيًا مشتركًا",
|
||||
"choose_common_schedule_team_event_description": "قم بتمكين هذا الخيار إذا كنت ترغب في استخدام جدول زمني مشترك بين المضيفين. عند تعطيل هذا الخيار، سيتم حجز كل مضيف على أساس جدوله الافتراضي.",
|
||||
"reason": "السبب",
|
||||
"sender_id": "معرّف المرسل",
|
||||
|
||||
@@ -454,14 +454,17 @@
|
||||
"additional_guests": "+ Weitere Gäste",
|
||||
"your_name": "Ihr Name",
|
||||
"email_address": "E-Mail Adresse",
|
||||
"enter_valid_email": "Bitte geben Sie eine gültige Emaildresse ein",
|
||||
"enter_valid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"location": "Ort",
|
||||
"address": "Adresse",
|
||||
"enter_address": "Geben Sie eine Adresse ein",
|
||||
"in_person_attendee_address": "In Person (Adresse von Ihnen)",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"additional_notes": "Zusätzliche Notizen",
|
||||
"booking_fail": "Termin konnte nicht gebucht werden.",
|
||||
"reschedule_fail": "Termin konnte nicht neugeplant werden.",
|
||||
"share_additional_notes": "Bitten teilen Sie Notizen zur Vorbereitung des Termins, falls nötig.",
|
||||
"share_additional_notes": "Teilen Sie uns weitere Informationen mit, die zur Vorbereitung des Termins hilfreich sind.",
|
||||
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
|
||||
"in_person_meeting": "Vor-Ort-Termin",
|
||||
|
||||
@@ -388,7 +388,7 @@
|
||||
"further_billing_help": "If you need any further help with billing, our support team are here to help.",
|
||||
"contact": "Contact",
|
||||
"our_support_team": "our support team",
|
||||
"contact_our_support_team":"Contact our support team",
|
||||
"contact_our_support_team": "Contact our support team",
|
||||
"uh_oh": "Uh oh!",
|
||||
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
|
||||
"edit_logo": "Edit logo",
|
||||
@@ -460,6 +460,9 @@
|
||||
"email_address": "Email address",
|
||||
"enter_valid_email": "Please enter a valid email",
|
||||
"location": "Location",
|
||||
"address": "Address",
|
||||
"enter_address": "Enter address",
|
||||
"in_person_attendee_address": "In Person (Attendee Address)",
|
||||
"yes": "yes",
|
||||
"no": "no",
|
||||
"additional_notes": "Additional notes",
|
||||
@@ -1091,6 +1094,10 @@
|
||||
"navigate": "Navigate",
|
||||
"open": "Open",
|
||||
"close": "Close",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
|
||||
"upgrade_to_access_recordings_description":"Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
|
||||
"recordings_are_part_of_the_teams_plan":"Recordings are part of the teams plan",
|
||||
"team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.",
|
||||
"team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.",
|
||||
"show_eventtype_on_profile": "Show on Profile",
|
||||
@@ -1148,8 +1155,11 @@
|
||||
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
|
||||
"delete_my_account": "Delete my account",
|
||||
"start_of_week": "Start of week",
|
||||
"recordings_title": "Recordings",
|
||||
"recording": "Recording",
|
||||
"select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.",
|
||||
"check_for_conflicts": "Check for conflicts",
|
||||
"view_recordings": "View recordings",
|
||||
"adding_events_to": "Adding events to",
|
||||
"follow_system_preferences": "Follow system preferences",
|
||||
"custom_brand_colors": "Custom brand colors",
|
||||
@@ -1221,6 +1231,7 @@
|
||||
"to": "To",
|
||||
"workflow_turned_on_successfully": "{{workflowName}} workflow turned {{offOn}} successfully",
|
||||
"download_responses": "Download Responses",
|
||||
"download": "Download",
|
||||
"create_your_first_form": "Create your first form",
|
||||
"create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.",
|
||||
"create_your_first_webhook": "Create your first Webhook",
|
||||
@@ -1473,5 +1484,9 @@
|
||||
"send_code" : "Send code",
|
||||
"number_verified": "Number Verified",
|
||||
"create_your_first_team_webhook_description": "Create your first webhook for this team event type",
|
||||
"create_webhook_team_event_type": "Create a webhook for this team event type"
|
||||
"create_webhook_team_event_type": "Create a webhook for this team event type",
|
||||
"disable_success_page":"Disable Success Page (only works if you have a redirect URL)",
|
||||
"invalid_admin_password": "You are set as an admin but you do not have a password length of at least 15 characters",
|
||||
"change_password_admin": "Change Password to gain admin access",
|
||||
"username_already_taken": "Username is already taken"
|
||||
}
|
||||
|
||||
@@ -611,7 +611,7 @@
|
||||
"monthly_other": "חודשים",
|
||||
"yearly_one": "שנה",
|
||||
"yearly_other": "שנים",
|
||||
"plus_more": "ועוד {{count}}",
|
||||
"plus_more": "{{count}} נוספים",
|
||||
"max": "מקסימום",
|
||||
"single_theme": "ערכת נושא בודדת",
|
||||
"brand_color": "צבע המותג",
|
||||
|
||||
@@ -303,6 +303,7 @@
|
||||
"cancelling_all_recurring": "Dette er alle gjenværende forekomster i den gjentakende hendelsen.",
|
||||
"error_with_status_code_occured": "Det oppstod en feil med statuskoden {{status}}.",
|
||||
"booking_already_cancelled": "Denne bookingen var allerede kansellert",
|
||||
"booking_already_accepted_rejected": "Denne bookingen var allerede akseptert eller avvist",
|
||||
"go_back_home": "Gå til hjem",
|
||||
"or_go_back_home": "Eller gå tilbake til hjem",
|
||||
"no_availability": "Utilgjengelig",
|
||||
@@ -385,6 +386,8 @@
|
||||
"go_to_billing_portal": "Gå til betalingsportalen",
|
||||
"need_anything_else": "Trenger du noe annet?",
|
||||
"further_billing_help": "Hvis du trenger ytterligere hjelp med betaling, er supportteamet vårt her for å hjelpe.",
|
||||
"contact": "Kontakt",
|
||||
"our_support_team": "vårt support-team",
|
||||
"contact_our_support_team": "Kontakt supportteamet vårt",
|
||||
"uh_oh": "Uh oh!",
|
||||
"no_event_types_have_been_setup": "Denne brukeren har ikke konfigurert noen hendelsestyper ennå.",
|
||||
@@ -629,6 +632,7 @@
|
||||
"teams": "Team",
|
||||
"team": "Team",
|
||||
"team_billing": "Team Fakturering",
|
||||
"team_billing_description": "Administrer betaling for ditt team",
|
||||
"upgrade_to_flexible_pro_title": "Vi har endret fakturering for team",
|
||||
"upgrade_to_flexible_pro_message": "Det er medlemmer i teamet ditt uten sete. Oppgrader proffplanen din for å dekke manglende seter.",
|
||||
"changed_team_billing_info": "Fra januar 2022 tar vi betalt per sete for teammedlemmer. Medlemmer av teamet ditt som hadde PRO gratis, har nå en 14 dagers prøveversjon. Når prøveperioden deres utløper, vil disse medlemmene bli skjult for teamet ditt med mindre du oppgraderer nå.",
|
||||
@@ -704,6 +708,7 @@
|
||||
"hide_event_type": "Skjul hendelsestype",
|
||||
"edit_location": "Rediger sted",
|
||||
"into_the_future": "i fremtiden",
|
||||
"when_booked_with_less_than_notice": "Når booket med mindre enn <time></time> varsel",
|
||||
"within_date_range": "Innenfor en datoperiode",
|
||||
"indefinitely_into_future": "Uendelig inn i fremtiden",
|
||||
"add_new_custom_input_field": "Legg til nytt egendefinert input felt",
|
||||
@@ -723,6 +728,8 @@
|
||||
"delete_account_confirmation_message": "Er du sikker på at du vil slette {{appName}}-kontoen din? Alle du har delt konto-lenken med, vil ikke lenger kunne booke ved å bruke den, og eventuelle innstillinger du har lagret vil gå tapt.",
|
||||
"integrations": "Integrasjoner",
|
||||
"apps": "Apper",
|
||||
"apps_description": "Her finner du en liste over appene dine",
|
||||
"apps_listing": "App liste",
|
||||
"category_apps": "{{category}} apper",
|
||||
"app_store": "App Butikken",
|
||||
"app_store_description": "Kobler sammen folk, teknologi og arbeidsplassen.",
|
||||
@@ -746,6 +753,7 @@
|
||||
"toggle_calendars_conflict": "Huk av kalenderne du vil sjekke for konfliker for å hindre dobbelt-bookinger.",
|
||||
"select_destination_calendar": "Opprett hendelser på",
|
||||
"connect_additional_calendar": "Koble til enda en kalender",
|
||||
"calendar_updated_successfully": "Kalenderen er oppdatert",
|
||||
"conferencing": "Konferansering",
|
||||
"calendar": "Kalender",
|
||||
"payments": "Betalinger",
|
||||
@@ -1028,6 +1036,9 @@
|
||||
"error_removing_app": "Feil ved fjerning av app",
|
||||
"web_conference": "Nett-konferanse",
|
||||
"requires_confirmation": "Krever bekreftelse",
|
||||
"always_requires_confirmation": "Alltid",
|
||||
"requires_confirmation_threshold": "Krever bekreftelse hvis booket med < {{time}} $t({{unit}}_timeUnit) varsel",
|
||||
"may_require_confirmation": "Kan kreve bekreftelse",
|
||||
"nr_event_type_one": "{{count}} hendelsestype",
|
||||
"nr_event_type_other": "{{count}} hendelsestyper",
|
||||
"add_action": "Legg til handling",
|
||||
@@ -1080,6 +1091,10 @@
|
||||
"navigate": "Naviger",
|
||||
"open": "Åpne",
|
||||
"close": "Lukk",
|
||||
"upgrade": "Oppgrader",
|
||||
"upgrade_to_access_recordings_title": "Oppgrader for å få tilgang til opptak",
|
||||
"upgrade_to_access_recordings_description": "Opptak er kun tilgjengelig som en del av team-planen vår. Oppgrader for å begynne å ta opp samtalene dine",
|
||||
"recordings_are_part_of_the_teams_plan": "Opptak er en del av team-planen",
|
||||
"team_feature_teams": "Dette er en Team-funksjon. Oppgrader til Team for å se teamets tilgjengelighet.",
|
||||
"team_feature_workflows": "Dette er en Team-funksjon. Oppgrader til Team for å automatisere hendelsesvarslene og påminnelsene dine med arbeidsflyter.",
|
||||
"show_eventtype_on_profile": "Vis på Profil",
|
||||
@@ -1137,8 +1152,11 @@
|
||||
"confirm_delete_account_modal": "Er du sikker på at du vil slette {{appName}}-kontoen din?",
|
||||
"delete_my_account": "Slett kontoen min",
|
||||
"start_of_week": "Begynnelsen av uken",
|
||||
"recordings_title": "Opptak",
|
||||
"recording": "Opptak",
|
||||
"select_calendars": "Velg hvilke kalendere du vil sjekke for konflikter for å forhindre dobbeltbookinger.",
|
||||
"check_for_conflicts": "Se etter konflikter",
|
||||
"view_recordings": "Vis opptak",
|
||||
"adding_events_to": "Legger til hendelser til",
|
||||
"follow_system_preferences": "Følg systempreferansene",
|
||||
"custom_brand_colors": "Egendefinerte merkevare-farger",
|
||||
@@ -1171,8 +1189,11 @@
|
||||
"invoices": "Fakturaer",
|
||||
"embeds": "Innbygginger",
|
||||
"impersonation": "Etterligning",
|
||||
"impersonation_description": "Innstillinger for å administrere etterligning av brukere",
|
||||
"users": "Brukere",
|
||||
"profile_description": "Administrer innstillinger for {{appName}} profilen din",
|
||||
"users_description": "Her finner du en liste over alle brukere",
|
||||
"users_listing": "Bruker liste",
|
||||
"general_description": "Administrer innstillinger for språk og tidssone",
|
||||
"calendars_description": "Konfigurer hvordan hendelsestypene dine samhandler med kalenderne dine",
|
||||
"appearance_description": "Administrer innstillinger for booking-utseendet",
|
||||
@@ -1207,6 +1228,7 @@
|
||||
"to": "Til",
|
||||
"workflow_turned_on_successfully": "Arbeidsflyten for {{workflowName}} ble {{offOn}} slått av",
|
||||
"download_responses": "Last ned Respons",
|
||||
"download": "Nedlasting",
|
||||
"create_your_first_form": "Lag ditt første skjema",
|
||||
"create_your_first_form_description": "Med Viderekoblings-skjema kan du stille kvalifiserende spørsmål og videresende til riktig person eller hendelsestype.",
|
||||
"create_your_first_webhook": "Lag din første Webhook",
|
||||
@@ -1315,6 +1337,8 @@
|
||||
"booking_confirmation_success": "Booking-bekreftelsen var vellykket",
|
||||
"booking_rejection_success": "Booking-avvisningen var vellykket",
|
||||
"booking_confirmation_fail": "Booking-bekreftelse feilet",
|
||||
"booking_tentative": "Denne bookingen er foreløpig",
|
||||
"booking_accept_intent": "Oops, jeg vil akseptere",
|
||||
"we_wont_show_again": "Vi vil ikke vise dette igjen",
|
||||
"couldnt_update_timezone": "Vi kunne ikke oppdatere tidssonen",
|
||||
"updated_timezone_to": "Oppdaterte tidssone til {{formattedCurrentTz}}",
|
||||
@@ -1377,7 +1401,9 @@
|
||||
"number_sms_notifications": "Telefonnummer (SMS varsler)",
|
||||
"attendee_email_workflow": "Deltakerens e-post",
|
||||
"attendee_email_info": "Den som booker sin e-post",
|
||||
"kbar_search_placeholder": "Skriv inn en kommando eller søk...",
|
||||
"invalid_credential": "Å nei! Det ser ut til at tillatelsen er utløpt eller tilbakekalt. Vennligst installer på nytt.",
|
||||
"reschedule_reason_success_page": "Årsak for endring av tidspunkt",
|
||||
"choose_common_schedule_team_event": "Velg en felles timeplan",
|
||||
"choose_common_schedule_team_event_description": "Aktiver dette hvis du vil bruke en felles tidsplan mellom verter. Når den er deaktivert, vil hver vert bookes basert på deres standard tidsplan.",
|
||||
"reason": "Årsak",
|
||||
@@ -1388,10 +1414,25 @@
|
||||
"route_to": "Videresend til",
|
||||
"test_preview_description": "Test viderekoblings-skjemaet ditt uten å sende noen data",
|
||||
"test_routing": "Test Viderekobling",
|
||||
"payment_app_disabled": "En administrator har deaktivert en betalingsapp",
|
||||
"edit_event_type": "Rediger hendelsestype",
|
||||
"collective_scheduling": "Kollektiv planlegging av tidspunkt",
|
||||
"make_it_easy_to_book": "Gjør det enkelt å booke teamet ditt når alle er tilgjengelige.",
|
||||
"find_the_best_person": "Finn den beste personen tilgjengelig og bla gjennom teamet ditt.",
|
||||
"fixed_round_robin": "Fast round robin",
|
||||
"add_one_fixed_attendee": "Legg til én fast deltaker og round robin gjennom et antall deltakere.",
|
||||
"calcom_is_better_with_team": "Cal.com er bedre med team",
|
||||
"add_your_team_members": "Legg til team-medlemmene dine i hendelsestypene dine. Bruk kollektiv planlegging av tidspunkt for å inkludere alle eller finn den mest passende personen med round robin-planlegging.",
|
||||
"booking_limit_reached": "Booking-grensen for denne hendelsestypen er nådd",
|
||||
"admin_has_disabled": "En administrator har deaktivert {{appName}}",
|
||||
"fill_this_field": "Vennligst fyll ut dette feltet",
|
||||
"options": "Alternativer",
|
||||
"enter_option": "Angi alternativ {{index}}",
|
||||
"add_an_option": "Legg til et alternativ",
|
||||
"radio": "Radio"
|
||||
"radio": "Radio",
|
||||
"create_your_first_team_webhook_description": "Opprett din første webhook for denne team-hendelsestypen",
|
||||
"create_webhook_team_event_type": "Opprett en webhook for denne team-hendelsestypen",
|
||||
"disable_success_page": "Deaktiver Suksess-side (fungerer bare hvis du har en omdirigerings-URL)",
|
||||
"invalid_admin_password": "Du er satt som administrator, men du har ikke en passordlengde på minst 15 tegn",
|
||||
"change_password_admin": "Endre passord for å få administratortilgang"
|
||||
}
|
||||
|
||||
@@ -1394,7 +1394,7 @@
|
||||
"reason": "Powód",
|
||||
"sender_id": "Identyfikator nadawcy",
|
||||
"sender_id_error_message": "Dozwolone są tylko litery, cyfry i spacje (maksymalnie 11 znaków)",
|
||||
"test_routing_form": "Przetestuj formularz przekierowania",
|
||||
"test_routing_form": "Testowanie formularza przekierowania",
|
||||
"test_preview": "Przetestuj podgląd",
|
||||
"route_to": "Przekieruj do",
|
||||
"test_preview_description": "Przetestuj swój formularz przekierowania bez przesyłania jakichkolwiek danych",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"still_waiting_for_approval": "Un eveniment încă așteaptă aprobare",
|
||||
"event_is_still_waiting": "Solicitarea evenimentului este încă în așteptare: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"no_more_results": "Nu mai există rezultate",
|
||||
"no_results": "Niciun rezultat",
|
||||
"load_more_results": "Încărcați mai multe rezultate",
|
||||
"integration_meeting_id": "{{integrationName}} ședință ID: {{meetingId}}",
|
||||
"confirmed_event_type_subject": "Confirmat: {{eventType}} cu {{name}} pe {{date}}",
|
||||
@@ -248,6 +249,7 @@
|
||||
"add_to_calendar": "Adaugă în calendar",
|
||||
"add_another_calendar": "Adăugați alt calendar",
|
||||
"other": "Altele",
|
||||
"email_sign_in_subject": "Linkul tău de conectare pentru {{appName}}",
|
||||
"emailed_you_and_attendees": "V-am trimis un e-mail dumneavoastră şi celuilalt participant la o invitaţie în calendar cu toate detaliile.",
|
||||
"emailed_you_and_attendees_recurring": "V-am trimis un e-mail dvs. și celorlalți participanți cu o invitație în calendar pentru primul dintre aceste evenimente recurente.",
|
||||
"emailed_you_and_any_other_attendees": "Dumneavoastră şi oricare alţi participanţi aţi primit prin e-mail aceste informaţii.",
|
||||
@@ -464,11 +466,14 @@
|
||||
"booking_confirmation": "Confirmă {{eventTypeTitle}} cu {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Reprogramează {{eventTypeTitle}} cu {{profileName}}",
|
||||
"in_person_meeting": "Link sau întâlnire în persoană",
|
||||
"attendeeInPerson": "În persoană (adresă participant)",
|
||||
"inPerson": "În persoană (adresă organizator)",
|
||||
"link_meeting": "Link întâlnire",
|
||||
"phone_call": "Număr de telefon participant",
|
||||
"your_number": "Numărul dvs. de telefon",
|
||||
"phone_number": "Număr de telefon",
|
||||
"attendee_phone_number": "Număr de telefon participant",
|
||||
"organizer_phone_number": "Număr de telefon organizator",
|
||||
"host_phone_number": "Numărul dvs. de telefon",
|
||||
"enter_phone_number": "Introduceți numărul de telefon",
|
||||
"reschedule": "Reprogrameaza",
|
||||
@@ -555,6 +560,10 @@
|
||||
"collective": "Colectiv",
|
||||
"collective_description": "Planifică ședințe când toți membrii selecționați ai echipei sunt disponibili.",
|
||||
"duration": "Durata",
|
||||
"available_durations": "Durate disponibile",
|
||||
"default_duration": "Durată implicită",
|
||||
"default_duration_no_options": "Alege mai întâi duratele disponibile",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"minutes": "Minute",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Întâlniri ciclice între mai mulţi membri ai echipei.",
|
||||
@@ -620,6 +629,7 @@
|
||||
"teams": "Echipe",
|
||||
"team": "Echipă",
|
||||
"team_billing": "Facturare echipă",
|
||||
"team_billing_description": "Gestionează facturarea pentru echipa ta",
|
||||
"upgrade_to_flexible_pro_title": "Am schimbat facturarea pentru echipe",
|
||||
"upgrade_to_flexible_pro_message": "Există membri fără licență în echipa dvs. Faceți upgrade la planul Pro pentru a acoperi licențele lipsă.",
|
||||
"changed_team_billing_info": "Începând din ianuarie 2022, se percepe un preț per licență pentru membrii echipei. Membrii echipei dvs. care au avut gratuit planul PRO sunt acum într-o perioadă de probă de 14 zile. Odată ce perioada de probă expiră, acești membri vor fi ascunși în echipa dvs. dacă nu faceți upgrade acum.",
|
||||
@@ -695,6 +705,7 @@
|
||||
"hide_event_type": "Ascunde tipul de eveniment",
|
||||
"edit_location": "Editați locația",
|
||||
"into_the_future": "în viitor",
|
||||
"when_booked_with_less_than_notice": "Când se rezervă cu aviz mai scurt de <time></time>",
|
||||
"within_date_range": "Intr-un interval de timp",
|
||||
"indefinitely_into_future": "Pe termen nelimitat în viitor",
|
||||
"add_new_custom_input_field": "Adaugă un nou câmp personalizat de introdus date",
|
||||
@@ -738,6 +749,7 @@
|
||||
"toggle_calendars_conflict": "Comutați calendarele în care doriți să verificați dacă există conflicte, pentru a preveni rezervările suprapuse.",
|
||||
"select_destination_calendar": "Creați evenimente în",
|
||||
"connect_additional_calendar": "Conectați calendarul suplimentar",
|
||||
"calendar_updated_successfully": "Calendar actualizat cu succes",
|
||||
"conferencing": "Conferință",
|
||||
"calendar": "Calendar",
|
||||
"payments": "Plăți",
|
||||
@@ -770,6 +782,7 @@
|
||||
"trending_apps": "Aplicații populare",
|
||||
"explore_apps": "Aplicații {{category}}",
|
||||
"installed_apps": "Aplicații instalate",
|
||||
"free_to_use_apps": "Gratuit",
|
||||
"no_category_apps": "Nicio aplicație {{category}}",
|
||||
"no_category_apps_description_calendar": "Adăugați o aplicație calendar pentru a verifica dacă există conflicte și a evita rezervările suprapuse",
|
||||
"no_category_apps_description_conferencing": "Încercați să adăugați o aplicație de conferință pentru a integra apelul video cu clienții dvs.",
|
||||
@@ -808,6 +821,8 @@
|
||||
"verify_wallet": "Verificați portofelul",
|
||||
"connect_metamask": "Conectați Metamask",
|
||||
"create_events_on": "Creați evenimente în",
|
||||
"enterprise_license": "Aceasta este o caracteristică de întreprindere",
|
||||
"enterprise_license_description": "Pentru a activa această caracteristică, obține o cheie de implementare la consola {{consoleUrl}} și adaug-o la .env ca CALCOM_LICENSE_KEY. Dacă echipa ta are deja o licență, te rugăm să contactezi {{supportMail}} pentru ajutor.",
|
||||
"missing_license": "Licență lipsă",
|
||||
"signup_requires": "Licență comercială necesară",
|
||||
"signup_requires_description": "În prezent, {{companyName}} nu oferă o versiune open-source gratuită a paginii de înscriere. Pentru a primi acces deplin la componentele de înscriere, trebuie să obțineți o licență comercială. Pentru uz personal, recomandăm Prisma Data Platform sau orice altă interfață Postgres pentru a crea conturi.",
|
||||
@@ -899,6 +914,7 @@
|
||||
"user_impersonation_heading": "Reprezentare utilizator",
|
||||
"user_impersonation_description": "Permite echipei noastre de asistență să se conecteze temporar în locul dvs. pentru a ne ajuta să rezolvăm orice probleme pe care ni le raportați.",
|
||||
"team_impersonation_description": "Permite membrilor echipei să se conecteze temporar din contul dvs.",
|
||||
"allow_booker_to_select_duration": "Permite persoanei care face rezervarea să selecteze durata",
|
||||
"impersonate_user_tip": "Toate utilizările acestei caracteristici sunt verificate.",
|
||||
"impersonating_user_warning": "Reprezentare nume de utilizator „{{user}}”.",
|
||||
"impersonating_stop_instructions": "<0>Faceți clic aici pentru a înceta</0>.",
|
||||
@@ -1016,6 +1032,9 @@
|
||||
"error_removing_app": "Eroare la ștergerea aplicației",
|
||||
"web_conference": "Conferință web",
|
||||
"requires_confirmation": "Necesită confirmare",
|
||||
"always_requires_confirmation": "Întotdeauna",
|
||||
"requires_confirmation_threshold": "Necesită confirmare dacă este rezervat cu aviz de < {{time}} $t({{unit}}_timeUnit)",
|
||||
"may_require_confirmation": "Poate necesita confirmare",
|
||||
"nr_event_type_one": "{{count}} tip de eveniment",
|
||||
"nr_event_type_other": "{{count}} (de) tipuri de evenimente",
|
||||
"add_action": "Adăugare acțiune",
|
||||
@@ -1103,6 +1122,8 @@
|
||||
"event_limit_tab_description": "Cât de des puteți fi rezervat",
|
||||
"event_advanced_tab_description": "Setări calendar și mai multe...",
|
||||
"event_advanced_tab_title": "Avansat",
|
||||
"event_setup_multiple_duration_error": "Configurare eveniment: duratele multiple necesită cel puțin o opțiune.",
|
||||
"event_setup_multiple_duration_default_error": "Configurare eveniment: selectează o durată implicită validă.",
|
||||
"event_setup_booking_limits_error": "Limitele de rezervare trebuie să fie în ordine crescătoare. [zi,săptămână,lună,an]",
|
||||
"select_which_cal": "Alegeți la ce calendar se adaugă rezervările",
|
||||
"custom_event_name": "Nume eveniment personalizat",
|
||||
@@ -1157,8 +1178,11 @@
|
||||
"invoices": "Facturi",
|
||||
"embeds": "Încorporări",
|
||||
"impersonation": "Reprezentare",
|
||||
"impersonation_description": "Setări pentru gestionarea reprezentării utilizatorului",
|
||||
"users": "Utilizatori",
|
||||
"profile_description": "Gestionați setările profilului dvs. {{appName}}",
|
||||
"users_description": "Aici poți găsi o listă a tuturor utilizatorilor",
|
||||
"users_listing": "Listare utilizatori",
|
||||
"general_description": "Gestionați setările pentru limbă și fus orar",
|
||||
"calendars_description": "Configurați modul în care interacționează tipurile de evenimente cu calendarele dvs.",
|
||||
"appearance_description": "Gestionați setările pentru aspectul rezervărilor dvs.",
|
||||
@@ -1363,6 +1387,7 @@
|
||||
"number_sms_notifications": "Număr de telefon (notificări SMS)",
|
||||
"attendee_email_workflow": "E-mail participant",
|
||||
"attendee_email_info": "E-mailul persoanei care efectuează rezervarea",
|
||||
"kbar_search_placeholder": "Scrie o comandă sau caută...",
|
||||
"invalid_credential": "Vai, nu! Se pare că permisiunea a expirat sau a fost revocată. Reinstalați din nou.",
|
||||
"choose_common_schedule_team_event": "Alegeți un program comun",
|
||||
"choose_common_schedule_team_event_description": "Activați această opțiune dacă doriți să folosiți un program comun între gazde. Atunci când este dezactivată, fiecare gazdă va fi rezervată în funcție de programul său implicit.",
|
||||
@@ -1374,9 +1399,42 @@
|
||||
"route_to": "Direcționați către",
|
||||
"test_preview_description": "Testați-vă formularul de redirecționare fără a trimite date",
|
||||
"test_routing": "Testați redirecționarea",
|
||||
"payment_app_disabled": "Un administrator a dezactivat o aplicație de plată",
|
||||
"edit_event_type": "Editează tipul de eveniment",
|
||||
"collective_scheduling": "Programare colectivă",
|
||||
"make_it_easy_to_book": "Facilitează rezervarea echipei tale când toată lumea este disponibilă.",
|
||||
"find_the_best_person": "Găsește cea mai bună persoană disponibilă și treci succesiv prin membrii echipei tale.",
|
||||
"fixed_round_robin": "Sistem fiecare-cu-fiecare fix",
|
||||
"add_one_fixed_attendee": "Adaugă un participant fix şi unul din sistemul fiecare-cu-fiecare dintr-un număr de participanţi.",
|
||||
"calcom_is_better_with_team": "Cal.com funcționează mai bine cu echipe",
|
||||
"add_your_team_members": "Adaugă membrii echipei tale la tipurile tale de evenimente. Folosește programarea colectivă pentru a include pe toată lumea sau găsește persoana cea mai potrivită cu programarea fiecare-cu-fiecare.",
|
||||
"booking_limit_reached": "A fost atinsă limita de rezervare pentru acest tip de eveniment",
|
||||
"admin_has_disabled": "Un administrator a dezactivat {{appName}}",
|
||||
"disabled_app_affects_event_type": "Un administrator a dezactivat {{appName}} care afectează tipul tău de eveniment {{eventType}}",
|
||||
"disable_payment_app": "Administratorul a dezactivat {{appName}} care afectează tipul tău de eveniment {{title}}. Participanții încă pot să facă rezervări pentru acest tip de eveniment, dar nu li se va solicita să plătească. Poți ascunde tipul de eveniment pentru a preveni acest lucru până când administratorul tău reactivează metoda ta de plată.",
|
||||
"payment_disabled_still_able_to_book": "Participanții încă pot să facă rezervări pentru acest tip de eveniment, dar nu li se va solicita să plătească. Poți ascunde tipul de eveniment pentru a preveni acest lucru până când administratorul tău reactivează metoda ta de plată.",
|
||||
"app_disabled_with_event_type": "Administratorul a dezactivat {{appName}} care afectează tipul tău de eveniment {{title}}.",
|
||||
"app_disabled_video": "Administratorul a dezactivat {{appName}} care poate afecta tipurile tale de evenimente. Dacă ai tipuri de evenimente cu {{appName}} ca locație, atunci va fi setat implicit la Cal Video.",
|
||||
"app_disabled_subject": "{{appName}} a fost dezactivat",
|
||||
"navigate_installed_apps": "Mergi la aplicațiile instalate",
|
||||
"disabled_calendar": "Dacă ai un alt calendar instalat, rezervările noi vor fi adăugate la acesta. Dacă nu ai, conectează un calendar nou, astfel încât să nu ratezi nicio rezervare nouă.",
|
||||
"enable_apps": "Activează aplicații",
|
||||
"enable_apps_description": "Activează aplicațiile pe care utilizatorii le pot integra cu Cal.com",
|
||||
"app_is_enabled": "{{appName}} este activat",
|
||||
"app_is_disabled": "{{appName}} este dezactivat",
|
||||
"keys_have_been_saved": "Cheile au fost salvate",
|
||||
"disable_app": "Dezactivează aplicația",
|
||||
"disable_app_description": "Dezactivarea acestei aplicații poate cauza probleme cu modul în care utilizatorii interacționează cu Cal",
|
||||
"edit_keys": "Editează chei",
|
||||
"no_available_apps": "Nu există nicio aplicație disponibilă",
|
||||
"no_available_apps_description": "Asigură-te că există aplicații în implementare sub „pachete/magazin de aplicații”",
|
||||
"no_apps": "Nu există nicio aplicație activată în această instanță de Cal",
|
||||
"apps_settings": "Setări aplicații",
|
||||
"fill_this_field": "Te rugăm să completezi acest câmp",
|
||||
"options": "Opțiuni",
|
||||
"enter_option": "Introdu opțiunea {{index}}",
|
||||
"add_an_option": "Adaugă o opțiune",
|
||||
"radio": "Radio",
|
||||
"event_type_duplicate_copy_text": "{{slug}}-copie"
|
||||
"event_type_duplicate_copy_text": "{{slug}}-copie",
|
||||
"set_as_default": "Setează ca implicit"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"still_waiting_for_approval": "Событие все еще ждет одобрения",
|
||||
"event_is_still_waiting": "Запрос встречи все еще ожидает: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"no_more_results": "Ничего не найдено",
|
||||
"no_results": "Нет результатов",
|
||||
"load_more_results": "Загрузить больше результатов",
|
||||
"integration_meeting_id": "{{integrationName}} ID встречи: {{meetingId}}",
|
||||
"confirmed_event_type_subject": "Подтверждено: {{eventType}} с {{name}} на {{date}}",
|
||||
@@ -248,6 +249,7 @@
|
||||
"add_to_calendar": "Добавить в календарь",
|
||||
"add_another_calendar": "Добавить другой календарь",
|
||||
"other": "Прочее",
|
||||
"email_sign_in_subject": "Ссылка для входа в {{appName}}",
|
||||
"emailed_you_and_attendees": "Мы отправили вам и другим участникам приглашение в календарь со всеми деталями.",
|
||||
"emailed_you_and_attendees_recurring": "Мы отправили вам и другим участникам в календарь приглашение на первое из этой серии повторяющихся событий.",
|
||||
"emailed_you_and_any_other_attendees": "Вы и другие участники были уведомлены по электронной почте.",
|
||||
@@ -464,11 +466,14 @@
|
||||
"booking_confirmation": "Подтвердите вашу встречу «{{eventTypeTitle}}» с {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Перенесите вашу встречу «{{eventTypeTitle}}» с {{profileName}}",
|
||||
"in_person_meeting": "Ссылка или личная встреча",
|
||||
"attendeeInPerson": "Лично (адрес участника)",
|
||||
"inPerson": "Лично (адрес организатора)",
|
||||
"link_meeting": "Встреча со ссылкой",
|
||||
"phone_call": "Номер телефона участника",
|
||||
"your_number": "Ваш номер телефона",
|
||||
"phone_number": "Номер телефона",
|
||||
"attendee_phone_number": "Номер телефона участника",
|
||||
"organizer_phone_number": "Телефон организатора",
|
||||
"host_phone_number": "Ваш номер телефона",
|
||||
"enter_phone_number": "Введите номер телефона",
|
||||
"reschedule": "Перенести",
|
||||
@@ -556,6 +561,9 @@
|
||||
"collective_description": "Расписание встреч, когда доступны все выбранные члены команды.",
|
||||
"duration": "Продолжительность",
|
||||
"available_durations": "Доступные сроки",
|
||||
"default_duration": "Продолжительность по умолчанию",
|
||||
"default_duration_no_options": "Пожалуйста, выберите сначала доступную продолжительность",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"minutes": "мин.",
|
||||
"round_robin": "По кругу",
|
||||
"round_robin_description": "Цикл встреч между несколькими членами команды.",
|
||||
@@ -621,6 +629,7 @@
|
||||
"teams": "Команды",
|
||||
"team": "Команда",
|
||||
"team_billing": "Условия расчета для команд",
|
||||
"team_billing_description": "Управление биллингом вашей команды",
|
||||
"upgrade_to_flexible_pro_title": "Мы изменили условия расчета для команд",
|
||||
"upgrade_to_flexible_pro_message": "В вашей команде есть пользователи без места. Перейдите на тарифный план Pro, чтобы получить недостающие места.",
|
||||
"changed_team_billing_info": "С января 2022 года мы взимаем плату с членов команды за каждое место. Членам вашей команды, которые пользовались тарифным планом Pro бесплатно, теперь будет предоставлена 14-дневная пробная версия. Если вы не перейдете на тарифный план Pro сейчас, по истечении этого периода такие члены команды будут скрыты.",
|
||||
@@ -696,6 +705,7 @@
|
||||
"hide_event_type": "Скрыть шаблон события",
|
||||
"edit_location": "Изменить местоположение",
|
||||
"into_the_future": "в будущее",
|
||||
"when_booked_with_less_than_notice": "Уведомлять при бронировании менее чем на <time></time>",
|
||||
"within_date_range": "В диапазоне дат",
|
||||
"indefinitely_into_future": "На неопределенный срок в будущем",
|
||||
"add_new_custom_input_field": "Добавить настраиваемый вопрос",
|
||||
@@ -739,6 +749,7 @@
|
||||
"toggle_calendars_conflict": "Переключайте календари для проверки на наличие конфликтов, чтобы избежать двойного бронирования.",
|
||||
"select_destination_calendar": "Создать события на",
|
||||
"connect_additional_calendar": "Подключить дополнительный календарь",
|
||||
"calendar_updated_successfully": "Календарь обновлен",
|
||||
"conferencing": "Конференция",
|
||||
"calendar": "Календарь",
|
||||
"payments": "Платежи",
|
||||
@@ -810,6 +821,7 @@
|
||||
"verify_wallet": "Подтвердить кошелек",
|
||||
"connect_metamask": "Подключить Metamask",
|
||||
"create_events_on": "Создавать события в календаре:",
|
||||
"enterprise_license": "Это функция корпоративного тарифного плана",
|
||||
"enterprise_license_description": "Чтобы включить эту функцию, получите ключ развертывания на консоли {{consoleUrl}} и добавьте его в .env как CALCOM_LICENSE_KEY. Если у вашей команды уже есть лицензия, пожалуйста, обратитесь за помощью в {{supportMail}}.",
|
||||
"missing_license": "Отсутствует лицензия",
|
||||
"signup_requires": "Требуется коммерческая лицензия",
|
||||
@@ -902,6 +914,7 @@
|
||||
"user_impersonation_heading": "Вход от имени пользователя",
|
||||
"user_impersonation_description": "Позволяет нашей службе поддержки временно входить в систему от вашего имени, чтобы быстро решать любые проблемы, о которых вы нам сообщаете.",
|
||||
"team_impersonation_description": "Позволяет участникам вашей команды временно входить в систему под вашим именем.",
|
||||
"allow_booker_to_select_duration": "Разрешить пользователю, оформляющему бронирование, выбирать длительность",
|
||||
"impersonate_user_tip": "Все варианты использования этой функции проверяются.",
|
||||
"impersonating_user_warning": "Олицетворение пользователя \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "<0>Нажмите здесь, чтобы остановить</0>.",
|
||||
@@ -1019,6 +1032,9 @@
|
||||
"error_removing_app": "Ошибка при удалении приложения",
|
||||
"web_conference": "Веб-конференция",
|
||||
"requires_confirmation": "Требуется подтверждение",
|
||||
"always_requires_confirmation": "Всегда",
|
||||
"requires_confirmation_threshold": "Требуется подтверждение, если бронирование оформляется с уведомлением < {{time}} $t({{unit}}_timeUnit)",
|
||||
"may_require_confirmation": "Может потребоваться подтверждение",
|
||||
"nr_event_type_one": "Типы событий: {{count}}",
|
||||
"nr_event_type_other": "Типы событий: {{count}}",
|
||||
"add_action": "Добавить действие",
|
||||
@@ -1106,6 +1122,8 @@
|
||||
"event_limit_tab_description": "Как часто можно забронировать для вас событие",
|
||||
"event_advanced_tab_description": "Настройки календаря и ещё...",
|
||||
"event_advanced_tab_title": "Расширенные",
|
||||
"event_setup_multiple_duration_error": "Настройка события: чтобы выбрать несколько вариантов продолжительности, требуется как минимум 1 параметр.",
|
||||
"event_setup_multiple_duration_default_error": "Настройка события: выберите допустимую продолжительность по умолчанию.",
|
||||
"event_setup_booking_limits_error": "Лимиты бронирования должны указываться по возрастанию [день, неделя, месяц, год].",
|
||||
"select_which_cal": "Выберите, в какой календарь добавить бронирование",
|
||||
"custom_event_name": "Пользовательское имя события",
|
||||
@@ -1160,8 +1178,10 @@
|
||||
"invoices": "Счета на оплату",
|
||||
"embeds": "Встраивание",
|
||||
"impersonation": "Разрешить вход под вашим именем",
|
||||
"impersonation_description": "Настройки для управления входа в аккаунт от имени пользователя",
|
||||
"users": "Пользователи",
|
||||
"profile_description": "Настройка вашего профиля в {{appName}}",
|
||||
"users_description": "Здесь представлен список всех пользователей",
|
||||
"users_listing": "Список пользователей",
|
||||
"general_description": "Настройте язык и часовой пояс",
|
||||
"calendars_description": "Настройка взаимодействия типов событий с календарями",
|
||||
@@ -1367,6 +1387,7 @@
|
||||
"number_sms_notifications": "Номер телефона (SMS-уведомления)",
|
||||
"attendee_email_workflow": "Электронная почта участника",
|
||||
"attendee_email_info": "Электронная почта участника, на которого оформляется бронирование",
|
||||
"kbar_search_placeholder": "Введите команду или начните поиск...",
|
||||
"invalid_credential": "Похоже, разрешение истекло или было отозвано. Необходима переустановка.",
|
||||
"choose_common_schedule_team_event": "Выберите общее расписание",
|
||||
"choose_common_schedule_team_event_description": "Позволяет организаторам использовать общее расписание. Если общее расписание отключено, то для каждого организатора бронирование выполняется в соответствии с его индивидуальным расписанием по умолчанию.",
|
||||
@@ -1378,18 +1399,42 @@
|
||||
"route_to": "Цель маршрута",
|
||||
"test_preview_description": "Проверить форму маршрутизации без отправки данных",
|
||||
"test_routing": "Проверить машрутизацию",
|
||||
"payment_app_disabled": "Администратор отключил платёжное приложение",
|
||||
"edit_event_type": "Редактировать тип мероприятия",
|
||||
"collective_scheduling": "Совместное планирование",
|
||||
"make_it_easy_to_book": "Оформляйте бронирование для команды, когда все пользователи доступны. Теперь это несложно.",
|
||||
"find_the_best_person": "Выберите наиболее подходящего сотрудника из команды.",
|
||||
"fixed_round_robin": "Фиксированный раунд",
|
||||
"add_one_fixed_attendee": "Добавьте одного фиксированного участника и общайтесь с другими участниками по кругу.",
|
||||
"calcom_is_better_with_team": "Cal.com отлично подходит для командной работы",
|
||||
"add_your_team_members": "Добавьте участников команды к типам мероприятий. Благодаря совместному планированию можно включить в мероприятие всех пользователей или найти наиболее подходящего пользователя с циклическим планированием.",
|
||||
"booking_limit_reached": "Достигнут лимит бронирования для этого типа события",
|
||||
"admin_has_disabled": "Администратор отключил {{appName}}",
|
||||
"disabled_app_affects_event_type": "Администратор отключил {{appName}}, которое влияет на тип мероприятия {{eventType}}",
|
||||
"disable_payment_app": "Администратор отключил приложение {{appName}}, которое влияет на тип вашего мероприятия {{title}}. Участники все еще могут забронировать этот тип мероприятия, но они не получат собщение о том, что нужно произвести оплату. Чтобы этого избежать, можно скрыть тип мероприятия, пока администратор не активирует способ оплаты снова.",
|
||||
"payment_disabled_still_able_to_book": "Участники все еще могут забронировать этот тип мероприятия, но они не получат сообщение о том, что нужно произвести оплату. Чтобы этого избежать, можно скрыть тип мероприятия, пока администратор не активирует способ оплаты снова.",
|
||||
"app_disabled_with_event_type": "Администратор отключил {{appName}}, которое влияет на тип мероприятия {{title}}.",
|
||||
"app_disabled_video": "Администратор отключил приложение {{appName}}, которое влияет на типы мероприятий. Если у вас есть типы мероприятий с {{appName}} в качестве места, то по умолчанию будет использоваться Cal Video.",
|
||||
"app_disabled_subject": "{{appName}} отключено",
|
||||
"navigate_installed_apps": "Перейти к установленным приложениям",
|
||||
"disabled_calendar": "Если у вас установлен другой календарь, в него будут добавляться новые бронирования. Если у вас нет установленного календаря, подключите новый календарь, чтобы не пропускать новые бронирования.",
|
||||
"enable_apps": "Включить приложения",
|
||||
"enable_apps_description": "Включить приложения, которые пользователи могут интегрировать с Cal.com",
|
||||
"app_is_enabled": "{{appName}} включено",
|
||||
"app_is_disabled": "{{appName}} отключено",
|
||||
"keys_have_been_saved": "Ключи сохранены",
|
||||
"disable_app": "Отключить приложение",
|
||||
"disable_app_description": "При отключении этого приложения у пользователей могут возникнуть проблемы с взаимодействием с Cal",
|
||||
"edit_keys": "Изменить ключи",
|
||||
"no_available_apps": "Нет доступных приложений",
|
||||
"no_available_apps_description": "Убедитесь, что в вашей установке в 'packages/app-store' есть приложения",
|
||||
"no_apps": "В этом экземпляре Cal нет включенных приложений",
|
||||
"apps_settings": "Настройки приложений",
|
||||
"fill_this_field": "Заполните это поле",
|
||||
"options": "Настройки",
|
||||
"enter_option": "Введите параметр {{index}}",
|
||||
"add_an_option": "Добавить параметр",
|
||||
"radio": "Переключатель",
|
||||
"event_type_duplicate_copy_text": "{{slug}}- копия"
|
||||
"event_type_duplicate_copy_text": "{{slug}}- копия",
|
||||
"set_as_default": "Использовать по умолчанию"
|
||||
}
|
||||
|
||||
@@ -611,7 +611,7 @@
|
||||
"monthly_other": "月",
|
||||
"yearly_one": "年",
|
||||
"yearly_other": "年",
|
||||
"plus_more": "多 {{count}} 個",
|
||||
"plus_more": "另外 {{count}} 項",
|
||||
"max": "最大",
|
||||
"single_theme": "主題",
|
||||
"brand_color": "品牌顏色",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "Cal Sans";
|
||||
src: url("/fonts/cal.ttf");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter var";
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-named-instance: "Regular";
|
||||
src: url("/fonts/Inter-roman.var.woff2") format("woff2");
|
||||
}
|
||||
@@ -215,17 +215,6 @@ button[role="switch"][data-state="checked"] {
|
||||
background: black;
|
||||
}
|
||||
|
||||
/* add padding bottom to bottom nav on standalone mode */
|
||||
@media all and (display-mode: standalone) {
|
||||
.bottom-nav {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.cta {
|
||||
bottom: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* hide chat bubble on mobile */
|
||||
@media only screen and (max-width: 768px) {
|
||||
/* Intercom FAB*/
|
||||
@@ -247,13 +236,6 @@ button[role="switch"][data-state="checked"] {
|
||||
}
|
||||
}
|
||||
|
||||
/* add padding bottom to bottom nav on standalone mode */
|
||||
@media all and (display-mode: standalone) {
|
||||
.bottom-nav {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.react-multi-email > [type="text"] {
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
switchChecked={enabled}>
|
||||
<div className="mt-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="mr-2">{isSunrise ? <Icon.FiSunrise /> : <Icon.FiSunset />}</span>I am an AppCard
|
||||
for Event with Title: {eventType.title}
|
||||
<span className="ltr:mr-2 rtl:ml-2">{isSunrise ? <Icon.FiSunrise /> : <Icon.FiSunset />}</span>I am
|
||||
an AppCard for Event with Title: {eventType.title}
|
||||
</div>{" "}
|
||||
<div className="mt-2">
|
||||
Edit <span className="italic">packages/app-store/{app.slug}/extensions/EventTypeAppCard.tsx</span>{" "}
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function AppleCalendarSetup() {
|
||||
</fieldset>
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
|
||||
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
|
||||
<Button type="button" color="secondary" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function CalDavCalendarSetup() {
|
||||
className="my-4"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
|
||||
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
|
||||
<Button type="button" color="secondary" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function CloseComSetup() {
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
|
||||
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
|
||||
<Button type="button" color="secondary" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||
import { GetRecordingsResponseSchema, getRecordingsResponseSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
@@ -62,7 +63,7 @@ export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = {
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
const fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
||||
export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
||||
const { api_key } = await getDailyAppKeys();
|
||||
return fetch(`https://api.daily.co/v1${endpoint}`, {
|
||||
method: "GET",
|
||||
@@ -87,9 +88,8 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
if (!event.uid) {
|
||||
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
||||
}
|
||||
const dailyEvent = await postToDailyAPI(endpoint, translateEvent(event)).then(
|
||||
dailyReturnTypeSchema.parse
|
||||
);
|
||||
const body = await translateEvent(event);
|
||||
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
|
||||
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
||||
properties: { room_name: dailyEvent.name, is_owner: true },
|
||||
}).then(meetingTokenSchema.parse);
|
||||
@@ -102,11 +102,11 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
});
|
||||
}
|
||||
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
const translateEvent = async (event: CalendarEvent) => {
|
||||
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||
// added a 1 hour buffer for room expiration
|
||||
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60;
|
||||
const scalePlan = process.env.DAILY_SCALE_PLAN;
|
||||
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
||||
|
||||
if (scalePlan === "true") {
|
||||
return {
|
||||
@@ -118,7 +118,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
enable_screenshare: true,
|
||||
enable_chat: true,
|
||||
exp: exp,
|
||||
enable_recording: "local",
|
||||
enable_recording: "cloud",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,6 +148,16 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
},
|
||||
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
|
||||
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
|
||||
getRecordingsResponseSchema.parse
|
||||
);
|
||||
return Promise.resolve(res);
|
||||
} catch (err) {
|
||||
throw new Error("Something went wrong! Unable to get recording");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
|
||||
const dailyAppKeysSchema = z.object({
|
||||
api_key: z.string(),
|
||||
scale_plan: z.string().default("false"),
|
||||
});
|
||||
|
||||
export const getDailyAppKeys = async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
api_key: z.string().min(1),
|
||||
scale_plan: z.string(),
|
||||
scale_plan: z.string().default("false"),
|
||||
});
|
||||
|
||||
export const appDataSchema = z.object({});
|
||||
|
||||
@@ -71,7 +71,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
if (!form) {
|
||||
throw new Error("Form not found");
|
||||
}
|
||||
const serializableForm = getSerializableForm(form, true);
|
||||
const serializableForm = await getSerializableForm(form, true);
|
||||
res.setHeader("Content-Type", "text/csv; charset=UTF-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextRouter } from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
import { createContext, forwardRef, useContext, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
SettingsToggle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
@@ -77,6 +79,7 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
const hookForm = useForm<{
|
||||
name: string;
|
||||
description: string;
|
||||
shouldConnect: boolean;
|
||||
}>();
|
||||
|
||||
const { action, target } = router.query as z.infer<typeof newFormModalQuerySchema>;
|
||||
@@ -97,6 +100,7 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
form={hookForm}
|
||||
handleSubmit={(values) => {
|
||||
const formId = uuidv4();
|
||||
|
||||
mutation.mutate({
|
||||
id: formId,
|
||||
...values,
|
||||
@@ -115,6 +119,23 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
placeholder="Form Description"
|
||||
/>
|
||||
</div>
|
||||
{action === "duplicate" && (
|
||||
<Controller
|
||||
name="shouldConnect"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title="Keep me connected with the form"
|
||||
description="Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate."
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button loading={mutation.isLoading} data-testid="add-form" type="submit">
|
||||
@@ -186,7 +207,7 @@ function Dialogs({
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.appRoutingForms.forms.setData(undefined, context.previousValue);
|
||||
}
|
||||
showToast("Something went wrong", "error");
|
||||
showToast(err.message || "Something went wrong", "error");
|
||||
},
|
||||
});
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
|
||||
import { SerializableForm, Response } from "../types/types";
|
||||
|
||||
@@ -18,6 +19,9 @@ export default function FormInputFields(props: Props) {
|
||||
return (
|
||||
<>
|
||||
{form.fields?.map((field) => {
|
||||
if (isRouterLinkedField(field)) {
|
||||
field = field.routerField;
|
||||
}
|
||||
const widget = queryBuilderConfig.widgets[field.type];
|
||||
if (!("factory" in widget)) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm, UseFormReturn } from "react-hook-form";
|
||||
|
||||
@@ -12,8 +13,9 @@ import {
|
||||
AppUser,
|
||||
} from "@calcom/types/AppGetServerSideProps";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Banner,
|
||||
Badge,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -42,7 +44,8 @@ import FormInputFields from "./FormInputFields";
|
||||
import RoutingNavBar from "./RoutingNavBar";
|
||||
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
type RoutingFormWithResponseCount = RoutingForm & {
|
||||
|
||||
export type RoutingFormWithResponseCount = RoutingForm & {
|
||||
_count: {
|
||||
responses: number;
|
||||
};
|
||||
@@ -236,13 +239,19 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
onSuccess() {
|
||||
showToast("Form updated successfully.", "success");
|
||||
},
|
||||
onError() {
|
||||
onError(e) {
|
||||
if (e.message) {
|
||||
showToast(e.message, "error");
|
||||
return;
|
||||
}
|
||||
showToast(`Something went wrong`, "error");
|
||||
},
|
||||
onSettled() {
|
||||
utils.viewer.appRoutingForms.formQuery.invalidate({ id: form.id });
|
||||
},
|
||||
});
|
||||
const connectedForms = form.connectedForms;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
@@ -295,6 +304,55 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.routers.length ? (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 block text-sm font-semibold leading-none text-black ">
|
||||
Routers
|
||||
</div>
|
||||
<p className="-mt-1 text-xs leading-normal text-gray-600">
|
||||
Modifications in fields and routes of following forms will be reflected in this form.
|
||||
</p>
|
||||
<div className="flex">
|
||||
{form.routers.map((router) => {
|
||||
return (
|
||||
<div key={router.id} className="mr-2">
|
||||
<Link href={`/${appUrl}/route-builder/${router.id}`}>
|
||||
<a>
|
||||
<Badge variant="gray">{router.name}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connectedForms?.length ? (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 block text-sm font-semibold leading-none text-black ">
|
||||
Connected Forms
|
||||
</div>
|
||||
<p className="-mt-1 text-xs leading-normal text-gray-600">
|
||||
Following forms would be affected when you modify fields or routes here
|
||||
</p>
|
||||
<div className="flex">
|
||||
{connectedForms.map((router) => {
|
||||
return (
|
||||
<div key={router.id} className="mr-2">
|
||||
<Link href={`/${appUrl}/route-builder/${router.id}`}>
|
||||
<a>
|
||||
<Badge variant="default">{router.name}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -452,8 +510,8 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
||||
};
|
||||
}
|
||||
|
||||
const isAllowed = (await import("../lib/isAllowed")).isAllowed;
|
||||
if (!(await isAllowed({ userId: user.id, formId }))) {
|
||||
const isFormEditAllowed = (await import("../lib/isFormEditAllowed")).isFormEditAllowed;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId }))) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
@@ -480,7 +538,7 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
form: getSerializableForm(form),
|
||||
form: await getSerializableForm(form),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as InstallAppButton } from "./InstallAppButton";
|
||||
export { default as Icon } from "./icon";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder";
|
||||
|
||||
import { GlobalRoute, SerializableRoute } from "../types/types";
|
||||
|
||||
export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
|
||||
const uuid = QbUtils.uuid();
|
||||
return {
|
||||
id: uuid,
|
||||
isFallback: true,
|
||||
action: {
|
||||
type: "customPageMessage",
|
||||
value: "Thank you for your interest! We will be in touch soon.",
|
||||
},
|
||||
queryValue: { id: uuid, type: "group" },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
|
||||
export default async function getConnectedForms(
|
||||
prisma: typeof import("@calcom/prisma").default,
|
||||
form: Pick<App_RoutingForms_Form, "id" | "userId">
|
||||
) {
|
||||
return await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
userId: form.userId,
|
||||
routes: {
|
||||
array_contains: [
|
||||
{
|
||||
id: form.id,
|
||||
isRouter: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { SerializableForm } from "../types/types";
|
||||
import { zodFields, zodRoutes } from "../zod";
|
||||
import { zodFields, zodRoutes, zodRoutesView, zodFieldsView } from "../zod";
|
||||
import getConnectedForms from "./getConnectedForms";
|
||||
import isRouter from "./isRouter";
|
||||
import isRouterLinkedField from "./isRouterLinkedField";
|
||||
|
||||
/**
|
||||
* Doesn't have deleted fields by default
|
||||
*/
|
||||
export function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
||||
export async function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
||||
form: TForm,
|
||||
withDeletedFields = false
|
||||
) {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const routesParsed = zodRoutes.safeParse(form.routes);
|
||||
if (!routesParsed.success) {
|
||||
throw new Error("Error parsing routes");
|
||||
}
|
||||
|
||||
const fieldsParsed = zodFields.safeParse(form.fields);
|
||||
|
||||
if (!fieldsParsed.success) {
|
||||
throw new Error("Error parsing fields");
|
||||
throw new Error("Error parsing fields" + fieldsParsed.error);
|
||||
}
|
||||
|
||||
const settings = RoutingFormSettings.parse(
|
||||
form.settings || {
|
||||
// Would have really loved to do it using zod. But adding .default(true) throws type error in prisma/zod/app_routingforms_form.ts
|
||||
@@ -28,18 +35,102 @@ export function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
||||
}
|
||||
);
|
||||
|
||||
// Ideally we shouldb't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
|
||||
const parsedFields =
|
||||
(withDeletedFields ? fieldsParsed.data : fieldsParsed.data?.filter((f) => !f.deleted)) || [];
|
||||
const parsedRoutes = routesParsed.data;
|
||||
const fields = parsedFields as NonNullable<z.infer<typeof zodFieldsView>>;
|
||||
|
||||
const fieldsExistInForm: Record<string, true> = {};
|
||||
parsedFields?.forEach((f) => {
|
||||
fieldsExistInForm[f.id] = true;
|
||||
});
|
||||
|
||||
const { routes, routers } = await getEnrichedRoutesAndRouters(parsedRoutes);
|
||||
|
||||
const connectedForms = (await getConnectedForms(prisma, form)).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
}));
|
||||
const finalFields = fields;
|
||||
|
||||
// Ideally we should't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
|
||||
const serializableForm: SerializableForm<TForm> = {
|
||||
...form,
|
||||
settings: settings,
|
||||
fields: fieldsParsed.data
|
||||
? withDeletedFields
|
||||
? fieldsParsed.data
|
||||
: fieldsParsed.data.filter((f) => !f.deleted)
|
||||
: [],
|
||||
routes: routesParsed.data,
|
||||
settings,
|
||||
fields: finalFields,
|
||||
routes,
|
||||
routers,
|
||||
connectedForms,
|
||||
createdAt: form.createdAt.toString(),
|
||||
updatedAt: form.updatedAt.toString(),
|
||||
};
|
||||
return serializableForm;
|
||||
|
||||
/**
|
||||
* Enriches routes that are actually routers and returns a list of routers separately
|
||||
*/
|
||||
async function getEnrichedRoutesAndRouters(parsedRoutes: z.infer<typeof zodRoutes>) {
|
||||
const routers: { name: string; description: string | null; id: string }[] = [];
|
||||
const routes: z.infer<typeof zodRoutesView> = [];
|
||||
if (!parsedRoutes) {
|
||||
return { routes, routers };
|
||||
}
|
||||
|
||||
for (const [, route] of Object.entries(parsedRoutes)) {
|
||||
if (isRouter(route)) {
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: form.userId,
|
||||
},
|
||||
});
|
||||
if (!router) {
|
||||
throw new Error("Form -" + route.id + ", being used as router, not found");
|
||||
}
|
||||
|
||||
const parsedRouter = await getSerializableForm(router, false);
|
||||
|
||||
routers.push({
|
||||
name: parsedRouter.name,
|
||||
description: parsedRouter.description,
|
||||
id: parsedRouter.id,
|
||||
});
|
||||
|
||||
// Enrichment
|
||||
routes.push({
|
||||
...route,
|
||||
isRouter: true,
|
||||
name: parsedRouter.name,
|
||||
description: parsedRouter.description,
|
||||
routes: parsedRouter.routes || [],
|
||||
});
|
||||
|
||||
parsedRouter.fields?.forEach((field) => {
|
||||
if (!fieldsExistInForm[field.id]) {
|
||||
// Instead of throwing error, Log it instead of breaking entire routing forms feature
|
||||
console.error(
|
||||
"This is an impossible state. A router field must always be present in the connected form."
|
||||
);
|
||||
} else {
|
||||
const currentFormField = fields.find((f) => f.id === field.id);
|
||||
if (!currentFormField || !("routerId" in currentFormField)) {
|
||||
return;
|
||||
}
|
||||
if (!isRouterLinkedField(field)) {
|
||||
currentFormField.routerField = field;
|
||||
}
|
||||
currentFormField.router = {
|
||||
id: parsedRouter.id,
|
||||
name: router.name,
|
||||
description: router.description || "",
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
routes.push(route);
|
||||
}
|
||||
}
|
||||
return { routes, routers };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodRoute } from "../zod";
|
||||
import isRouter from "./isRouter";
|
||||
|
||||
export const isFallbackRoute = (route: z.infer<typeof zodRoute>) => {
|
||||
if (isRouter(route)) {
|
||||
return false;
|
||||
}
|
||||
return route.isFallback;
|
||||
};
|
||||
+1
-1
@@ -2,7 +2,7 @@ import prisma from "@calcom/prisma";
|
||||
|
||||
import { App_RoutingForms_Form, User } from ".prisma/client";
|
||||
|
||||
export async function isAllowed({
|
||||
export async function isFormEditAllowed({
|
||||
userId,
|
||||
formId,
|
||||
}: {
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodRouterRouteView, zodRoute, zodRouterRoute, zodRouteView } from "../zod";
|
||||
|
||||
export default function isRouter(
|
||||
route: z.infer<typeof zodRouteView> | z.infer<typeof zodRoute>
|
||||
): route is z.infer<typeof zodRouterRouteView> | z.infer<typeof zodRouterRoute> {
|
||||
if ("isRouter" in route) {
|
||||
return route.isRouter;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodFieldView, zodField, zodRouterField, zodRouterFieldView } from "../zod";
|
||||
|
||||
export default function isRouterLinkedField(
|
||||
field: z.infer<typeof zodFieldView> | z.infer<typeof zodField>
|
||||
): field is z.infer<typeof zodRouterField> | z.infer<typeof zodRouterFieldView> {
|
||||
if ("routerId" in field) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import jsonLogic from "json-logic-js";
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
|
||||
import { Response, Route, SerializableForm } from "../types/types";
|
||||
import { zodNonRouterRoute } from "../zod";
|
||||
import { isFallbackRoute } from "./isFallbackRoute";
|
||||
import isRouter from "./isRouter";
|
||||
import { App_RoutingForms_Form } from ".prisma/client";
|
||||
|
||||
export function processRoute({
|
||||
@@ -18,15 +22,24 @@ export function processRoute({
|
||||
|
||||
let decidedAction: Route["action"] | null = null;
|
||||
|
||||
const fallbackRoute = routes.find((route) => route.isFallback);
|
||||
const fallbackRoute = routes.find(isFallbackRoute);
|
||||
|
||||
if (!fallbackRoute) {
|
||||
throw new Error("Fallback route is missing");
|
||||
}
|
||||
|
||||
const reorderedRoutes = routes.filter((route) => !route.isFallback).concat([fallbackRoute]);
|
||||
const routesWithFallbackInEnd = routes
|
||||
.flatMap((r) => {
|
||||
// For a router, use it's routes instead.
|
||||
if (isRouter(r)) return r.routes;
|
||||
return r;
|
||||
})
|
||||
// Use only non fallback routes
|
||||
.filter((route) => route && !isFallbackRoute(route))
|
||||
// After above flat map, all routes are non router routes.
|
||||
.concat([fallbackRoute]) as z.infer<typeof zodNonRouterRoute>[];
|
||||
|
||||
reorderedRoutes.some((route) => {
|
||||
routesWithFallbackInEnd.some((route) => {
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFieldArray, UseFormReturn } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -21,17 +20,12 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import SingleForm, {
|
||||
getServerSidePropsForSingleFormView as getServerSideProps,
|
||||
RoutingFormWithResponseCount,
|
||||
} from "../../components/SingleForm";
|
||||
import { SerializableForm } from "../../types/types";
|
||||
|
||||
export { getServerSideProps };
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
type RoutingFormWithResponseCount = RoutingForm & {
|
||||
_count: {
|
||||
responses: number;
|
||||
};
|
||||
};
|
||||
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
|
||||
|
||||
export const FieldTypes = [
|
||||
{
|
||||
label: "Short Text",
|
||||
@@ -70,6 +64,7 @@ function Field({
|
||||
fieldIndex,
|
||||
moveUp,
|
||||
moveDown,
|
||||
appUrl,
|
||||
}: {
|
||||
fieldIndex: number;
|
||||
hookForm: HookForm;
|
||||
@@ -86,6 +81,7 @@ function Field({
|
||||
check: () => boolean;
|
||||
fn: () => void;
|
||||
};
|
||||
appUrl: string;
|
||||
}) {
|
||||
const [identifier, _setIdentifier] = useState(hookForm.getValues(`${hookFieldNamespace}.identifier`));
|
||||
|
||||
@@ -102,7 +98,8 @@ function Field({
|
||||
_setIdentifier(label);
|
||||
}
|
||||
}, [label, hookFieldNamespace, hookForm]);
|
||||
|
||||
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
|
||||
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
|
||||
return (
|
||||
<div
|
||||
data-testid="field"
|
||||
@@ -111,13 +108,22 @@ function Field({
|
||||
label={label || `Field ${fieldIndex + 1}`}
|
||||
moveUp={moveUp}
|
||||
moveDown={moveDown}
|
||||
deleteField={deleteField}>
|
||||
badge={
|
||||
router ? { text: router.name, variant: "gray", href: `${appUrl}/form-edit/${router.id}` } : null
|
||||
}
|
||||
deleteField={router ? null : deleteField}>
|
||||
<div className="w-full">
|
||||
<div className="mb-6 w-full">
|
||||
<TextField
|
||||
disabled={!!router}
|
||||
label="Label"
|
||||
type="text"
|
||||
placeholder="This is what your users would see"
|
||||
/**
|
||||
* This is a bit of a hack to make sure that for routerField, label is shown from there.
|
||||
* For other fields, value property is used because it exists and would take precedence
|
||||
*/
|
||||
defaultValue={routerField?.label}
|
||||
required
|
||||
{...hookForm.register(`${hookFieldNamespace}.label`)}
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
@@ -125,11 +131,13 @@ function Field({
|
||||
</div>
|
||||
<div className="mb-6 w-full">
|
||||
<TextField
|
||||
disabled={!!router}
|
||||
label="Identifier"
|
||||
name="identifier"
|
||||
required
|
||||
placeholder="Identifies field by this name."
|
||||
value={identifier}
|
||||
defaultValue={routerField?.identifier || routerField?.label}
|
||||
onChange={(e) => setUserChangedIdentifier(e.target.value)}
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
/>
|
||||
@@ -138,11 +146,13 @@ function Field({
|
||||
<Controller
|
||||
name={`${hookFieldNamespace}.type`}
|
||||
control={hookForm.control}
|
||||
defaultValue={routerField?.type}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
||||
return (
|
||||
<SelectField
|
||||
label="Type"
|
||||
isDisabled={!!router}
|
||||
containerClassName="data-testid-field-type"
|
||||
options={FieldTypes}
|
||||
onChange={(option) => {
|
||||
@@ -161,8 +171,10 @@ function Field({
|
||||
<div className="mt-2 block items-center sm:flex">
|
||||
<div className="w-full">
|
||||
<TextAreaField
|
||||
disabled={!!router}
|
||||
rows={3}
|
||||
label="Options"
|
||||
defaultValue={routerField?.selectText}
|
||||
placeholder="Add 1 option per line"
|
||||
{...hookForm.register(`${hookFieldNamespace}.selectText`)}
|
||||
/>
|
||||
@@ -174,8 +186,16 @@ function Field({
|
||||
<Controller
|
||||
name={`${hookFieldNamespace}.required`}
|
||||
control={hookForm.control}
|
||||
defaultValue={routerField?.required}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <BooleanToggleGroupField label="Required" value={value} onValueChange={onChange} />;
|
||||
return (
|
||||
<BooleanToggleGroupField
|
||||
disabled={!!router}
|
||||
label="Required"
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -184,12 +204,15 @@ function Field({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FormEdit = ({
|
||||
hookForm,
|
||||
form,
|
||||
appUrl,
|
||||
}: {
|
||||
hookForm: HookForm;
|
||||
form: inferSSRProps<typeof getServerSideProps>["form"];
|
||||
appUrl: string;
|
||||
}) => {
|
||||
const fieldsNamespace = "fields";
|
||||
const {
|
||||
@@ -228,6 +251,7 @@ const FormEdit = ({
|
||||
{hookFormFields.map((field, key) => {
|
||||
return (
|
||||
<Field
|
||||
appUrl={appUrl}
|
||||
fieldIndex={key}
|
||||
hookForm={hookForm}
|
||||
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
||||
@@ -272,14 +296,18 @@ const FormEdit = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button data-testid="add-field" onClick={addField} className="w-full">
|
||||
<div className="w-full">
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiFileText}
|
||||
headline="Create your first field"
|
||||
description="Fields are the form fields that the booker would see."
|
||||
buttonRaw={<Button>Create Field</Button>}
|
||||
buttonRaw={
|
||||
<Button data-testid="add-field" onClick={addField}>
|
||||
Create Field
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -291,7 +319,7 @@ export default function FormEditPage({
|
||||
<SingleForm
|
||||
form={form}
|
||||
appUrl={appUrl}
|
||||
Page={({ hookForm, form }) => <FormEdit hookForm={hookForm} form={form} />}
|
||||
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ export default function RoutingForms({
|
||||
|
||||
function NewFormButton() {
|
||||
return (
|
||||
<FormAction routingForm={null} data-testid="new-routing-form" StartIcon={Icon.FiPlus} action="create">
|
||||
<FormAction
|
||||
size="fab"
|
||||
routingForm={null}
|
||||
data-testid="new-routing-form"
|
||||
StartIcon={Icon.FiPlus}
|
||||
action="create">
|
||||
{t("new")}
|
||||
</FormAction>
|
||||
);
|
||||
@@ -210,7 +215,10 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
},
|
||||
});
|
||||
|
||||
const serializableForms = forms.map((form) => getSerializableForm(form));
|
||||
const serializableForms = [];
|
||||
for (const [, form] of Object.entries(forms)) {
|
||||
serializableForms.push(await getSerializableForm(form));
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Builder, Config, Query, Utils as QbUtils } from "react-awesome-query-builder";
|
||||
import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-builder";
|
||||
// types
|
||||
import { BuilderProps, ImmutableTree, JsonTree } from "react-awesome-query-builder";
|
||||
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import {
|
||||
Button,
|
||||
SelectField,
|
||||
FormCard,
|
||||
Icon,
|
||||
SelectWithValidation as Select,
|
||||
Shell,
|
||||
TextArea,
|
||||
TextField,
|
||||
Badge,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import SingleForm, {
|
||||
@@ -22,7 +23,9 @@ import SingleForm, {
|
||||
} from "../../components/SingleForm";
|
||||
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
|
||||
import "../../components/react-awesome-query-builder/styles.css";
|
||||
import { SerializableForm } from "../../types/types";
|
||||
import { createFallbackRoute } from "../../lib/createFallbackRoute";
|
||||
import isRouter from "../../lib/isRouter";
|
||||
import { GlobalRoute, LocalRoute, SerializableForm, SerializableRoute } from "../../types/types";
|
||||
import { FieldTypes } from "../form-edit/[...appPages]";
|
||||
|
||||
export { getServerSideProps };
|
||||
@@ -30,8 +33,10 @@ export { getServerSideProps };
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
const InitialConfig = QueryBuilderInitialConfig;
|
||||
const hasRules = (route: Route) =>
|
||||
const hasRules = (route: Route) => {
|
||||
if (isRouter(route)) return false;
|
||||
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
|
||||
};
|
||||
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
|
||||
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
const fields: Record<
|
||||
@@ -49,6 +54,9 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
}
|
||||
> = {};
|
||||
form.fields?.forEach((field) => {
|
||||
if ("routerField" in field) {
|
||||
field = field.routerField;
|
||||
}
|
||||
if (FieldTypes.map((f) => f.value).includes(field.type)) {
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
@@ -90,7 +98,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const getEmptyRoute = (): SerializableRoute => {
|
||||
const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
|
||||
const uuid = QbUtils.uuid();
|
||||
return {
|
||||
id: uuid,
|
||||
@@ -102,41 +110,19 @@ const getEmptyRoute = (): SerializableRoute => {
|
||||
};
|
||||
};
|
||||
|
||||
const createFallbackRoute = (): SerializableRoute => {
|
||||
const uuid = QbUtils.uuid();
|
||||
return {
|
||||
id: uuid,
|
||||
isFallback: true,
|
||||
action: {
|
||||
type: "customPageMessage",
|
||||
value: "Thank you for your interest! We will be in touch soon.",
|
||||
},
|
||||
queryValue: { id: uuid, type: "group" },
|
||||
};
|
||||
};
|
||||
type Route =
|
||||
| (LocalRoute & {
|
||||
// This is what's persisted
|
||||
queryValue: JsonTree;
|
||||
// `queryValue` is parsed to create state
|
||||
state: {
|
||||
tree: ImmutableTree;
|
||||
config: QueryBuilderUpdatedConfig;
|
||||
};
|
||||
})
|
||||
| GlobalRoute;
|
||||
|
||||
type Route = {
|
||||
id: string;
|
||||
isFallback?: boolean;
|
||||
action: {
|
||||
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
|
||||
value: string;
|
||||
};
|
||||
// This is what's persisted
|
||||
queryValue: JsonTree;
|
||||
// `queryValue` is parsed to create state
|
||||
state: {
|
||||
tree: ImmutableTree;
|
||||
config: QueryBuilderUpdatedConfig;
|
||||
};
|
||||
};
|
||||
|
||||
type SerializableRoute = Pick<Route, "id" | "action"> & {
|
||||
queryValue: Route["queryValue"];
|
||||
isFallback?: Route["isFallback"];
|
||||
};
|
||||
|
||||
export const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
|
||||
export const RoutingPages: { label: string; value: NonNullable<LocalRoute["action"]>["type"] }[] = [
|
||||
{
|
||||
label: "Custom Page",
|
||||
value: "customPageMessage",
|
||||
@@ -159,6 +145,8 @@ const Route = ({
|
||||
setRoutes,
|
||||
moveUp,
|
||||
moveDown,
|
||||
appUrl,
|
||||
disabled = false,
|
||||
}: {
|
||||
route: Route;
|
||||
routes: Route[];
|
||||
@@ -167,6 +155,8 @@ const Route = ({
|
||||
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
|
||||
moveUp?: { fn: () => void; check: () => boolean } | null;
|
||||
moveDown?: { fn: () => void; check: () => boolean } | null;
|
||||
appUrl: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const index = routes.indexOf(route);
|
||||
|
||||
@@ -202,6 +192,42 @@ const Route = ({
|
||||
[]
|
||||
);
|
||||
|
||||
if (isRouter(route)) {
|
||||
return (
|
||||
<div>
|
||||
<FormCard
|
||||
moveUp={moveUp}
|
||||
moveDown={moveDown}
|
||||
deleteField={{
|
||||
check: () => routes.length !== 1,
|
||||
fn: () => {
|
||||
const newRoutes = routes.filter((r) => r.id !== route.id);
|
||||
setRoutes(newRoutes);
|
||||
},
|
||||
}}
|
||||
label={
|
||||
<div>
|
||||
<span className="mr-2">{`Route ${index + 1}`}</span>
|
||||
</div>
|
||||
}
|
||||
className="mb-6">
|
||||
<div className="-mt-3">
|
||||
<Link href={`/${appUrl}/route-builder/${route.id}`}>
|
||||
<a>
|
||||
<Badge variant="gray">
|
||||
<span className="font-semibold">{route.name}</span>
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Fields available in <span className="font-bold">{route.name}</span> will be added to this form.
|
||||
</p>
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
className="mb-6"
|
||||
@@ -223,14 +249,15 @@ const Route = ({
|
||||
<span>Send Booker to</span>
|
||||
</div>
|
||||
<Select
|
||||
isDisabled={disabled}
|
||||
className="block w-full flex-grow px-2"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === route.action.type)}
|
||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const action: Route["action"] = {
|
||||
const action: LocalRoute["action"] = {
|
||||
type: item.value,
|
||||
value: "",
|
||||
};
|
||||
@@ -245,10 +272,11 @@ const Route = ({
|
||||
}}
|
||||
options={RoutingPages}
|
||||
/>
|
||||
{route.action.type ? (
|
||||
route.action.type === "customPageMessage" ? (
|
||||
{route.action?.type ? (
|
||||
route.action?.type === "customPageMessage" ? (
|
||||
<TextArea
|
||||
required
|
||||
disabled={disabled}
|
||||
name="customPageMessage"
|
||||
className="flex w-full flex-grow border-gray-300"
|
||||
value={route.action.value}
|
||||
@@ -256,8 +284,9 @@ const Route = ({
|
||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
||||
}}
|
||||
/>
|
||||
) : route.action.type === "externalRedirectUrl" ? (
|
||||
) : route.action?.type === "externalRedirectUrl" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="externalRedirectUrl"
|
||||
className="flex w-full flex-grow border-gray-300 text-sm"
|
||||
containerClassName="w-full mt-2"
|
||||
@@ -274,6 +303,7 @@ const Route = ({
|
||||
<div className="block w-full">
|
||||
<Select
|
||||
required
|
||||
isDisabled={disabled}
|
||||
options={eventOptions}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
@@ -308,7 +338,10 @@ const Route = ({
|
||||
);
|
||||
};
|
||||
|
||||
const deserializeRoute = (route: SerializableRoute, config: QueryBuilderUpdatedConfig): Route => {
|
||||
const deserializeRoute = (
|
||||
route: Exclude<SerializableRoute, GlobalRoute>,
|
||||
config: QueryBuilderUpdatedConfig
|
||||
): Route => {
|
||||
return {
|
||||
...route,
|
||||
state: {
|
||||
@@ -321,18 +354,22 @@ const deserializeRoute = (route: SerializableRoute, config: QueryBuilderUpdatedC
|
||||
const Routes = ({
|
||||
form,
|
||||
hookForm,
|
||||
appUrl,
|
||||
}: {
|
||||
form: inferSSRProps<typeof getServerSideProps>["form"];
|
||||
// Figure out the type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hookForm: any;
|
||||
appUrl: string;
|
||||
}) => {
|
||||
const { routes: serializedRoutes } = form;
|
||||
|
||||
const config = getQueryBuilderConfig(form);
|
||||
const [routes, setRoutes] = useState(() => {
|
||||
const transformRoutes = () => {
|
||||
const _routes = serializedRoutes || [getEmptyRoute()];
|
||||
_routes.forEach((r) => {
|
||||
if (isRouter(r)) return;
|
||||
if (!r.queryValue?.id) {
|
||||
r.queryValue = { id: QbUtils.uuid(), type: "group" };
|
||||
}
|
||||
@@ -340,13 +377,74 @@ const Routes = ({
|
||||
return _routes;
|
||||
};
|
||||
|
||||
return transformRoutes().map((route) => deserializeRoute(route, config));
|
||||
return transformRoutes().map((route) => {
|
||||
if (isRouter(route)) return route;
|
||||
return deserializeRoute(route, config);
|
||||
});
|
||||
});
|
||||
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
|
||||
|
||||
const availableRouters =
|
||||
allForms
|
||||
?.filter((router) => {
|
||||
return router.id !== form.id;
|
||||
})
|
||||
.map((router) => {
|
||||
return {
|
||||
value: router.id,
|
||||
label: router.name,
|
||||
name: router.name,
|
||||
description: router.description,
|
||||
isDisabled: false,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const isConnectedForm = (id: string) => form.connectedForms.map((f) => f.id).includes(id);
|
||||
|
||||
const routerOptions = (
|
||||
[
|
||||
{
|
||||
label: "Create a New Route",
|
||||
value: "newRoute",
|
||||
name: null,
|
||||
description: null,
|
||||
},
|
||||
] as {
|
||||
label: string;
|
||||
value: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
isDisabled?: boolean;
|
||||
}[]
|
||||
).concat(
|
||||
availableRouters.map((r) => {
|
||||
// Reset disabled state
|
||||
r.isDisabled = false;
|
||||
|
||||
// Can't select a form as router that is already a connected form. It avoids cyclic dependency
|
||||
if (isConnectedForm(r.value)) {
|
||||
r.isDisabled = true;
|
||||
}
|
||||
// A route that's already used, can't be reselected
|
||||
if (routes.find((route) => route.id === r.value)) {
|
||||
r.isDisabled = true;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
const mainRoutes = routes.filter((route) => !route.isFallback);
|
||||
let fallbackRoute = routes.find((route) => route.isFallback);
|
||||
const mainRoutes = routes.filter((route) => {
|
||||
if (isRouter(route)) return true;
|
||||
return !route.isFallback;
|
||||
});
|
||||
|
||||
let fallbackRoute = routes.find((route) => {
|
||||
if (isRouter(route)) return false;
|
||||
return route.isFallback;
|
||||
});
|
||||
|
||||
if (!fallbackRoute) {
|
||||
fallbackRoute = deserializeRoute(createFallbackRoute(), config);
|
||||
setRoutes((routes) => {
|
||||
@@ -363,6 +461,7 @@ const Routes = ({
|
||||
return [...routes.filter((route) => route.id !== fallbackRoute!.id), fallbackRoute!];
|
||||
});
|
||||
}
|
||||
|
||||
const setRoute = (id: string, route: Partial<Route>) => {
|
||||
const index = routes.findIndex((route) => route.id === id);
|
||||
const newRoutes = [...routes];
|
||||
@@ -380,19 +479,27 @@ const Routes = ({
|
||||
});
|
||||
};
|
||||
|
||||
const routesToSave: SerializableRoute[] = routes.map((route) => ({
|
||||
id: route.id,
|
||||
action: route.action,
|
||||
isFallback: route.isFallback,
|
||||
queryValue: route.queryValue,
|
||||
}));
|
||||
const routesToSave = routes.map((route) => {
|
||||
if (isRouter(route)) {
|
||||
return route;
|
||||
}
|
||||
return {
|
||||
id: route.id,
|
||||
action: route.action,
|
||||
isFallback: route.isFallback,
|
||||
queryValue: route.queryValue,
|
||||
};
|
||||
});
|
||||
|
||||
hookForm.setValue("routes", routesToSave);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col-reverse md:flex-row">
|
||||
<div ref={animationRef} className="w-full ltr:mr-2 rtl:ml-2">
|
||||
{mainRoutes.map((route, key) => {
|
||||
return (
|
||||
<Route
|
||||
appUrl={appUrl}
|
||||
key={route.id}
|
||||
config={config}
|
||||
route={route}
|
||||
@@ -415,28 +522,50 @@ const Routes = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
className="mb-6"
|
||||
color="secondary"
|
||||
StartIcon={Icon.FiPlus}
|
||||
data-testid="add-route"
|
||||
onClick={() => {
|
||||
const newEmptyRoute = getEmptyRoute();
|
||||
const newRoutes = [
|
||||
...routes,
|
||||
{
|
||||
...newEmptyRoute,
|
||||
state: {
|
||||
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
|
||||
config,
|
||||
<SelectField
|
||||
placeholder="Select a router"
|
||||
containerClassName="mb-6 data-testid-select-router"
|
||||
isOptionDisabled={(option) => !!option.isDisabled}
|
||||
label="Add a new Route"
|
||||
options={routerOptions}
|
||||
key={mainRoutes.length}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
const router = option.value;
|
||||
if (router === "newRoute") {
|
||||
const newEmptyRoute = getEmptyRoute();
|
||||
const newRoutes = [
|
||||
...routes,
|
||||
{
|
||||
...newEmptyRoute,
|
||||
state: {
|
||||
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
setRoutes(newRoutes);
|
||||
}}>
|
||||
Add Route
|
||||
</Button>
|
||||
];
|
||||
|
||||
setRoutes(newRoutes);
|
||||
} else {
|
||||
const routerId = router;
|
||||
if (!routerId) {
|
||||
return;
|
||||
}
|
||||
setRoutes([
|
||||
...routes,
|
||||
{
|
||||
isRouter: true,
|
||||
id: routerId,
|
||||
name: option.name,
|
||||
description: option.description,
|
||||
} as Route,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Route
|
||||
config={config}
|
||||
@@ -444,6 +573,7 @@ const Routes = ({
|
||||
routes={routes}
|
||||
setRoute={setRoute}
|
||||
setRoutes={setRoutes}
|
||||
appUrl={appUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -461,7 +591,7 @@ export default function RouteBuilder({
|
||||
appUrl={appUrl}
|
||||
Page={({ hookForm, form }) => (
|
||||
<div className="route-config">
|
||||
<Routes hookForm={hookForm} form={form} />
|
||||
<Routes hookForm={hookForm} appUrl={appUrl} form={form} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const serializableForm = getSerializableForm(form);
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
|
||||
const response: Record<string, Pick<Response[string], "value">> = {};
|
||||
serializableForm.fields?.forEach((field) => {
|
||||
|
||||
@@ -199,7 +199,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
brandColor: form.user.brandColor,
|
||||
darkBrandColor: form.user.darkBrandColor,
|
||||
},
|
||||
form: getSerializableForm(form),
|
||||
form: await getSerializableForm(form),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,88 +2,13 @@ import { expect, Page } from "@playwright/test";
|
||||
|
||||
import { Fixtures, test } from "@calcom/web/playwright/lib/fixtures";
|
||||
|
||||
async function gotoRoutingLink(page: Page, formId: string) {
|
||||
await page.goto(`/forms/${formId}`);
|
||||
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
export async function addForm(page: Page) {
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
await page.fill("input[name]", "Test Form Name");
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
if (!formId) {
|
||||
throw new Error("Form ID couldn't be determined from url");
|
||||
}
|
||||
return formId;
|
||||
}
|
||||
|
||||
async function verifySelectOptions(
|
||||
selector: { selector: string; nth: number },
|
||||
expectedOptions: string[],
|
||||
page: Page
|
||||
) {
|
||||
await page.locator(selector.selector).nth(selector.nth).click();
|
||||
const selectOptions = await page
|
||||
.locator(selector.selector)
|
||||
.nth(selector.nth)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.allInnerTexts();
|
||||
|
||||
const sortedSelectOptions = [...selectOptions].sort();
|
||||
const sortedExpectedOptions = [...expectedOptions].sort();
|
||||
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
||||
return {
|
||||
optionsInUi: selectOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fillForm(
|
||||
page: Page,
|
||||
form: { description: string; field?: { typeIndex: number; label: string } }
|
||||
) {
|
||||
await page.click('[data-testid="add-field"]');
|
||||
await page.fill('[data-testid="description"]', form.description);
|
||||
|
||||
// Verify all Options of SelectBox
|
||||
const { optionsInUi: types } = await verifySelectOptions(
|
||||
{ selector: ".data-testid-field-type", nth: 0 },
|
||||
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
|
||||
page
|
||||
);
|
||||
|
||||
if (form.field) {
|
||||
await page.fill('[name="fields.0.label"]', form.field.label);
|
||||
await page.click(".data-testid-field-type");
|
||||
await page.locator('[id*="react-select-"][aria-disabled]').nth(form.field.typeIndex).click();
|
||||
}
|
||||
await page.click('[data-testid="update-form"]');
|
||||
await page.waitForSelector(".data-testid-toast-success");
|
||||
return {
|
||||
types,
|
||||
};
|
||||
function todo(title: string) {
|
||||
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
|
||||
test.skip(title, () => {});
|
||||
}
|
||||
|
||||
test.describe("Routing Forms", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
// This also delete forms on cascade
|
||||
await users.deleteAll();
|
||||
});
|
||||
test.describe("Zero State Routing Forms", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create({ username: "routing-forms" });
|
||||
await user.login();
|
||||
// Install app
|
||||
await page.goto(`/apps/routing-forms`);
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
await page.waitForNavigation({
|
||||
url: (url) => url.pathname === `/apps/routing-forms/forms`,
|
||||
});
|
||||
});
|
||||
|
||||
test("should be able to add a new form and view it", async ({ page }) => {
|
||||
await page.waitForSelector('[data-testid="empty-screen"]');
|
||||
|
||||
@@ -107,7 +32,7 @@ test.describe("Routing Forms", () => {
|
||||
});
|
||||
|
||||
test("should be able to edit the form", async ({ page }) => {
|
||||
await addForm(page);
|
||||
const formId = await addForm(page);
|
||||
const description = "Test Description";
|
||||
|
||||
const field = {
|
||||
@@ -115,7 +40,7 @@ test.describe("Routing Forms", () => {
|
||||
typeIndex: 1,
|
||||
};
|
||||
|
||||
const { types } = await fillForm(page, {
|
||||
const { types } = await addOneFieldAndDescriptionAndSaveForm(formId, page, {
|
||||
description,
|
||||
field: field,
|
||||
});
|
||||
@@ -124,26 +49,91 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
expect(await page.inputValue(`[data-testid="description"]`), description);
|
||||
expect(await page.locator('[data-testid="field"]').count()).toBe(1);
|
||||
expect(await page.inputValue('[name="fields.0.label"]')).toBe(field.label);
|
||||
expect(await page.locator(".data-testid-field-type").first().locator("div").nth(1).innerText()).toBe(
|
||||
types[field.typeIndex]
|
||||
);
|
||||
|
||||
await expectCurrentFormToHaveFields(page, { 0: field }, types);
|
||||
|
||||
await page.click('[href*="/apps/routing-forms/route-builder/"]');
|
||||
await page.click('[data-testid="add-route"]');
|
||||
await selectNewRoute(page);
|
||||
|
||||
await page.click('[data-testid="add-rule"]');
|
||||
await verifySelectOptions(
|
||||
{
|
||||
selector: ".rule-container .data-testid-field-select",
|
||||
nth: 0,
|
||||
},
|
||||
[field.label],
|
||||
page
|
||||
);
|
||||
|
||||
await verifyFieldOptionsInRule([field.label], page);
|
||||
});
|
||||
|
||||
test.describe("F1<-F2 Relationship", () => {
|
||||
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
|
||||
const form1Id = await addForm(page, { name: "F1" });
|
||||
const form2Id = await addForm(page, { name: "F2" });
|
||||
|
||||
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
|
||||
description: "Form 1 Description",
|
||||
field: {
|
||||
label: "F1 Field1",
|
||||
typeIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { types } = await addOneFieldAndDescriptionAndSaveForm(form2Id, page, {
|
||||
description: "Form 2 Description",
|
||||
field: {
|
||||
label: "F2 Field1",
|
||||
//TODO: Maybe choose some other type and choose type by it's name and not index
|
||||
typeIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Add F1 as Router to F2
|
||||
await page.goto(`/apps/routing-forms/route-builder/${form2Id}`);
|
||||
await selectNewRoute(page, {
|
||||
// It should be F1. TODO: Verify that it's F1
|
||||
routeSelectNumber: 2,
|
||||
});
|
||||
await saveCurrentForm(page);
|
||||
|
||||
// Expect F1 fields to be available in F2
|
||||
await page.goto(`/apps/routing-forms/form-edit/${form2Id}`);
|
||||
expect(await page.locator('[data-testid="field"]').count()).toBe(2);
|
||||
await expectCurrentFormToHaveFields(page, { 1: { label: "F1 Field1", typeIndex: 1 } }, types);
|
||||
// Add 1 more field in F1
|
||||
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
|
||||
field: {
|
||||
label: "F1 Field2",
|
||||
typeIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/apps/routing-forms/form-edit/${form2Id}`);
|
||||
expect(await page.locator('[data-testid="field"]').count()).toBe(3);
|
||||
await expectCurrentFormToHaveFields(page, { 2: { label: "F1 Field2", typeIndex: 1 } }, types);
|
||||
});
|
||||
todo("Create relationship by using duplicate with live connect");
|
||||
});
|
||||
|
||||
// TODO: How to install the app just once?
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create({ username: "routing-forms" });
|
||||
await user.login();
|
||||
// Install app
|
||||
await page.goto(`/apps/routing-forms`);
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
await page.waitForNavigation({
|
||||
url: (url) => url.pathname === `/apps/routing-forms/forms`,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ users }) => {
|
||||
// This also delete forms on cascade
|
||||
await users.deleteAll();
|
||||
});
|
||||
});
|
||||
|
||||
todo("should be able to duplicate form");
|
||||
|
||||
test.describe("Seeded Routing Form ", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
// This also delete forms on cascade
|
||||
await users.deleteAll();
|
||||
});
|
||||
const createUserAndLoginAndInstallApp = async function ({
|
||||
users,
|
||||
page,
|
||||
@@ -324,6 +314,27 @@ test.describe("Routing Forms", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectCurrentFormToHaveFields(
|
||||
page: Page,
|
||||
fields: {
|
||||
[key: number]: { label: string; typeIndex: number };
|
||||
},
|
||||
types: string[]
|
||||
) {
|
||||
for (const [index, field] of Object.entries(fields)) {
|
||||
expect(await page.inputValue(`[name="fields.${index}.label"]`)).toBe(field.label);
|
||||
expect(
|
||||
await page
|
||||
.locator(".data-testid-field-type")
|
||||
.nth(+index)
|
||||
.locator("div")
|
||||
.nth(1)
|
||||
.innerText()
|
||||
).toBe(types[field.typeIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="form-field"]', "event-routing");
|
||||
@@ -348,3 +359,127 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await page.click('button[type="submit"]');
|
||||
await page.isVisible("text=Custom Page Result");
|
||||
}
|
||||
|
||||
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||
await page.goto("/apps/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
await page.fill("input[name]", name);
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
if (!formId) {
|
||||
throw new Error("Form ID couldn't be determined from url");
|
||||
}
|
||||
return formId;
|
||||
}
|
||||
|
||||
export async function addOneFieldAndDescriptionAndSaveForm(
|
||||
formId: string,
|
||||
page: Page,
|
||||
form: { description?: string; field?: { typeIndex: number; label: string } }
|
||||
) {
|
||||
await page.goto(`apps/routing-forms/form-edit/${formId}`);
|
||||
await page.click('[data-testid="add-field"]');
|
||||
if (form.description) {
|
||||
await page.fill('[data-testid="description"]', form.description);
|
||||
}
|
||||
|
||||
// Verify all Options of SelectBox
|
||||
const { optionsInUi: types } = await verifySelectOptions(
|
||||
{ selector: ".data-testid-field-type", nth: 0 },
|
||||
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
|
||||
page
|
||||
);
|
||||
|
||||
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
|
||||
|
||||
if (form.field) {
|
||||
await page.fill(`[name="fields.${nextFieldIndex}.label"]`, form.field.label);
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator(".data-testid-field-type")
|
||||
.click();
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.nth(form.field.typeIndex)
|
||||
.click();
|
||||
}
|
||||
await saveCurrentForm(page);
|
||||
return {
|
||||
types,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectOption({
|
||||
page,
|
||||
selector,
|
||||
option,
|
||||
}: {
|
||||
page: Page;
|
||||
selector: { selector: string; nth: number };
|
||||
option: number;
|
||||
}) {
|
||||
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
|
||||
await locatorForSelect.click();
|
||||
await locatorForSelect
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.nth(option - 1)
|
||||
.click();
|
||||
}
|
||||
|
||||
async function verifyFieldOptionsInRule(options: string[], page: Page) {
|
||||
await verifySelectOptions(
|
||||
{
|
||||
selector: ".rule-container .data-testid-field-select",
|
||||
nth: 0,
|
||||
},
|
||||
options,
|
||||
page
|
||||
);
|
||||
}
|
||||
|
||||
async function verifySelectOptions(
|
||||
selector: { selector: string; nth: number },
|
||||
expectedOptions: string[],
|
||||
page: Page
|
||||
) {
|
||||
await page.locator(selector.selector).nth(selector.nth).click();
|
||||
const selectOptions = await page
|
||||
.locator(selector.selector)
|
||||
.nth(selector.nth)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.allInnerTexts();
|
||||
|
||||
const sortedSelectOptions = [...selectOptions].sort();
|
||||
const sortedExpectedOptions = [...expectedOptions].sort();
|
||||
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
||||
return {
|
||||
optionsInUi: selectOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
||||
await selectOption({
|
||||
selector: {
|
||||
selector: ".data-testid-select-router",
|
||||
nth: 0,
|
||||
},
|
||||
option: routeSelectNumber,
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoRoutingLink(page: Page, formId: string) {
|
||||
await page.goto(`/forms/${formId}`);
|
||||
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
async function saveCurrentForm(page: Page) {
|
||||
await page.click('[data-testid="update-form"]');
|
||||
await page.waitForSelector(".data-testid-toast-success");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { App_RoutingForms_Form, Prisma, User, WebhookTriggerEvents } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
@@ -12,10 +11,15 @@ import { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import ResponseEmail from "./emails/templates/response-email";
|
||||
import { jsonLogicToPrisma } from "./jsonLogicToPrisma";
|
||||
import { createFallbackRoute } from "./lib/createFallbackRoute";
|
||||
import getConnectedForms from "./lib/getConnectedForms";
|
||||
import { getSerializableForm } from "./lib/getSerializableForm";
|
||||
import { isAllowed } from "./lib/isAllowed";
|
||||
import { isFallbackRoute } from "./lib/isFallbackRoute";
|
||||
import { isFormEditAllowed } from "./lib/isFormEditAllowed";
|
||||
import isRouter from "./lib/isRouter";
|
||||
import isRouterLinkedField from "./lib/isRouterLinkedField";
|
||||
import { Response, SerializableForm } from "./types/types";
|
||||
import { zodFields, zodRoutes } from "./zod";
|
||||
import { zodFields, zodRouterRoute, zodRoutes } from "./zod";
|
||||
|
||||
async function onFormSubmission(
|
||||
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
||||
@@ -106,7 +110,7 @@ const appRoutingForms = router({
|
||||
});
|
||||
}
|
||||
|
||||
const serializableForm = getSerializableForm(form);
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
if (!serializableForm.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
@@ -191,7 +195,10 @@ const appRoutingForms = router({
|
||||
},
|
||||
});
|
||||
|
||||
const serializableForms = forms.map((form) => getSerializableForm(form));
|
||||
const serializableForms = [];
|
||||
for (let i = 0; i < forms.length; i++) {
|
||||
serializableForms.push(await getSerializableForm(forms[i]));
|
||||
}
|
||||
return serializableForms;
|
||||
}),
|
||||
formQuery: authedProcedure
|
||||
@@ -220,7 +227,7 @@ const appRoutingForms = router({
|
||||
return null;
|
||||
}
|
||||
|
||||
return getSerializableForm(form);
|
||||
return await getSerializableForm(form);
|
||||
}),
|
||||
formMutation: authedProcedure
|
||||
.input(
|
||||
@@ -233,54 +240,29 @@ const appRoutingForms = router({
|
||||
routes: zodRoutes,
|
||||
addFallback: z.boolean().optional(),
|
||||
duplicateFrom: z.string().nullable().optional(),
|
||||
shouldConnect: z.boolean().optional(),
|
||||
settings: RoutingFormSettings.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom } = input;
|
||||
if (!(await isAllowed({ userId: user.id, formId: id }))) {
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
let { routes } = input;
|
||||
let { fields } = input;
|
||||
let { routes: inputRoutes } = input;
|
||||
let { fields: inputFields } = input;
|
||||
inputFields = inputFields || [];
|
||||
inputRoutes = inputRoutes || [];
|
||||
type InputFields = typeof inputFields;
|
||||
type InputRoutes = typeof inputRoutes;
|
||||
let routes: InputRoutes;
|
||||
let fields: InputFields;
|
||||
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
|
||||
|
||||
if (duplicateFrom) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
fields: true,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
const fieldParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
if (!fieldParsed.success || !routesParsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not parse source form's fields or routes",
|
||||
});
|
||||
}
|
||||
// Duplicate just routes and fields
|
||||
// We don't want name, description and responses to be copied
|
||||
routes = routesParsed.data;
|
||||
fields = fieldParsed.data;
|
||||
}
|
||||
|
||||
fields = fields || [];
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
@@ -299,35 +281,28 @@ const appRoutingForms = router({
|
||||
},
|
||||
});
|
||||
|
||||
// Add back deleted fields in the end. Fields can't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
|
||||
if (form) {
|
||||
const serializedForm = getSerializableForm(form, true);
|
||||
// Find all fields that are in DB(including deleted) but not in the mutation
|
||||
const deletedFields =
|
||||
serializedForm.fields?.filter((f) => !fields!.find((field) => field.id === f.id)) || [];
|
||||
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
|
||||
|
||||
fields = fields.concat(
|
||||
deletedFields.map((f) => {
|
||||
f.deleted = true;
|
||||
return f;
|
||||
})
|
||||
);
|
||||
if (duplicateFrom) {
|
||||
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
|
||||
} else {
|
||||
[fields, routes] = [inputFields, inputRoutes];
|
||||
if (dbSerializedForm) {
|
||||
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
|
||||
}
|
||||
}
|
||||
|
||||
if (dbSerializedForm) {
|
||||
// If it's an existing form being mutated, update fields in the connected forms(if any).
|
||||
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
|
||||
}
|
||||
|
||||
fields = await getUpdatedRouterLinkedFields(fields, routes);
|
||||
|
||||
if (addFallback) {
|
||||
const uuid = uuidv4();
|
||||
routes = routes || [];
|
||||
// Add a fallback route if there is none
|
||||
if (!routes.find((route) => route.isFallback)) {
|
||||
routes.push({
|
||||
id: uuid,
|
||||
isFallback: true,
|
||||
action: {
|
||||
type: "customPageMessage",
|
||||
value: "Thank you for your interest! We will be in touch soon.",
|
||||
},
|
||||
queryValue: { id: uuid, type: "group" },
|
||||
});
|
||||
if (!routes.find(isFallbackRoute)) {
|
||||
routes.push(createFallbackRoute());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +316,7 @@ const appRoutingForms = router({
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fields: fields,
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
|
||||
@@ -350,13 +325,230 @@ const appRoutingForms = router({
|
||||
},
|
||||
update: {
|
||||
disabled: disabled,
|
||||
fields: fields,
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
settings: settings === null ? Prisma.JsonNull : settings,
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* If Form has Router Linked fields, enrich them with the latest info from the Router
|
||||
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
|
||||
*/
|
||||
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
|
||||
const routerLinkedFields: Record<string, boolean> = {};
|
||||
for (const [, field] of Object.entries(fields)) {
|
||||
if (!isRouterLinkedField(field)) {
|
||||
continue;
|
||||
}
|
||||
routerLinkedFields[field.routerId] = true;
|
||||
|
||||
if (!routes.some((route) => route.id === field.routerId)) {
|
||||
// If the field is from a router that is not available anymore, mark it as deleted
|
||||
field.deleted = true;
|
||||
continue;
|
||||
}
|
||||
// Get back deleted field as now the Router is there for it.
|
||||
if (field.deleted) field.deleted = false;
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: field.routerId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
|
||||
// There is a field from some router available, make sure that the field has up-to-date info from the router
|
||||
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
|
||||
// Update local field(cache) with router field on every mutation
|
||||
Object.assign(field, routerField);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, route] of Object.entries(routes)) {
|
||||
if (!isRouter(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
|
||||
if (routerLinkedFields[route.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
const fieldsFromRouter = parsedRouterFields
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
...f,
|
||||
routerId: route.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (fieldsFromRouter) {
|
||||
fields = fields.concat(fieldsFromRouter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function findFieldWithId(id: string, fields: InputFields) {
|
||||
return fields.find((field) => field.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields in connected forms as per the inputFields
|
||||
*/
|
||||
async function updateFieldsInConnectedForms(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
inputFields: InputFields
|
||||
) {
|
||||
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
|
||||
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
});
|
||||
if (!connectedFormDb) {
|
||||
continue;
|
||||
}
|
||||
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
|
||||
|
||||
const fieldsThatAreNotInConnectedForm = (
|
||||
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
|
||||
).map((f) => ({
|
||||
...f,
|
||||
routerId: serializedForm.id,
|
||||
}));
|
||||
|
||||
const updatedConnectedFormFields = connectedFormFields
|
||||
// Update fields that are already in connected form
|
||||
?.map((field) => {
|
||||
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
|
||||
return {
|
||||
...field,
|
||||
...findFieldWithId(field.id, inputFields || []),
|
||||
};
|
||||
}
|
||||
return field;
|
||||
})
|
||||
// Add fields that are not there
|
||||
.concat(fieldsThatAreNotInConnectedForm);
|
||||
|
||||
await prisma.app_RoutingForms_Form.update({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
data: {
|
||||
fields: updatedConnectedFormFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fields: true,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
|
||||
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
if (!fieldsParsed.success || !routesParsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not parse source form's fields or routes",
|
||||
});
|
||||
}
|
||||
|
||||
let fields, routes;
|
||||
if (shouldConnect) {
|
||||
routes = [
|
||||
// This connected route would automatically link the fields
|
||||
zodRouterRoute.parse({
|
||||
id: sourceForm.id,
|
||||
isRouter: true,
|
||||
}),
|
||||
];
|
||||
fields =
|
||||
fieldsParsed.data
|
||||
// Deleted fields in the form shouldn't be added to the new form
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
routerId: sourceForm.id,
|
||||
label: "",
|
||||
type: "",
|
||||
};
|
||||
}) || [];
|
||||
} else {
|
||||
// Duplicate just routes and fields
|
||||
// We don't want name, description and responses to be copied
|
||||
routes = routesParsed.data || [];
|
||||
// FIXME: Deleted fields shouldn't come in duplicate
|
||||
fields = fieldsParsed.data || [];
|
||||
}
|
||||
return { routes, fields };
|
||||
}
|
||||
|
||||
function markMissingFieldsDeleted(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
fields: InputFields
|
||||
) {
|
||||
// Find all fields that are in DB(including deleted) but not in the mutation
|
||||
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
|
||||
const deletedFields =
|
||||
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
|
||||
|
||||
// Add back deleted fields in the end and mark them deleted.
|
||||
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
|
||||
fields = fields.concat(
|
||||
deletedFields.map((f) => {
|
||||
f.deleted = true;
|
||||
return f;
|
||||
})
|
||||
);
|
||||
return fields;
|
||||
}
|
||||
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
|
||||
const routesOfRouter = zodRoutes.parse(router.routes);
|
||||
if (routesOfRouter) {
|
||||
if (routesOfRouter.find(isRouter)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
deleteForm: authedProcedure
|
||||
.input(
|
||||
@@ -366,11 +558,24 @@ const appRoutingForms = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
if (!(await isAllowed({ userId: user.id, formId: input.id }))) {
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const areFormsUsingIt = (
|
||||
await getConnectedForms(prisma, {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
})
|
||||
).length;
|
||||
if (areFormsUsingIt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This form is being used by other forms. Please remove it's usage from there first.",
|
||||
});
|
||||
}
|
||||
return await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
@@ -378,6 +583,7 @@ const appRoutingForms = router({
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
report: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -414,7 +620,7 @@ const appRoutingForms = router({
|
||||
});
|
||||
}
|
||||
// TODO: Second argument is required to return deleted operators.
|
||||
const serializedForm = getSerializableForm(form, true);
|
||||
const serializedForm = await getSerializableForm(form, true);
|
||||
|
||||
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
|
||||
+15
-3
@@ -3,7 +3,7 @@ import z from "zod";
|
||||
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { zodFields, zodRoutes } from "../zod";
|
||||
import { zodRouterRouteView, zodNonRouterRoute, zodFieldsView, zodRoutesView } from "../zod";
|
||||
|
||||
export type Response = Record<
|
||||
// Field ID
|
||||
@@ -14,9 +14,9 @@ export type Response = Record<
|
||||
}
|
||||
>;
|
||||
|
||||
export type Fields = z.infer<typeof zodFields>;
|
||||
export type Fields = z.infer<typeof zodFieldsView>;
|
||||
export type Field = Fields[number];
|
||||
export type Routes = z.infer<typeof zodRoutes>;
|
||||
export type Routes = z.infer<typeof zodRoutesView>;
|
||||
export type Route = Routes[0];
|
||||
export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
|
||||
T,
|
||||
@@ -27,4 +27,16 @@ export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
|
||||
settings: z.infer<typeof RoutingFormSettings>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
connectedForms: { name: string; description: string | null; id: string }[];
|
||||
routers: { name: string; description: string | null; id: string }[];
|
||||
};
|
||||
|
||||
export type LocalRoute = z.infer<typeof zodNonRouterRoute>;
|
||||
export type GlobalRoute = z.infer<typeof zodRouterRouteView>;
|
||||
|
||||
export type SerializableRoute =
|
||||
| (LocalRoute & {
|
||||
queryValue: LocalRoute["queryValue"];
|
||||
isFallback?: LocalRoute["isFallback"];
|
||||
})
|
||||
| GlobalRoute;
|
||||
|
||||
@@ -1,45 +1,81 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const zodFields = z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
identifier: z.string().optional(),
|
||||
type: z.string(),
|
||||
selectText: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional();
|
||||
export const zodNonRouterField = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
identifier: z.string().optional(),
|
||||
type: z.string(),
|
||||
selectText: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const zodRoutes = z
|
||||
.union([
|
||||
z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
queryValue: z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.union([z.literal("group"), z.literal("switch_group")]),
|
||||
children1: z.any(),
|
||||
properties: z.any(),
|
||||
}),
|
||||
isFallback: z.boolean().optional(),
|
||||
action: z.object({
|
||||
// TODO: Make it a union type of "customPageMessage" and ..
|
||||
type: z.union([
|
||||
z.literal("customPageMessage"),
|
||||
z.literal("externalRedirectUrl"),
|
||||
z.literal("eventTypeRedirectUrl"),
|
||||
]),
|
||||
value: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
z.null(),
|
||||
])
|
||||
.optional();
|
||||
export const zodRouterField = zodNonRouterField.extend({
|
||||
routerId: z.string(),
|
||||
});
|
||||
|
||||
// This ordering is important - If routerId is present then it should be in the parsed object. Moving zodNonRouterField to first position doesn't do that
|
||||
export const zodField = z.union([zodRouterField, zodNonRouterField]);
|
||||
export const zodFields = z.array(zodField).optional();
|
||||
|
||||
export const zodNonRouterFieldView = zodNonRouterField;
|
||||
export const zodRouterFieldView = zodRouterField.extend({
|
||||
routerField: zodNonRouterFieldView,
|
||||
router: z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Has some additional fields that are not supposed to be saved to DB but are required for the UI
|
||||
*/
|
||||
export const zodFieldView = z.union([zodNonRouterFieldView, zodRouterFieldView]);
|
||||
|
||||
export const zodFieldsView = z.array(zodFieldView).optional();
|
||||
|
||||
export const zodNonRouterRoute = z.object({
|
||||
id: z.string(),
|
||||
queryValue: z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.union([z.literal("group"), z.literal("switch_group")]),
|
||||
children1: z.any(),
|
||||
properties: z.any(),
|
||||
}),
|
||||
isFallback: z.boolean().optional(),
|
||||
action: z.object({
|
||||
// TODO: Make it a union type of "customPageMessage" and ..
|
||||
type: z.union([
|
||||
z.literal("customPageMessage"),
|
||||
z.literal("externalRedirectUrl"),
|
||||
z.literal("eventTypeRedirectUrl"),
|
||||
]),
|
||||
value: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const zodNonRouterRouteView = zodNonRouterRoute;
|
||||
|
||||
export const zodRouterRoute = z.object({
|
||||
// This is the id of the Form being used as router
|
||||
id: z.string(),
|
||||
isRouter: z.literal(true),
|
||||
});
|
||||
|
||||
export const zodRoute = z.union([zodNonRouterRoute, zodRouterRoute]);
|
||||
|
||||
export const zodRouterRouteView = zodRouterRoute.extend({
|
||||
//TODO: Extend it from form
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
routes: z.array(z.union([zodRoute, z.null()])),
|
||||
});
|
||||
|
||||
export const zodRoutes = z.union([z.array(zodRoute), z.null()]).optional();
|
||||
|
||||
export const zodRouteView = z.union([zodNonRouterRouteView, zodRouterRouteView]);
|
||||
|
||||
export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional();
|
||||
|
||||
// TODO: This is a requirement right now that zod.ts file (if it exists) must have appDataSchema export(which is only required by apps having EventTypeAppCard interface)
|
||||
// This is a temporary solution and will be removed in future
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function Exchange2013CalendarSetup() {
|
||||
</fieldset>
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
|
||||
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
|
||||
<Button type="button" color="secondary" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function Exchange2016CalendarSetup() {
|
||||
</fieldset>
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
|
||||
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
|
||||
<Button type="button" color="secondary" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function ExchangeSetup() {
|
||||
/>
|
||||
</fieldset>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button type="button" color="secondary" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user