Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6415d42a1d | ||
|
|
ee620c7fff | ||
|
|
112efbbda2 | ||
|
|
fac0b0fa32 | ||
|
|
12d47ab949 | ||
|
|
4fef7ba5e2 | ||
|
|
6af0428a18 | ||
|
|
d66f3d1dc9 | ||
|
|
a5249b6cb5 | ||
|
|
6771c11360 | ||
|
|
002375ac72 | ||
|
|
0402e46328 | ||
|
|
02e3738a17 | ||
|
|
f822177788 | ||
|
|
1edc9c50d5 | ||
|
|
7beb17c007 | ||
|
|
ad7af451ff | ||
|
|
f513ce8687 | ||
|
|
d83883e7e4 | ||
|
|
9e001c53e8 | ||
|
|
3937b0c4c6 | ||
|
|
cbe358bb6b | ||
|
|
2b91102f3f | ||
|
|
4e7804c46d | ||
|
|
d7226fc3d4 | ||
|
|
0cb278d109 | ||
|
|
a1430101d1 | ||
|
|
9092addd9c | ||
|
|
7f14e00119 | ||
|
|
abc5ff2d97 | ||
|
|
fcde1f7a9d | ||
|
|
30eea089ba | ||
|
|
3c8cb3a9ea | ||
|
|
54f4e665a3 |
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"projectId": "cl4u26bwz7962859ply7ibuo43t",
|
||||
"databaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||
"targetDatabaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.3.0"
|
||||
"version": "0.22.3"
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { classNames } from "@calcom/lib";
|
||||
interface ExampleProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
isFullWidth?: boolean;
|
||||
}
|
||||
export const Example = ({ children, title }: ExampleProps) => {
|
||||
export const Example = ({ children, title, isFullWidth = false }: ExampleProps) => {
|
||||
return (
|
||||
<div className="examples-item">
|
||||
<span className="examples-item-title">{title}</span>
|
||||
<div className={classNames("examples-item", isFullWidth && "w-full")}>
|
||||
<span className={classNames("examples-item-title", isFullWidth && "mb-0 mt-2")}>{title}</span>
|
||||
<div className="examples-item-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -598,60 +598,6 @@ paths:
|
||||
summary: Gets and stores the OAuth token
|
||||
tags:
|
||||
- Integrations
|
||||
/api/user/profile:
|
||||
patch:
|
||||
description: Updates a user's profile.
|
||||
summary: Updates a user's profile
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
avatar:
|
||||
type: string
|
||||
timeZone:
|
||||
type: string
|
||||
weekStart:
|
||||
type: string
|
||||
hideBranding:
|
||||
type: string
|
||||
theme:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
/api/me:
|
||||
get:
|
||||
description: Gets current user's profile.
|
||||
@@ -686,71 +632,6 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
/api/user/membership:
|
||||
get:
|
||||
description: Get a list of the teams the user has joined.
|
||||
summary: Get the teams a user is joined to
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
patch:
|
||||
description: Accept team invitation
|
||||
summary: Accept team invitation.
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
teamId:
|
||||
type: string
|
||||
delete:
|
||||
description: Leave team or decline membership invite of current user
|
||||
summary: Leave team or decline team invite.
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
teamId:
|
||||
type: string
|
||||
"/api/{team}":
|
||||
delete:
|
||||
description: Deletes a team
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Button, SkeletonButton } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||
|
||||
const Component = ({
|
||||
name,
|
||||
type,
|
||||
logo,
|
||||
body,
|
||||
categories,
|
||||
author,
|
||||
price = 0,
|
||||
commission,
|
||||
isGlobal = false,
|
||||
feeType,
|
||||
docs,
|
||||
website,
|
||||
email,
|
||||
tos,
|
||||
privacy,
|
||||
isProOnly,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const handleOpenChange = () => {
|
||||
utils.viewer.integrations.invalidate();
|
||||
router.replace("/apps/installed");
|
||||
};
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: () => {
|
||||
showToast("App successfully installed", "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || "App could not be installed", "error");
|
||||
},
|
||||
});
|
||||
|
||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const appCredentials = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
onSuccess(data) {
|
||||
setExistingCredentials(data);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
||||
|
||||
return (
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="px-8">
|
||||
<Link href="/apps">
|
||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
||||
<Icon.FiChevronLeft className="h-5 w-5" /> {t("browse_apps")}
|
||||
</a>
|
||||
</Link>
|
||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
||||
<div className="flex">
|
||||
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
|
||||
<header className="px-4 py-2">
|
||||
<div className="flex items-center">
|
||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||
</div>
|
||||
<h2 className="text-sm text-gray-500">
|
||||
<span className="capitalize">{categories[0]}</span> • {t("published_by", { author })}
|
||||
</h2>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||
{!appCredentials.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("globally_install")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button StartIcon={Icon.FiPlus} {...props} data-testid="install-app-button">
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
<DisconnectIntegration
|
||||
id={existingCredentials[0]}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button data-testid="install-app-button" {...props}>
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<SkeletonButton width="24" height="10" />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<small className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="justify-between px-8 py-10 md:flex">
|
||||
<div className="prose-sm prose mb-6">{body}</div>
|
||||
<div className="md:max-w-80 flex-1 md:ml-8">
|
||||
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
||||
<div className="space-x-2">
|
||||
{categories.map((category) => (
|
||||
<Link href={"/apps/categories/" + category} key={category}>
|
||||
<a>
|
||||
<Badge variant="success">{category}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="mt-8 font-medium text-gray-900 ">{t("pricing")}</h4>
|
||||
<small>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
<h4 className="mt-8 mb-2 font-medium text-gray-900 ">{t("learn_more")}</h4>
|
||||
<ul className="prose -ml-1 -mr-1 text-xs leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={docs}>
|
||||
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={website}>
|
||||
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={tos}>
|
||||
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-6" />
|
||||
<small className="leading-1 block text-gray-500">
|
||||
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
|
||||
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
|
||||
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
|
||||
</small>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<Icon.FiFlag className="inline h-3 w-3" /> Report App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App(props: {
|
||||
name: string;
|
||||
type: AppType["type"];
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
author: string;
|
||||
pro?: boolean;
|
||||
price?: number;
|
||||
commission?: number;
|
||||
feeType?: AppType["feeType"];
|
||||
docs?: string;
|
||||
website?: string;
|
||||
email: string; // required
|
||||
tos?: string;
|
||||
privacy?: string;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
isProOnly: AppType["isProOnly"];
|
||||
}) {
|
||||
return (
|
||||
<Shell large isPublic>
|
||||
{props.licenseRequired ? (
|
||||
<LicenseRequired>
|
||||
<Component {...props} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<Component {...props} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -146,6 +146,7 @@ const Component = ({
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
<DisconnectIntegration
|
||||
buttonProps={{ color: "secondary" }}
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2";
|
||||
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
@@ -152,13 +152,13 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
{isLoading && !slots.length && (
|
||||
<>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -43,9 +43,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: () => {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
onSuccess: (data) => {
|
||||
if (data.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -108,7 +112,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/cancel/${booking.uid}${isTabRecurring && isRecurring ? "?allRemainingBookings=true" : ""}`,
|
||||
href: `/success?uid=${booking.uid}&cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}`,
|
||||
icon: Icon.FiX,
|
||||
},
|
||||
{
|
||||
@@ -181,26 +187,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||
|
||||
const location = booking.location || "";
|
||||
|
||||
const onClickTableData = () => {
|
||||
router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date: booking.startTime,
|
||||
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
||||
type: booking.eventType.id || 0,
|
||||
eventSlug: booking.eventType.slug,
|
||||
username: user?.username || "",
|
||||
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||
location: location,
|
||||
eventName: booking.eventType.eventName || "",
|
||||
bookingId: booking.id,
|
||||
recur: booking.recurringEventId,
|
||||
reschedule: isConfirmed,
|
||||
uid: booking.uid,
|
||||
allRemainingBookings: isTabRecurring,
|
||||
listingStatus: booking.listingStatus,
|
||||
status: booking.status,
|
||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -467,8 +461,9 @@ const FirstAttendee = ({
|
||||
user: UserProps;
|
||||
currentEmail: string | null | undefined;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return user.email === currentEmail ? (
|
||||
<div className="inline-block">You</div>
|
||||
<div className="inline-block">{t("you")}</div>
|
||||
) : (
|
||||
<a
|
||||
key={user.email}
|
||||
@@ -480,19 +475,19 @@ const FirstAttendee = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Attendee: React.FC<{ email: string; children: React.ReactNode }> = ({ email, children }) => {
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||
return (
|
||||
<a className=" hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
<a className="hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
|
||||
{name || email}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
interface AttendeeProps {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const DisplayAttendees = ({
|
||||
attendees,
|
||||
user,
|
||||
@@ -500,42 +495,33 @@ const DisplayAttendees = ({
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
user: UserProps | null;
|
||||
currentEmail: string | null | undefined;
|
||||
currentEmail?: string | null;
|
||||
}) => {
|
||||
if (attendees.length === 1) {
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
<span> and </span>
|
||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
||||
</div>
|
||||
);
|
||||
} else if (attendees.length === 2) {
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
<span>, </span>
|
||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
||||
<div className="inline-block text-sm text-gray-900"> and </div>
|
||||
<Attendee email={attendees[1].email}>{attendees[1].name}</Attendee>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
<span>, </span>
|
||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
||||
<span> & </span>
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee, key) => (
|
||||
<p key={key}>{attendee.name}</p>
|
||||
))}>
|
||||
<div className="inline-block">{attendees.length - 1} more</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} />
|
||||
{attendees.length > 1 && (
|
||||
<>
|
||||
<div className="inline-block text-sm text-gray-900"> {t("and")} </div>
|
||||
{attendees.length > 2 ? (
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee) => (
|
||||
<p key={attendee.email}>
|
||||
<Attendee {...attendee} />
|
||||
</p>
|
||||
))}>
|
||||
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Attendee {...attendees[1]} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button, TextArea } from "@calcom/ui/components";
|
||||
|
||||
type Props = {
|
||||
booking: {
|
||||
@@ -22,13 +22,14 @@ type Props = {
|
||||
team?: string | null;
|
||||
setIsCancellationMode: (value: boolean) => void;
|
||||
theme: string | null;
|
||||
allRemainingBookings: boolean;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, profile, team } = props;
|
||||
const { booking, profile, team, allRemainingBookings } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
@@ -51,30 +52,23 @@ export default function CancelBooking(props: Props) {
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||
<textarea
|
||||
<TextArea
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mt-2 mb-3 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:mb-3 "
|
||||
className="mt-2 mb-4 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white "
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse sm:flex-row">
|
||||
{!props.recurringEvent && (
|
||||
<div className="border-bookinglightest mt-5 flex w-full justify-center border-t pt-3 sm:mt-0 sm:justify-start sm:border-0 sm:pt-0">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="border-0 sm:border"
|
||||
onClick={() => router.push(`/reschedule/${booking?.uid}`)}>
|
||||
{t("reschedule_this")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 flex w-full space-x-2 text-right sm:mb-0 ">
|
||||
<Button color="secondary" onClick={() => props.setIsCancellationMode(false)}>
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
<div className="ml-auto flex w-full space-x-4 ">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
onClick={() => props.setIsCancellationMode(false)}>
|
||||
{t("nevermind")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex grow justify-center"
|
||||
className="flex justify-center"
|
||||
data-testid="cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
@@ -82,6 +76,7 @@ export default function CancelBooking(props: Props) {
|
||||
const payload = {
|
||||
id: booking?.id,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
};
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
@@ -98,7 +93,7 @@ export default function CancelBooking(props: Props) {
|
||||
await router.push(
|
||||
`/cancel/success?name=${props.profile.name}&title=${booking?.title}&eventPage=${
|
||||
profile.slug
|
||||
}&team=${team ? 1 : 0}&recurring=${!!props.recurringEvent}`
|
||||
}&team=${team ? 1 : 0}&allRemainingBookings=${allRemainingBookings}`
|
||||
);
|
||||
} else {
|
||||
setLoading(false);
|
||||
@@ -110,7 +105,7 @@ export default function CancelBooking(props: Props) {
|
||||
}
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("cancel_event")}
|
||||
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,7 @@ const BookingPage = ({
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { id, paymentUid } = responseData;
|
||||
const { uid, paymentUid } = responseData;
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -138,17 +138,10 @@ const BookingPage = ({
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
username: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: bookingForm.getValues("name"),
|
||||
email: bookingForm.getValues("email"),
|
||||
location: responseData.location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
uid,
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -156,31 +149,15 @@ const BookingPage = ({
|
||||
|
||||
const recurringMutation = useMutation(createRecurringBooking, {
|
||||
onSuccess: async (responseData = []) => {
|
||||
const { attendees = [], id, recurringEventId } = responseData[0] || {};
|
||||
const location = (function humanReadableLocation(location) {
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (location.includes("integration")) {
|
||||
return t("web_conferencing_details_to_follow");
|
||||
}
|
||||
return location;
|
||||
})(responseData[0].location);
|
||||
const { uid } = responseData[0] || {};
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
recur: recurringEventId,
|
||||
username: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
uid,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -515,7 +492,7 @@ const BookingPage = ({
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className="dark:text-darkgray-600 text-sm">
|
||||
{t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
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 { useState } from "react";
|
||||
import { useFormContext, Controller, useWatch } from "react-hook-form";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import convertToNewDurationType, { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PeriodType } from "@calcom/prisma/client";
|
||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { Label, Input, MinutesField } from "@calcom/ui/components/form";
|
||||
import { Input, InputField, Label } from "@calcom/ui/components/form";
|
||||
import { Select, SettingsToggle } from "@calcom/ui/v2";
|
||||
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
|
||||
|
||||
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType } = props;
|
||||
const minimumBookingNoticeType = useRef<DurationType>(findDurationType(eventType.minimumBookingNotice));
|
||||
const prevBookingNoticeType = useRef<DurationType>(minimumBookingNoticeType.current);
|
||||
|
||||
const minimumBookingNoticeInDurationTypeFormValue = formMethods.watch("minimumBookingNoticeInDurationType");
|
||||
|
||||
const PERIOD_TYPES = [
|
||||
{
|
||||
type: "ROLLING" as const,
|
||||
@@ -47,6 +53,57 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
defaultValue: periodType?.type,
|
||||
});
|
||||
|
||||
const onMinimumNoticeDurationTypeChange = useMemo(
|
||||
() => (durationType?: DurationType) => {
|
||||
if (typeof durationType === "undefined") return;
|
||||
|
||||
// Store current selected type in ref to use in previous run for comparrison.
|
||||
minimumBookingNoticeType.current = durationType;
|
||||
|
||||
// Uses the previous selected type and converts the value to the new type.
|
||||
// Eg day was selected before, user selects hours now, so we multiple * 24 and round up.
|
||||
const minimumBookingNoticeInDurationType = convertToNewDurationType(
|
||||
prevBookingNoticeType.current,
|
||||
durationType,
|
||||
Number(minimumBookingNoticeInDurationTypeFormValue)
|
||||
);
|
||||
|
||||
// Uses the round up value in the new type to calculate the value in minutes.
|
||||
// We should NOT use minimumBookingNoticeInDurationTypeFormValue here, since that
|
||||
// would break in the case that the user enters 90 minutes, changes the type to
|
||||
// hours (that will show 2 hours), but the value in minutes would then remain 90,
|
||||
// which is something else than the user things they save.
|
||||
const minimumBookingNoticeInMinutes = convertToNewDurationType(
|
||||
durationType,
|
||||
"minutes",
|
||||
Number(minimumBookingNoticeInDurationType)
|
||||
);
|
||||
|
||||
// Updates both form values as well as stores the current type as previous type in ref.
|
||||
formMethods.setValue("minimumBookingNotice", minimumBookingNoticeInMinutes);
|
||||
formMethods.setValue("minimumBookingNoticeInDurationType", minimumBookingNoticeInDurationType);
|
||||
prevBookingNoticeType.current = durationType;
|
||||
},
|
||||
[formMethods, minimumBookingNoticeInDurationTypeFormValue]
|
||||
);
|
||||
|
||||
/**
|
||||
* When the user inputs a new value for minimumBookingNoticeInDurationType,
|
||||
* we calculate the value in minutes, and update this hidden field as well.
|
||||
*/
|
||||
const onMinimumNoticeChange = useMemo(
|
||||
() => (duration: string) => {
|
||||
const minimumBookingNoticeInMinutes = convertToNewDurationType(
|
||||
minimumBookingNoticeType.current,
|
||||
"minutes",
|
||||
Number(duration)
|
||||
);
|
||||
|
||||
formMethods.setValue("minimumBookingNotice", minimumBookingNoticeInMinutes);
|
||||
},
|
||||
[formMethods]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
@@ -116,13 +173,54 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 pt-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<MinutesField
|
||||
required
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="120"
|
||||
{...formMethods.register("minimumBookingNotice", { valueAsNumber: true })}
|
||||
<div className="flex w-full items-end">
|
||||
<Controller
|
||||
name="minimumBookingNotice"
|
||||
control={formMethods.control}
|
||||
render={() => {
|
||||
const durationTypeOptions = [
|
||||
{
|
||||
label: t("minutes"),
|
||||
value: "minutes",
|
||||
},
|
||||
{
|
||||
label: t("hours"),
|
||||
value: "hours",
|
||||
},
|
||||
{
|
||||
label: t("days"),
|
||||
value: "days",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-1/2 md:w-3/4">
|
||||
<InputField
|
||||
required
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="120"
|
||||
className="mr-2 rounded-[4px]"
|
||||
{...formMethods.register("minimumBookingNoticeInDurationType", {
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onMinimumNoticeChange(event.target.value),
|
||||
})}
|
||||
/>
|
||||
<input type="hidden" {...formMethods.register("minimumBookingNotice")} />
|
||||
</div>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
className="mb-2 ml-2 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
defaultValue={durationTypeOptions.find(
|
||||
(option) => option.value === minimumBookingNoticeType.current
|
||||
)}
|
||||
onChange={(input) => onMinimumNoticeDurationTypeChange(input?.value as DurationType)}
|
||||
options={durationTypeOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -37,7 +37,7 @@ function SkeletonItem() {
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,11 @@ import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { List } from "@calcom/ui/List";
|
||||
import { ShellSubHeading } from "@calcom/ui/Shell";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
@@ -161,7 +161,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
id={item.credentialId}
|
||||
externalId={item.primary?.externalId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||
<Button {...btnProps} color="secondary" data-testid="integration-connection-button">
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -194,7 +194,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||
<Button {...btnProps} color="secondary" data-testid="integration-connection-button">
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
import { ButtonBaseProps } from "@calcom/ui/components/button";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
export default function DisconnectIntegration(props: {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
|
||||
|
||||
export default function DisconnectStripeIntegration(props: {
|
||||
/** Integration credential id */
|
||||
id: number;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async (action: string) => {
|
||||
const res = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.id, action }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
onSuccess(data) {
|
||||
showToast(data.message, "success");
|
||||
setModalOpen(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DeleteStripeDialogContent
|
||||
variety="warning"
|
||||
title="Disconnect Stripe Integration"
|
||||
cancelAllBookingsBtnText="Cancel all bookings"
|
||||
removeBtnText="Remove payment"
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate("cancel");
|
||||
}}
|
||||
onRemove={() => {
|
||||
mutation.mutate("remove");
|
||||
}}>
|
||||
If you have unpaid and unconfirmed bookings, you must choose to cancel them or remove the required
|
||||
payment field.
|
||||
</DeleteStripeDialogContent>
|
||||
</Dialog>
|
||||
{props.render({
|
||||
onClick() {
|
||||
setModalOpen(true);
|
||||
},
|
||||
disabled: modalOpen,
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,9 +17,9 @@ const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
|
||||
let appsStatus: AppsStatus[] | undefined = undefined;
|
||||
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
|
||||
// emailed booking taking into account accumulated results to send app status accurately
|
||||
for (let key = 0; key < data.length; key++) {
|
||||
for (let key = data.length - 1; key >= 0; key--) {
|
||||
const booking = data[key];
|
||||
if (key === data.length - 1) {
|
||||
if (key === 0) {
|
||||
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||
.reduce((prev, curr) => {
|
||||
@@ -39,6 +39,7 @@ const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
|
||||
appsStatus,
|
||||
allRecurringDates,
|
||||
currentRecurringIndex: key,
|
||||
noEmail: key !== 0,
|
||||
});
|
||||
createdBookings.push(response);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.x <17",
|
||||
"node": ">=16.x",
|
||||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Before adding new endpoints here
|
||||
|
||||
We're deprecating adding new endpoints in favor of creating tRPC procedures.
|
||||
|
||||
You can learn about [tRPC procedures in the docs](https://trpc.io/docs/v10/procedures).
|
||||
|
||||
You can see our current tRPC procedures in this file `packages/trpc/server/routers/_app.ts`
|
||||
@@ -1,85 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type CalendlyEventType = {
|
||||
name: string;
|
||||
slug: string;
|
||||
duration: number;
|
||||
description_plain: string;
|
||||
secret: boolean;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
const authenticatedUser = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: session?.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (req.method == "POST") {
|
||||
const userResult = await fetch("https://api.calendly.com/users/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + req.body.token,
|
||||
},
|
||||
});
|
||||
|
||||
if (userResult.status == 200) {
|
||||
const userData = await userResult.json();
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: authenticatedUser.id,
|
||||
},
|
||||
data: {
|
||||
name: userData.resource.name,
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypesResult = await fetch(
|
||||
"https://api.calendly.com/event_types?user=" + userData.resource.uri,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + req.body.token,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const eventTypesData = await eventTypesResult.json();
|
||||
|
||||
eventTypesData.collection.forEach(async (eventType: CalendlyEventType) => {
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
title: eventType.name,
|
||||
slug: eventType.slug,
|
||||
length: eventType.duration,
|
||||
description: eventType.description_plain,
|
||||
hidden: eventType.secret,
|
||||
users: {
|
||||
connect: {
|
||||
id: authenticatedUser.id,
|
||||
},
|
||||
},
|
||||
userId: authenticatedUser.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
res.status(201).end();
|
||||
} else {
|
||||
res.status(500).end();
|
||||
}
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type SavvyCalEventType = {
|
||||
name: string;
|
||||
slug: string;
|
||||
durations: [number];
|
||||
description: string;
|
||||
state: "active";
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
const authenticatedUser = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: session?.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (req.method === "POST") {
|
||||
const userResult = await fetch("https://api.savvycal.com/v1/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + req.body.token,
|
||||
},
|
||||
});
|
||||
|
||||
if (userResult.status === 200) {
|
||||
const userData = await userResult.json();
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: authenticatedUser.id,
|
||||
},
|
||||
data: {
|
||||
name: userData.display_name,
|
||||
timeZone: userData.time_zone,
|
||||
weekStart: userData.first_day_of_week === 0 ? "Sunday" : "Monday",
|
||||
avatar: userData.avatar_url,
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypesResult = await fetch("https://api.savvycal.com/v1/links?limit=100", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + req.body.token,
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypesData = await eventTypesResult.json();
|
||||
|
||||
eventTypesData.entries.forEach(async (eventType: SavvyCalEventType) => {
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
title: eventType.name,
|
||||
slug: eventType.slug,
|
||||
length: eventType.durations[0],
|
||||
description: eventType.description.replace(/<[^>]*>?/gm, ""),
|
||||
hidden: eventType.state === "active",
|
||||
users: {
|
||||
connect: {
|
||||
id: authenticatedUser.id,
|
||||
},
|
||||
},
|
||||
userId: authenticatedUser.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
res.status(201).end();
|
||||
} else {
|
||||
res.status(500).end();
|
||||
}
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { BookingStatus, Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!["GET", "DELETE"].includes(req.method || "")) {
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
// Check that user is authenticated
|
||||
const session = await getSession({ req });
|
||||
const userId = session?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json(credentials);
|
||||
}
|
||||
|
||||
if (req.method == "DELETE") {
|
||||
const id = req.body.id;
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
credentials: {
|
||||
delete: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
};
|
||||
const integration = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
/* If the user deletes a zapier integration, we delete all his api keys as well. */
|
||||
if (integration?.appId === "zapier") {
|
||||
data.apiKeys = {
|
||||
deleteMany: {
|
||||
userId,
|
||||
appId: "zapier",
|
||||
},
|
||||
};
|
||||
/* We also delete all user's zapier wehbooks */
|
||||
data.webhooks = {
|
||||
deleteMany: {
|
||||
userId,
|
||||
appId: "zapier",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
if (req.body?.action === "cancel" || req.body?.action === "remove") {
|
||||
try {
|
||||
const bookingIdsWithPayments = await prisma.booking
|
||||
.findMany({
|
||||
where: {
|
||||
userId: session?.user?.id,
|
||||
paid: false,
|
||||
NOT: {
|
||||
payment: {
|
||||
every: {
|
||||
booking: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((bookings) => bookings.map((booking) => booking.id));
|
||||
const deletePayments = prisma.payment.deleteMany({
|
||||
where: {
|
||||
bookingId: {
|
||||
in: bookingIdsWithPayments,
|
||||
},
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
|
||||
const updateBookings = prisma.booking.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: bookingIdsWithPayments,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
rejectionReason: "Payment provider got removed",
|
||||
},
|
||||
});
|
||||
|
||||
const bookingReferences = await prisma.booking
|
||||
.findMany({
|
||||
where: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((bookings) => bookings.map((booking) => booking.id));
|
||||
|
||||
const deleteBookingReferences = prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
bookingId: {
|
||||
in: bookingReferences,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (req.body?.action === "cancel") {
|
||||
await prisma.$transaction([deletePayments, updateBookings, deleteBookingReferences]);
|
||||
} else {
|
||||
const updateBookings = prisma.booking.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: bookingIdsWithPayments,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
paid: true,
|
||||
},
|
||||
});
|
||||
await prisma.$transaction([deletePayments, updateBookings]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ message: "Integration could not be deleted" });
|
||||
}
|
||||
}
|
||||
res.status(200).json({ message: "Integration deleted successfully" });
|
||||
}
|
||||
}
|
||||
@@ -65,8 +65,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await handler(req, res);
|
||||
} else {
|
||||
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
|
||||
redirectUrl = handler.redirectUrl || getInstalledAppPath(handler);
|
||||
res.json({ url: redirectUrl });
|
||||
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
||||
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
||||
}
|
||||
return res.status(200);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { userMetadata as zodUserMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { intentUsername } = req.body;
|
||||
// Check that user is authenticated
|
||||
try {
|
||||
const session = await getSession({ req });
|
||||
const userId = session?.user?.id;
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
metadata: true,
|
||||
},
|
||||
where: { id: userId },
|
||||
});
|
||||
const checkPremiumUsernameResult = await checkUsername(intentUsername);
|
||||
|
||||
if (userId && user) {
|
||||
const userMetadata = zodUserMetadata.parse(user.metadata);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
metadata: {
|
||||
...userMetadata,
|
||||
intentUsername,
|
||||
isIntentPremium: checkPremiumUsernameResult.premium,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(501).send({ message: "intent-username.save.error" });
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: getHandler }),
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
/**
|
||||
* @deprecated Use TRCP's viewer.me query
|
||||
*/
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
theme: true,
|
||||
createdDate: true,
|
||||
hideBranding: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
user.avatar = user.avatar || defaultAvatarSrc({ email: user.email });
|
||||
|
||||
res.status(200).json({
|
||||
user,
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { getAvailabilityFromSchedule } from "@lib/availability";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.body.schedule || req.body.schedule.length !== 7) {
|
||||
return res.status(400).json({ message: "Bad Request." });
|
||||
}
|
||||
|
||||
const availability = getAvailabilityFromSchedule(req.body.schedule);
|
||||
|
||||
if (req.method === "POST") {
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
availability: {
|
||||
/* We delete user availabilty */
|
||||
deleteMany: {
|
||||
userId: {
|
||||
equals: userId,
|
||||
},
|
||||
},
|
||||
/* So we can replace it */
|
||||
createMany: {
|
||||
data: availability.map((schedule) => ({
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return res.status(200).json({
|
||||
message: "created",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ message: "Unable to create schedule." });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
if (!session.user?.id) {
|
||||
console.log("Received session token without a user id.");
|
||||
return res.status(500).json({ message: "Something went wrong." });
|
||||
}
|
||||
if (!req.query.team) {
|
||||
console.log("Missing team query param.");
|
||||
return res.status(500).json({ message: "Something went wrong." });
|
||||
}
|
||||
|
||||
const teamId = parseInt(req.query.team as string);
|
||||
|
||||
// GET /api/teams/{team}
|
||||
if (req.method === "GET") {
|
||||
const team = await getTeamWithMembers(teamId);
|
||||
return res.status(200).json({ team });
|
||||
}
|
||||
// DELETE /api/teams/{team}
|
||||
if (req.method === "DELETE") {
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership || membership.role !== "OWNER") {
|
||||
console.log(`User ${session.user.id} tried deleting an organization they don't own.`);
|
||||
return res.status(403).json({ message: "Forbidden." });
|
||||
}
|
||||
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: session.user.id, teamId },
|
||||
},
|
||||
});
|
||||
const deletedTeam = await prisma.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
closeComDeleteTeam(deletedTeam);
|
||||
|
||||
return res.status(204).send(null);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { sendTeamInviteEmail } from "@calcom/emails";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(400).json({ message: "Bad request" });
|
||||
}
|
||||
|
||||
const session = await getSession({ req });
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: parseInt(req.query.team as string),
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ message: "Invalid team" });
|
||||
}
|
||||
|
||||
const reqBody = req.body as {
|
||||
usernameOrEmail: string;
|
||||
role: MembershipRole;
|
||||
sendEmailInvitation: boolean;
|
||||
};
|
||||
const { role, sendEmailInvitation } = reqBody;
|
||||
// liberal email match
|
||||
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
||||
const usernameOrEmail = isEmail(reqBody.usernameOrEmail)
|
||||
? reqBody.usernameOrEmail.toLowerCase()
|
||||
: slugify(reqBody.usernameOrEmail);
|
||||
|
||||
const invitee = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitee) {
|
||||
const email = isEmail(usernameOrEmail) ? usernameOrEmail : undefined;
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
|
||||
});
|
||||
}
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
teams: {
|
||||
create: {
|
||||
team: {
|
||||
connect: {
|
||||
id: parseInt(req.query.team as string),
|
||||
},
|
||||
},
|
||||
role,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token: string = randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: usernameOrEmail,
|
||||
token,
|
||||
expires: new Date(new Date().setHours(168)), // +1 week
|
||||
},
|
||||
});
|
||||
|
||||
if (session?.user?.name && team?.name) {
|
||||
await sendTeamInviteEmail({
|
||||
language: t,
|
||||
from: session.user.name,
|
||||
to: usernameOrEmail,
|
||||
teamName: team.name,
|
||||
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=/settings/teams}`,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({});
|
||||
}
|
||||
|
||||
// create provisional membership
|
||||
try {
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
teamId: parseInt(req.query.team as string),
|
||||
userId: invitee.id,
|
||||
role,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
// unique constraint violation
|
||||
return res.status(409).json({
|
||||
message: "This user is a member of this team / has a pending invitation.",
|
||||
});
|
||||
} else {
|
||||
throw err; // rethrow
|
||||
}
|
||||
}
|
||||
|
||||
// inform user of membership by email
|
||||
if (sendEmailInvitation && session?.user?.name && team?.name) {
|
||||
await sendTeamInviteEmail({
|
||||
language: t,
|
||||
from: session.user.name,
|
||||
to: usernameOrEmail,
|
||||
teamName: team.name,
|
||||
joinLink: BASE_URL + "/settings/teams",
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isTeamOwner = !!(await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: session.user?.id,
|
||||
teamId: parseInt(req.query.team as string),
|
||||
role: "OWNER",
|
||||
},
|
||||
}));
|
||||
|
||||
if (!isTeamOwner) {
|
||||
res.status(403).json({ message: "You are not authorized to manage this team" });
|
||||
return;
|
||||
}
|
||||
|
||||
// List members
|
||||
if (req.method === "GET") {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: parseInt(req.query.team as string),
|
||||
},
|
||||
});
|
||||
|
||||
let members = await prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: memberships.map((membership) => membership.userId),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
members = members.map((member) => {
|
||||
const membership = memberships.find((membership) => member.id === membership.userId);
|
||||
return {
|
||||
...member,
|
||||
role: membership?.accepted ? membership?.role : "INVITEE",
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).json({ members: members });
|
||||
}
|
||||
|
||||
// Cancel a membership (invite)
|
||||
if (req.method === "DELETE") {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: req.body.userId,
|
||||
},
|
||||
});
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team as string) },
|
||||
},
|
||||
});
|
||||
// Sync Services: Close.com
|
||||
closeComDeleteTeamMembership(user);
|
||||
|
||||
return res.status(204).send(null);
|
||||
}
|
||||
|
||||
// Promote or demote a member of the team
|
||||
|
||||
res.status(200).json({});
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
// @deprecated - USE TRPC
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
if (!session?.user?.id) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const isTeamOwner = !!(await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
teamId: parseInt(req.query.team as string),
|
||||
role: "OWNER",
|
||||
},
|
||||
}));
|
||||
|
||||
if (!isTeamOwner) {
|
||||
res.status(403).json({ message: "You are not authorized to manage this team" });
|
||||
return;
|
||||
}
|
||||
|
||||
// PATCH /api/teams/profile/{team}
|
||||
if (req.method === "PATCH") {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: parseInt(req.query.team as string),
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ message: "Invalid team" });
|
||||
}
|
||||
|
||||
const username = req.body.username;
|
||||
const userConflict = await prisma.team.findMany({
|
||||
where: {
|
||||
slug: username,
|
||||
},
|
||||
});
|
||||
const teamId = Number(req.query.team);
|
||||
if (userConflict.some((team) => team.id !== teamId)) {
|
||||
return res.status(409).json({ message: "Team username already taken" });
|
||||
}
|
||||
|
||||
const name = req.body.name;
|
||||
const slug = req.body.username;
|
||||
const bio = req.body.description;
|
||||
const logo = req.body.logo;
|
||||
const hideBranding = req.body.hideBranding;
|
||||
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
logo,
|
||||
bio,
|
||||
hideBranding,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Team updated successfully" });
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getStripeCustomerId } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { WEBSITE_URL } from "@lib/config/constants";
|
||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
if (!session?.user?.id) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
if (!["GET", "POST"].includes(req.method || "")) {
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stripeCustomerId = await getStripeCustomerId(user);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WEBSITE_URL}/api/upgrade`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stripeCustomerId,
|
||||
email: user.email,
|
||||
fromApp: true,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.url) throw new HttpCode({ statusCode: 401, message: data.message });
|
||||
|
||||
res.redirect(303, data.url);
|
||||
} catch (error) {
|
||||
console.error(`error`, error);
|
||||
res.redirect(303, req.headers.origin || "/");
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { pick } from "lodash";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
|
||||
if (!session?.user.id) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.string().transform((val) => parseInt(val)),
|
||||
});
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
const userId = parsedQuery.success ? parsedQuery.data.id : null;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ message: "No user id provided" });
|
||||
}
|
||||
|
||||
const authenticatedUser = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (userId !== authenticatedUser.id) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
return res.status(405).json({ message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
if (req.method === "PATCH") {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: authenticatedUser.id,
|
||||
},
|
||||
data: {
|
||||
...pick(req.body.data, [
|
||||
"username",
|
||||
"name",
|
||||
"avatar",
|
||||
"timeZone",
|
||||
"timeFormat",
|
||||
"weekStart",
|
||||
"hideBranding",
|
||||
"theme",
|
||||
"completedOnboarding",
|
||||
]),
|
||||
bio: req.body.description ?? req.body.data?.bio,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
timeZone: true,
|
||||
timeFormat: true,
|
||||
weekStart: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
hideBranding: true,
|
||||
theme: true,
|
||||
createdDate: true,
|
||||
plan: true,
|
||||
completedOnboarding: true,
|
||||
},
|
||||
});
|
||||
return res.status(200).json({ message: "User Updated", data: updatedUser });
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
|
||||
if (!session?.user.id) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
if (req.method !== "DELETE") {
|
||||
return res.status(405).json({ message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
// Get user
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: session.user?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
metadata: true,
|
||||
username: true,
|
||||
createdDate: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
// Delete from stripe
|
||||
await deleteStripeCustomer(user).catch(console.warn);
|
||||
// Delete from Cal
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: session?.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services
|
||||
syncServicesDeleteWebUser(user);
|
||||
|
||||
return res.status(204).end();
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
if (!session || !session.user?.id) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: memberships.map((membership) => membership.teamId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
membership: memberships.map((membership) => ({
|
||||
role: membership.accepted ? membership.role : "INVITEE",
|
||||
...teams.find((team) => team.id === membership.teamId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.body.teamId) {
|
||||
return res.status(400).json({ message: "Bad request" });
|
||||
}
|
||||
|
||||
// Leave team or decline membership invite of current user
|
||||
if (req.method === "DELETE") {
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: session.user.id, teamId: req.body.teamId },
|
||||
},
|
||||
});
|
||||
return res.status(204).send(null);
|
||||
}
|
||||
|
||||
// Accept team invitation
|
||||
if (req.method === "PATCH") {
|
||||
await prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: { userId: session.user.id, teamId: req.body.teamId },
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(204).send(null);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { pick } from "lodash";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import { resizeBase64Image } from "@server/lib/resizeBase64Image";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const avatar = req.body.avatar ? await resizeBase64Image(req.body.avatar) : undefined;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
...pick(req.body, [
|
||||
"username",
|
||||
"name",
|
||||
"timeZone",
|
||||
"weekStart",
|
||||
"hideBranding",
|
||||
"theme",
|
||||
"completedOnboarding",
|
||||
"locale",
|
||||
]),
|
||||
avatar,
|
||||
bio: req.body.description,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
return res.status(409).json({ message: "Username already taken" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: "Profile updated successfully" });
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { ensureSession } from "@calcom/lib/auth";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||
// Only logged in users can opt-in/out
|
||||
await ensureSession({ req });
|
||||
|
||||
// If has the cookie, Opt-out of V2
|
||||
if ("calcom-v2-early-access" in req.cookies && req.cookies["calcom-v2-early-access"] === "1") {
|
||||
res.setHeader("Set-Cookie", `calcom-v2-early-access=0; Max-Age=0; Path=/`);
|
||||
} else {
|
||||
/* Opt-int to V2 */
|
||||
res.setHeader("Set-Cookie", "calcom-v2-early-access=1; Path=/");
|
||||
}
|
||||
|
||||
let redirectUrl = "/";
|
||||
|
||||
// We take you back where you came from if possible
|
||||
if (typeof req.headers["referer"] === "string") redirectUrl = req.headers["referer"];
|
||||
|
||||
res.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -90,7 +90,7 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<form id="setup-step-1" name="setup-step-1" className="space-y-4" onSubmit={onSubmit}>
|
||||
<form id="wizard-step-1" name="wizard-step-1" className="space-y-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<Controller
|
||||
name="username"
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
const querySchema = z.object({
|
||||
uid: z.string(),
|
||||
allRemainingBookings: z
|
||||
@@ -32,288 +9,16 @@ const querySchema = z.object({
|
||||
.transform((val) => (val ? JSON.parse(val) : false)),
|
||||
});
|
||||
|
||||
export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { uid, allRemainingBookings } = querySchema.parse(router.query);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(props.booking ? null : t("booking_already_cancelled"));
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
return (
|
||||
<div className="h-screen bg-neutral-100 dark:bg-neutral-900">
|
||||
<HeadSeo
|
||||
title={`${t("cancel")} ${props.booking && props.booking.title} | ${props.profile?.name}`}
|
||||
description={`${t("cancel")} ${props.booking && props.booking.title} | ${props.profile?.name}`}
|
||||
/>
|
||||
<CustomBranding lightVal={props.profile?.brandColor} darkVal={props.profile?.darkBrandColor} />
|
||||
<main className="h-full sm:flex sm:items-center">
|
||||
<div className="mx-auto flex justify-center px-4 pt-4 pb-20 sm:block sm:p-0">
|
||||
<div className="inline-block transform overflow-hidden rounded-md border bg-white px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 dark:bg-gray-800 sm:my-8 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle">
|
||||
<div>
|
||||
<div>
|
||||
{error && (
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<Icon.FiX className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<>
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<Icon.FiX className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white">
|
||||
{props.cancellationAllowed
|
||||
? t("really_cancel_booking")
|
||||
: t("cannot_cancel_booking")}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{!props.booking?.eventType.recurringEvent
|
||||
? props.cancellationAllowed
|
||||
? t("reschedule_instead")
|
||||
: t("event_is_in_the_past")
|
||||
: allRemainingBookings
|
||||
? t("cancelling_all_recurring")
|
||||
: t("cancelling_event_recurring")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-bookinglightest text-bookingdark mt-4 grid grid-cols-3 border-t py-4 text-left dark:border-gray-900 dark:text-gray-300">
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6">{props.booking?.title}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
{props.booking?.eventType.recurringEvent && props.recurringInstances ? (
|
||||
<>
|
||||
<div className="mb-1 inline py-1 text-left">
|
||||
<div>
|
||||
{dayjs(props.recurringInstances[0].startTime).format(
|
||||
detectBrowserTimeFormat + ", dddd DD MMMM YYYY"
|
||||
)}
|
||||
<Collapsible
|
||||
open={moreEventsVisible}
|
||||
onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className={classNames(
|
||||
"-ml-4 block w-full text-center",
|
||||
moreEventsVisible ? "hidden" : ""
|
||||
)}>
|
||||
{t("plus_more", { count: props.recurringInstances.length - 1 })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{props.booking?.eventType.recurringEvent?.count &&
|
||||
props.recurringInstances.slice(1).map((dateObj, idx) => (
|
||||
<div key={idx} className="">
|
||||
{dayjs(dateObj.startTime).format(
|
||||
detectBrowserTimeFormat + ", dddd DD MMMM YYYY"
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{dayjs(props.booking?.startTime).format(
|
||||
detectBrowserTimeFormat + ", dddd DD MMMM YYYY"
|
||||
)}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.booking?.eventType.recurringEvent &&
|
||||
props.booking?.eventType.recurringEvent.freq &&
|
||||
props.recurringInstances && (
|
||||
<div className="border-b text-center text-gray-500">
|
||||
<Icon.FiRefreshCcw className="mr-3 -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: props.booking.eventType.recurringEvent,
|
||||
recurringCount: props.recurringInstances.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.cancellationAllowed && (
|
||||
<div>
|
||||
<textarea
|
||||
autoFocus={true}
|
||||
name={t("cancellation_reason")}
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mt-2 mb-3 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:mb-3 "
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-between space-x-2 text-center rtl:space-x-reverse">
|
||||
{!props.booking.eventType?.recurringEvent && (
|
||||
<Button color="secondary" onClick={() => router.push("/reschedule/" + uid)}>
|
||||
{t("reschedule_this")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-testid="cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const payload = {
|
||||
uid: uid,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings: !!props.recurringInstances,
|
||||
};
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
await router.push(
|
||||
`/cancel/success?name=${props.profile.name}&title=${
|
||||
props.booking.title
|
||||
}&eventPage=${props.profile.slug}&team=${
|
||||
props.booking.eventType?.team ? 1 : 0
|
||||
}&recurring=${!!props.recurringInstances}`
|
||||
);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("cancel_event")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default function Type() {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const session = await getSession(context);
|
||||
const { allRemainingBookings, uid } = querySchema.parse(context.query);
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
recurringEventId: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
select: {
|
||||
length: true,
|
||||
recurringEvent: true,
|
||||
team: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
// TODO: Booking is already cancelled
|
||||
return {
|
||||
props: { booking: null },
|
||||
};
|
||||
}
|
||||
|
||||
const bookingObj = Object.assign({}, booking, {
|
||||
startTime: booking.startTime.toString(),
|
||||
endTime: booking.endTime.toString(),
|
||||
eventType: {
|
||||
...booking.eventType,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||
},
|
||||
});
|
||||
|
||||
let recurringInstances = null;
|
||||
if (booking.eventType?.recurringEvent && allRemainingBookings) {
|
||||
recurringInstances = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: booking.recurringEventId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
NOT: [{ status: "CANCELLED" }, { status: "REJECTED" }],
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
},
|
||||
});
|
||||
recurringInstances = recurringInstances.map((recurr) => ({
|
||||
...recurr,
|
||||
startTime: recurr.startTime.toString(),
|
||||
endTime: recurr.endTime.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: booking.eventType?.team?.name || booking.user?.name || null,
|
||||
slug: booking.eventType?.team?.slug || booking.user?.username || null,
|
||||
brandColor: booking.user?.brandColor || null,
|
||||
darkBrandColor: booking.user?.darkBrandColor || null,
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
profile,
|
||||
booking: bookingObj,
|
||||
recurringInstances,
|
||||
cancellationAllowed:
|
||||
(!!session?.user && session.user?.id === booking.user?.id) || booking.startTime >= new Date(),
|
||||
trpcState: ssr.dehydrate(),
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/success?uid=${uid}&allRemainingBookings=${allRemainingBookings}&cancel=true`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
const querySchema = z.object({
|
||||
title: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
eventPage: z.string().optional(),
|
||||
allRemainingBookings: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? JSON.parse(val) : false)),
|
||||
});
|
||||
|
||||
export default function CancelSuccess() {
|
||||
const { t } = useLocale();
|
||||
// Get router variables
|
||||
const router = useRouter();
|
||||
const { title, name, eventPage, recurring } = router.query;
|
||||
const { title, name, eventPage, allRemainingBookings } = querySchema.parse(router.query);
|
||||
let team: string | string[] | number | undefined = router.query.team;
|
||||
const { data: session, status } = useSession();
|
||||
const isRecurringEvent = recurring === "true" ? true : false;
|
||||
const loading = status === "loading";
|
||||
// If team param passed wrongly just assume it be a non team case.
|
||||
if (team instanceof Array || typeof team === "undefined") {
|
||||
@@ -63,7 +73,7 @@ export default function CancelSuccess() {
|
||||
{!loading && session?.user && (
|
||||
<Button
|
||||
data-testid="back-to-bookings"
|
||||
href={isRecurringEvent ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
href={!!allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
StartIcon={Icon.FiArrowLeft}>
|
||||
{t("back_to_bookings")}
|
||||
</Button>
|
||||
|
||||
@@ -12,6 +12,8 @@ import getApps, { getEventTypeAppData, getLocationOptions } from "@calcom/app-st
|
||||
import { LocationObject, EventLocationType } from "@calcom/core/location";
|
||||
import { parseRecurringEvent, parseBookingLimit, 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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -73,6 +75,7 @@ export type FormValues = {
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
minimumBookingNotice: number;
|
||||
minimumBookingNoticeInDurationType: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
slotInterval: number | null;
|
||||
@@ -163,6 +166,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
},
|
||||
schedulingType: eventType.schedulingType,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
minimumBookingNoticeInDurationType: convertToNewDurationType(
|
||||
"minutes",
|
||||
findDurationType(eventType.minimumBookingNotice),
|
||||
eventType.minimumBookingNotice
|
||||
),
|
||||
metadata: eventType.metadata,
|
||||
},
|
||||
});
|
||||
@@ -237,9 +245,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
recurringEvent,
|
||||
locations,
|
||||
metadata,
|
||||
// We don't need to send it to the backend
|
||||
// We don't need to send send these values to the backend
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
seatsPerTimeSlotEnabled,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
minimumBookingNoticeInDurationType,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
|
||||
@@ -56,11 +56,11 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
return (
|
||||
<Link href={`/event-types/${type.id}?tabName=setup`}>
|
||||
<a
|
||||
className="flex-grow truncate text-sm"
|
||||
className="w-full overflow-hidden pr-4 text-sm"
|
||||
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
|
||||
<div>
|
||||
<span
|
||||
className="truncate font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"
|
||||
className="font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"
|
||||
data-testid={"event-type-title-" + type.id}>
|
||||
{type.title}
|
||||
</span>
|
||||
@@ -252,8 +252,8 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
const calLink = `${CAL_URL}/${embedLink}`;
|
||||
return (
|
||||
<li key={type.id}>
|
||||
<div className="flex items-center justify-between hover:bg-neutral-50">
|
||||
<div className="group flex w-full items-center justify-between px-4 py-4 pr-0 sm:px-6">
|
||||
<div className="flex w-full items-center justify-between hover:bg-neutral-50">
|
||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
||||
{!(firstItem && firstItem.id === type.id) && (
|
||||
<button
|
||||
className="invisible absolute left-[5px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
@@ -270,7 +270,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</button>
|
||||
)}
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.users?.length > 1 && (
|
||||
<AvatarGroup
|
||||
@@ -391,7 +391,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-5 flex flex-shrink-0 sm:hidden">
|
||||
<div className="min-w-9 mr-5 flex sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button type="button" size="icon" color="secondary" StartIcon={Icon.FiMoreHorizontal} />
|
||||
|
||||
@@ -52,16 +52,6 @@ const BillingView = () => {
|
||||
<>
|
||||
<Meta title={t("billing")} description={t("manage_billing_description")} />
|
||||
<div className="space-y-6 text-sm sm:space-y-8">
|
||||
{!isPro && (
|
||||
<CtaRow title={t("billing_freeplan_title")} description={t("billing_freeplan_description")}>
|
||||
<form target="_blank" method="POST" action="/api/upgrade">
|
||||
<Button type="submit" EndIcon={Icon.FiExternalLink}>
|
||||
{t("billing_freeplan_cta")}
|
||||
</Button>
|
||||
</form>
|
||||
</CtaRow>
|
||||
)}
|
||||
|
||||
<CtaRow
|
||||
className={classNames(!isPro && "pointer-events-none opacity-30")}
|
||||
title={t("billing_manage_details_title")}
|
||||
|
||||
@@ -155,8 +155,7 @@ const ProfileView = () => {
|
||||
|
||||
const {
|
||||
reset,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
formState: { dirtyFields },
|
||||
formState: { isSubmitting, isDirty },
|
||||
} = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -197,6 +196,7 @@ const ProfileView = () => {
|
||||
};
|
||||
|
||||
if (isLoading || !user) return <SkeletonLoader />;
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -261,7 +261,12 @@ const ProfileView = () => {
|
||||
<TextField label={t("about")} hint={t("bio_hint")} {...formMethods.register("bio")} />
|
||||
</div>
|
||||
|
||||
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
className="mt-8"
|
||||
type="submit"
|
||||
loading={mutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
|
||||
@@ -297,7 +302,6 @@ const ProfileView = () => {
|
||||
data-testid="password"
|
||||
name="password"
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
label="Password"
|
||||
@@ -331,7 +335,6 @@ const ProfileView = () => {
|
||||
data-testid="password"
|
||||
name="password"
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
label="Password"
|
||||
|
||||
+152
-117
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { createEvent } from "ics";
|
||||
@@ -14,8 +14,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ConfigType } from "@calcom/dayjs";
|
||||
import dayjs, { ConfigType } from "@calcom/dayjs";
|
||||
import {
|
||||
sdkActionManager,
|
||||
useEmbedNonStylesConfig,
|
||||
@@ -33,12 +32,11 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma, { baseUserSelect } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { EmailInput } from "@calcom/ui/form/fields";
|
||||
import { Button, EmailInput } from "@calcom/ui/components";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -141,38 +139,66 @@ function RedirectionToast({ url }: { url: string }) {
|
||||
|
||||
type SuccessProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
const stringToBoolean = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "true");
|
||||
|
||||
const querySchema = z.object({
|
||||
uid: z.string(),
|
||||
allRemainingBookings: stringToBoolean,
|
||||
cancel: stringToBoolean,
|
||||
reschedule: stringToBoolean,
|
||||
isSuccessBookingPage: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
location: _location,
|
||||
name,
|
||||
email,
|
||||
reschedule,
|
||||
listingStatus,
|
||||
status,
|
||||
allRemainingBookings,
|
||||
isSuccessBookingPage,
|
||||
} = router.query;
|
||||
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(_location)
|
||||
? _location[0] || ""
|
||||
: _location || "";
|
||||
cancel: isCancellationMode,
|
||||
} = querySchema.parse(router.query);
|
||||
|
||||
if (isCancellationMode && typeof window !== "undefined") {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(props.bookingInfo.location)
|
||||
? props.bookingInfo.location[0] || ""
|
||||
: props.bookingInfo.location || "";
|
||||
|
||||
if (!location) {
|
||||
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
||||
console.error(`No location found `);
|
||||
}
|
||||
|
||||
const name = props.bookingInfo?.user?.name;
|
||||
const email = props.bookingInfo?.user?.email;
|
||||
const status = props.bookingInfo?.status;
|
||||
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
||||
const cancellationReason = props.bookingInfo.cancellationReason;
|
||||
|
||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date)));
|
||||
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
|
||||
const { eventType, bookingInfo } = props;
|
||||
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
const isEmbed = useIsEmbed();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const [isCancellationMode, setIsCancellationMode] = useState(false);
|
||||
|
||||
function setIsCancellationMode(value: boolean) {
|
||||
if (value) router.query.cancel = "true";
|
||||
else delete router.query.cancel;
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query },
|
||||
});
|
||||
}
|
||||
|
||||
const attendeeName = typeof name === "string" ? name : "Nameless";
|
||||
|
||||
@@ -189,7 +215,7 @@ export default function Success(props: SuccessProps) {
|
||||
const giphyImage = giphyAppData?.thankYouPage;
|
||||
|
||||
const eventName = getEventName(eventNameObject, true);
|
||||
const needsConfirmation = eventType.requiresConfirmation && reschedule != "true";
|
||||
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
|
||||
const isCancelled = status === "CANCELLED" || status === "REJECTED";
|
||||
const telemetry = useTelemetry();
|
||||
useEffect(() => {
|
||||
@@ -252,7 +278,7 @@ export default function Success(props: SuccessProps) {
|
||||
function getTitle(): string {
|
||||
const titleSuffix = props.recurringBookings ? "_recurring" : "";
|
||||
if (isCancelled) {
|
||||
return t("emailed_information_about_cancelled_event");
|
||||
return "";
|
||||
}
|
||||
if (needsConfirmation) {
|
||||
if (props.profile.name !== null) {
|
||||
@@ -277,7 +303,7 @@ export default function Success(props: SuccessProps) {
|
||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||
{userIsOwner && !isEmbed && (
|
||||
<div className="mt-2 ml-4 -mb-4">
|
||||
<Link href={eventType.recurringEvent?.count ? "/bookings/recurring" : "/bookings/upcoming"}>
|
||||
<Link href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}>
|
||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-transparent dark:hover:text-white">
|
||||
<Icon.FiChevronLeft className="h-5 w-5" /> {t("back_to_bookings")}
|
||||
</a>
|
||||
@@ -346,6 +372,12 @@ export default function Success(props: SuccessProps) {
|
||||
<p className="text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
||||
</div>
|
||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-300 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
||||
{isCancelled && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">{t("reason")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
@@ -353,7 +385,7 @@ export default function Success(props: SuccessProps) {
|
||||
<RecurringBookings
|
||||
eventType={props.eventType}
|
||||
recurringBookings={props.recurringBookings}
|
||||
listingStatus={(listingStatus as string) || "recurring"}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
/>
|
||||
@@ -369,9 +401,9 @@ export default function Success(props: SuccessProps) {
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee, index) => (
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
<div key={attendee.name} className="mb-3 last:mb-0">
|
||||
<p>{attendee.name}</p>
|
||||
{attendee.name && <p>{attendee.name}</p>}
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -435,8 +467,8 @@ export default function Success(props: SuccessProps) {
|
||||
!isCancelled &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<div className="py-8 text-center last:pb-0">
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mb-8" />
|
||||
<div className="text-center last:pb-0">
|
||||
<span className="text-gray-900 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("need_to_make_a_change")}
|
||||
</span>
|
||||
@@ -451,6 +483,7 @@ export default function Success(props: SuccessProps) {
|
||||
)}
|
||||
|
||||
<button
|
||||
data-testid="cancel"
|
||||
className={classNames(
|
||||
"text-bookinglight text-gray-700 underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
@@ -461,19 +494,23 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CancelBooking
|
||||
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title, id: bookingInfo?.id }}
|
||||
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
team={eventType?.team?.name}
|
||||
setIsCancellationMode={setIsCancellationMode}
|
||||
theme={isSuccessBookingPage ? props.profile.theme : "light"}
|
||||
/>
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<CancelBooking
|
||||
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title, id: bookingInfo?.id }}
|
||||
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
team={eventType?.team?.name}
|
||||
setIsCancellationMode={setIsCancellationMode}
|
||||
theme={isSuccessBookingPage ? props.profile.theme : "light"}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{userIsOwner && !needsConfirmation && !isCancellationMode && !isCancelled && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<div className="text-bookingdark align-center flex flex-row justify-center py-8">
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
||||
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("add_to_calendar")}
|
||||
</span>
|
||||
@@ -580,7 +617,7 @@ export default function Success(props: SuccessProps) {
|
||||
)}
|
||||
{session === null && !(userIsOwner || props.hideBranding) && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
<div className="border-bookinglightest text-booking-lighter dark:border-darkgray-300 pt-8 text-center text-xs dark:text-white">
|
||||
<a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
|
||||
|
||||
@@ -596,11 +633,15 @@ export default function Success(props: SuccessProps) {
|
||||
<EmailInput
|
||||
name="email"
|
||||
id="email"
|
||||
defaultValue={router.query.email}
|
||||
className="focus:border-brand border-bookinglightest dark:border-darkgray-300 mt-0 block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:bg-black dark:text-white sm:text-sm"
|
||||
defaultValue={email || ""}
|
||||
className="mr- focus:border-brand border-bookinglightest dark:border-darkgray-300 mt-0 block w-full rounded-none rounded-l-md border-gray-300 shadow-sm focus:ring-black dark:bg-black dark:text-white sm:text-sm"
|
||||
placeholder="rick.astley@cal.com"
|
||||
/>
|
||||
<Button size="lg" type="submit" className="min-w-max" color="primary">
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="min-w-max rounded-none rounded-r-md"
|
||||
color="primary">
|
||||
{t("try_for_free")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -623,15 +664,15 @@ type RecurringBookingsProps = {
|
||||
recurringBookings: SuccessProps["recurringBookings"];
|
||||
date: dayjs.Dayjs;
|
||||
is24h: boolean;
|
||||
listingStatus: string;
|
||||
allRemainingBookings: boolean;
|
||||
};
|
||||
|
||||
export function RecurringBookings({
|
||||
eventType,
|
||||
recurringBookings,
|
||||
date,
|
||||
allRemainingBookings,
|
||||
is24h,
|
||||
listingStatus,
|
||||
}: RecurringBookingsProps) {
|
||||
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
|
||||
const { t } = useLocale();
|
||||
@@ -640,7 +681,7 @@ export function RecurringBookings({
|
||||
? recurringBookings.sort((a: ConfigType, b: ConfigType) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1))
|
||||
: null;
|
||||
|
||||
if (recurringBookingsSorted && listingStatus === "recurring") {
|
||||
if (recurringBookingsSorted && allRemainingBookings) {
|
||||
return (
|
||||
<>
|
||||
{eventType.recurringEvent?.count && (
|
||||
@@ -667,7 +708,7 @@ export function RecurringBookings({
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className={classNames("flex w-full", moreEventsVisible ? "hidden" : "")}>
|
||||
{t("plus_more", { count: recurringBookingsSorted.length - 4 })}
|
||||
+ {t("plus_more", { count: recurringBookingsSorted.length - 4 })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{eventType.recurringEvent?.count &&
|
||||
@@ -738,7 +779,10 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
},
|
||||
},
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -755,29 +799,15 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
const strToNumber = z.string().transform((val, ctx) => {
|
||||
const parsed = parseInt(val);
|
||||
if (isNaN(parsed)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Not a number" });
|
||||
return parsed;
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
type: strToNumber,
|
||||
date: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
reschedule: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
uid: z.string(),
|
||||
email: z.string().optional(),
|
||||
recur: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
eventSlug: z.string().default("15min"),
|
||||
eventName: z.string().default(""),
|
||||
bookingId: strToNumber,
|
||||
eventTypeSlug: z.string().optional(),
|
||||
});
|
||||
|
||||
const handleSeatsEventTypeOnBooking = (
|
||||
eventType: {
|
||||
seatsPerTimeSlot?: boolean | null;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
[x: string | number | symbol]: unknown;
|
||||
},
|
||||
@@ -789,6 +819,8 @@ const handleSeatsEventTypeOnBooking = (
|
||||
if (eventType?.seatsPerTimeSlot !== null) {
|
||||
// @TODO: right now bookings with seats doesn't save every description that its entered by every user
|
||||
delete booking.description;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!eventType.seatsShowAttendees) {
|
||||
const attendee = booking?.attendees?.find((a) => a.email === email);
|
||||
@@ -801,18 +833,61 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const parsedQuery = schema.safeParse(context.query);
|
||||
if (!parsedQuery.success) return { notFound: true };
|
||||
const {
|
||||
type: eventTypeId,
|
||||
recur: recurringEventIdQuery,
|
||||
eventSlug: eventTypeSlug,
|
||||
eventName: dynamicEventName,
|
||||
bookingId,
|
||||
username,
|
||||
name,
|
||||
email,
|
||||
} = parsedQuery.data;
|
||||
const { uid, email, eventTypeSlug } = parsedQuery.data;
|
||||
|
||||
const eventTypeRaw = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
|
||||
const bookingInfo = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
smsReminderNumber: true,
|
||||
recurringEventId: true,
|
||||
startTime: true,
|
||||
location: true,
|
||||
status: true,
|
||||
cancellationReason: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
eventName: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!bookingInfo) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
|
||||
|
||||
const eventTypeRaw = !bookingInfo.eventTypeId
|
||||
? getDefaultEvent(eventTypeSlug || "")
|
||||
: await getEventTypesFromDB(bookingInfo.eventTypeId);
|
||||
if (!eventTypeRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -840,6 +915,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
periodStartDate: eventTypeRaw.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
@@ -853,58 +930,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
slug: eventType.team?.slug || eventType.users[0]?.username || null,
|
||||
};
|
||||
|
||||
const where: Prisma.BookingWhereInput = {
|
||||
id: bookingId,
|
||||
attendees: { some: { email, name } },
|
||||
};
|
||||
// Dynamic Event uses EventType from @calcom/lib/defaultEvents(a fake EventType) which doesn't have a real user/team/eventTypeId
|
||||
// So, you can't look them up in DB.
|
||||
if (!eventType.isDynamic) {
|
||||
// A Team Event doesn't have a correct user query param as of now. It is equal to team/{eventSlug} which is not a user, so you can't look it up in DB.
|
||||
if (!eventType.team) {
|
||||
// username being equal to profile.slug isn't applicable for Team or Dynamic Events.
|
||||
where.user = { username };
|
||||
}
|
||||
where.eventTypeId = eventType.id;
|
||||
} else {
|
||||
// username being equal to eventSlug for Dynamic Event Booking, it can't be used for user lookup. So, just use eventTypeId which would always be null for Dynamic Event Bookings
|
||||
where.eventTypeId = null;
|
||||
}
|
||||
|
||||
const bookingInfo = await prisma.booking.findFirst({
|
||||
where,
|
||||
select: {
|
||||
title: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
smsReminderNumber: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (bookingInfo !== null && email) {
|
||||
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
|
||||
}
|
||||
|
||||
let recurringBookings = null;
|
||||
if (recurringEventIdQuery) {
|
||||
if (bookingInfo.recurringEventId) {
|
||||
// We need to get the dates for the bookings to be able to show them in the UI
|
||||
recurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: recurringEventIdQuery,
|
||||
recurringEventId: bookingInfo.recurringEventId,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
@@ -919,7 +954,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventType,
|
||||
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
dynamicEventName,
|
||||
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
||||
bookingInfo,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ test.describe("pro user", () => {
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname === "/success" && url.searchParams.get("reschedule") === "true";
|
||||
return url.pathname === "/success";
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,7 @@ test.describe("pro user", () => {
|
||||
await page.locator('[data-testid="cancel"]').first().click();
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return url.pathname.startsWith("/cancel");
|
||||
return url.pathname.startsWith("/success");
|
||||
},
|
||||
});
|
||||
// --- fill form
|
||||
|
||||
@@ -37,7 +37,7 @@ test("dynamic booking", async ({ page, users }) => {
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname === "/success" && url.searchParams.get("reschedule") === "true";
|
||||
return url.pathname === "/success";
|
||||
},
|
||||
});
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
@@ -48,7 +48,7 @@ test("dynamic booking", async ({ page, users }) => {
|
||||
await page.locator('[data-testid="cancel"]').first().click();
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return url.pathname.startsWith("/cancel");
|
||||
return url.pathname.startsWith("/success");
|
||||
},
|
||||
});
|
||||
// --- fill form
|
||||
|
||||
@@ -22,7 +22,7 @@ test.describe("Event Types tests", () => {
|
||||
});
|
||||
|
||||
test("has at least 2 events", async ({ page }) => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > *");
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const count = await $eventTypes.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ test.describe("Event Types tests", () => {
|
||||
expect(formSlug).toBe(firstSlug);
|
||||
});
|
||||
test("edit first event", async ({ page }) => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > *");
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
await firstEventTypeElement.click();
|
||||
await page.waitForNavigation({
|
||||
@@ -134,13 +134,13 @@ test.describe("Event Types tests", () => {
|
||||
});
|
||||
|
||||
test("has at least 2 events where first is enabled", async ({ page }) => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > *");
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const count = await $eventTypes.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("edit first event", async ({ page }) => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > *");
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
await firstEventTypeElement.click();
|
||||
await page.waitForNavigation({
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe("hash my url", () => {
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.locator('//ul[@data-testid="event-types"]/li[1]').click();
|
||||
await page.locator("ul[data-testid=event-types] > li a").first().click();
|
||||
// We wait for the page to load
|
||||
await page.locator(".primary-navigation >> text=Advanced").click();
|
||||
// ignore if it is already checked, and click if unchecked
|
||||
@@ -45,7 +45,7 @@ test.describe("hash my url", () => {
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.click('//ul[@data-testid="event-types"]/li[1]');
|
||||
await page.locator("ul[data-testid=event-types] > li a").first().click();
|
||||
// We wait for the page to load
|
||||
await page.locator(".primary-navigation >> text=Advanced").click();
|
||||
// we wait for the hashedLink setting to load
|
||||
|
||||
@@ -9,15 +9,12 @@ test.describe("Teams", () => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("Can create teams via Wizard", async ({ page, users, prisma }) => {
|
||||
test("Can create teams via Wizard", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.login();
|
||||
await page.goto("/teams");
|
||||
|
||||
// Expect teams to be empty
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create Team").click();
|
||||
@@ -58,7 +55,9 @@ test.describe("Teams", () => {
|
||||
await page.locator("text=Delete Team").click();
|
||||
await page.locator("text=Yes, disband team").click();
|
||||
await page.waitForURL("/teams");
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
await expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
// FLAKY: If other tests are running async this may mean there are >0 teams, empty screen will not be shown.
|
||||
// await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -597,7 +597,7 @@
|
||||
"monthly_other": "أشهر",
|
||||
"yearly_one": "سنة",
|
||||
"yearly_other": "سنوات",
|
||||
"plus_more": "+ {{count}} أكثر",
|
||||
"plus_more": "{{count}} أكثر",
|
||||
"max": "الحد الأقصى",
|
||||
"single_theme": "سمة واحدة",
|
||||
"brand_color": "لون العلامة التجارية",
|
||||
|
||||
@@ -597,7 +597,7 @@
|
||||
"monthly_other": "měs.",
|
||||
"yearly_one": "rok",
|
||||
"yearly_other": "roky/let",
|
||||
"plus_more": "+ ještě {{count}}",
|
||||
"plus_more": "ještě {{count}}",
|
||||
"max": "Max.",
|
||||
"single_theme": "Jednotná šablona",
|
||||
"brand_color": "Barva značky",
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"event_request_cancelled": "Your scheduled event was cancelled",
|
||||
"organizer": "Organizer",
|
||||
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
||||
"cancellation_reason": "Reason for cancellation",
|
||||
"cancellation_reason_placeholder": "Why are you cancelling? (optional)",
|
||||
"cancellation_reason": "Reason for cancellation (optional)",
|
||||
"cancellation_reason_placeholder": "Why are you cancelling?",
|
||||
"rejection_reason": "Reason for rejecting",
|
||||
"rejection_reason_title": "Reject the booking request?",
|
||||
"rejection_reason_description": "Are you sure you want to reject the booking? We'll let the person who tried to book know. You can provide a reason below.",
|
||||
@@ -108,6 +108,8 @@
|
||||
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
|
||||
"upgrade_to_per_seat": "Upgrade to Per-Seat",
|
||||
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.",
|
||||
"team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.",
|
||||
"team_upgrade_banner_action": "Upgrade here",
|
||||
"team_upgraded_successfully": "Your team was upgraded successfully!",
|
||||
"use_link_to_reset_password": "Use the link below to reset your password",
|
||||
"hey_there": "Hey there,",
|
||||
@@ -599,7 +601,7 @@
|
||||
"monthly_other": "months",
|
||||
"yearly_one": "year",
|
||||
"yearly_other": "years",
|
||||
"plus_more": "+ {{count}} more",
|
||||
"plus_more": "{{count}} more",
|
||||
"max": "Max",
|
||||
"single_theme": "Single Theme",
|
||||
"brand_color": "Brand Color",
|
||||
@@ -1295,6 +1297,7 @@
|
||||
"fetching_calendars_error": "There was a problem fetching your calendars. Please <1>try again</1> or reach out to customer support.",
|
||||
"calendar_connection_fail": "Calendar connection failed",
|
||||
"booking_confirmation_success": "Booking confirmation succeeded",
|
||||
"booking_rejection_success": "Booking rejection succeeded",
|
||||
"booking_confirmation_fail": "Booking confirmation failed",
|
||||
"we_wont_show_again": "We won't show this again",
|
||||
"couldnt_update_timezone": "We couldn't update the timezone",
|
||||
@@ -1361,8 +1364,12 @@
|
||||
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
|
||||
"choose_common_schedule_team_event": "Choose a common schedule",
|
||||
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
|
||||
"reason": "Reason",
|
||||
"sender_id": "Sender ID",
|
||||
"sender_id_error_message":"Only letters, numbers and spaces allowed (max. 11 characters)",
|
||||
"test_routing_form": "Test Routing Form",
|
||||
"test_preview": "Test Preview",
|
||||
"route_to": "Route to",
|
||||
"test_preview_description": "Test your routing form without submitting any data"
|
||||
"test_preview_description": "Test your routing form without submitting any data",
|
||||
"test_routing": "Test Routing"
|
||||
}
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "meses",
|
||||
"yearly_one": "año",
|
||||
"yearly_other": "años",
|
||||
"plus_more": "+ {{count}} más",
|
||||
"plus_more": "{{count}} más",
|
||||
"max": "Máx.",
|
||||
"single_theme": "Tema",
|
||||
"brand_color": "Color de marca",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "mois",
|
||||
"yearly_one": "année",
|
||||
"yearly_other": "années",
|
||||
"plus_more": "+ {{count}} de plus",
|
||||
"plus_more": "{{count}} de plus",
|
||||
"max": "Max.",
|
||||
"single_theme": "Thème unique",
|
||||
"brand_color": "Couleur de la marque",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "mesi",
|
||||
"yearly_one": "anno",
|
||||
"yearly_other": "anni",
|
||||
"plus_more": "+ altri {{count}}",
|
||||
"plus_more": "altri {{count}}",
|
||||
"max": "Max",
|
||||
"single_theme": "Tema Singolo",
|
||||
"brand_color": "Colore del Marchio",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "月",
|
||||
"yearly_one": "年",
|
||||
"yearly_other": "年",
|
||||
"plus_more": "+ {{count}} 回以上",
|
||||
"plus_more": "{{count}} 回以上",
|
||||
"max": "最大",
|
||||
"single_theme": "シングルテーマ",
|
||||
"brand_color": "ブランドカラー",
|
||||
@@ -697,7 +697,7 @@
|
||||
"add_new_event_type": "新しいイベントタイプを追加する",
|
||||
"new_event_type_to_book_description": "ユーザーが時間を予約する際に使う新しいイベントタイプを作成する。",
|
||||
"length": "長さ",
|
||||
"minimum_booking_notice": "最低頻度の通知",
|
||||
"minimum_booking_notice": "予約通知の最小時間",
|
||||
"slot_interval": "時間帯の間隔",
|
||||
"slot_interval_default": "イベントの長さを使用する (デフォルト)",
|
||||
"delete_event_type_description": "このイベントタイプを削除してもよろしいですか? あなたがこのリンクを共有した人は、それを使って予約することができなくなります。",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "개월",
|
||||
"yearly_one": "년",
|
||||
"yearly_other": "년",
|
||||
"plus_more": "+ {{count}} 이상",
|
||||
"plus_more": "{{count}} 이상",
|
||||
"max": "최대",
|
||||
"single_theme": "단일 테마",
|
||||
"brand_color": "브랜드 색상",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "maanden",
|
||||
"yearly_one": "jaar",
|
||||
"yearly_other": "jaar",
|
||||
"plus_more": "Nog {{count}}",
|
||||
"plus_more": "{{count}} meer",
|
||||
"max": "Max.",
|
||||
"single_theme": "Enkel Thema",
|
||||
"brand_color": "Merkkleur",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "miesiące",
|
||||
"yearly_one": "rok",
|
||||
"yearly_other": "lata",
|
||||
"plus_more": "+ {{count}} więcej",
|
||||
"plus_more": "{{count}} więcej",
|
||||
"max": "Maks.",
|
||||
"single_theme": "Pojedynczy motyw",
|
||||
"brand_color": "Kolor Marki",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "meses",
|
||||
"yearly_one": "ano",
|
||||
"yearly_other": "anos",
|
||||
"plus_more": "+ {{count}} mais",
|
||||
"plus_more": "{{count}} mais",
|
||||
"max": "Máx",
|
||||
"single_theme": "Tema",
|
||||
"brand_color": "Cor da Marca",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "meses",
|
||||
"yearly_one": "ano",
|
||||
"yearly_other": "anos",
|
||||
"plus_more": "+ {{count}} mais",
|
||||
"plus_more": "{{count}} mais",
|
||||
"max": "Máximo",
|
||||
"single_theme": "Tema",
|
||||
"brand_color": "Cor da marca",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "luni",
|
||||
"yearly_one": "an",
|
||||
"yearly_other": "ani",
|
||||
"plus_more": "+ încă {{count}}",
|
||||
"plus_more": "încă {{count}}",
|
||||
"max": "Max.",
|
||||
"single_theme": "Temă unică",
|
||||
"brand_color": "Culoare marcă",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "мес.",
|
||||
"yearly_one": "год",
|
||||
"yearly_other": "г.",
|
||||
"plus_more": "+ еще {{count}}",
|
||||
"plus_more": "еще {{count}}",
|
||||
"max": "Макс.",
|
||||
"single_theme": "Тема оформления",
|
||||
"brand_color": "Цвет бренда",
|
||||
|
||||
@@ -597,7 +597,7 @@
|
||||
"monthly_other": "meseci",
|
||||
"yearly_one": "godinu",
|
||||
"yearly_other": "godina",
|
||||
"plus_more": "+ još {{count}}",
|
||||
"plus_more": "još {{count}}",
|
||||
"max": "Maks.",
|
||||
"single_theme": "Tema",
|
||||
"brand_color": "Boja brenda",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "månader",
|
||||
"yearly_one": "år",
|
||||
"yearly_other": "år",
|
||||
"plus_more": "+ {{count}} till",
|
||||
"plus_more": "{{count}} till",
|
||||
"max": "Max",
|
||||
"single_theme": "Ett tema",
|
||||
"brand_color": "Primär färg",
|
||||
|
||||
@@ -597,7 +597,7 @@
|
||||
"monthly_other": "ay",
|
||||
"yearly_one": "yıl",
|
||||
"yearly_other": "yıl",
|
||||
"plus_more": "+ {{count}} tane daha",
|
||||
"plus_more": "{{count}} tane daha",
|
||||
"max": "Maks.",
|
||||
"single_theme": "Tek Tema",
|
||||
"brand_color": "Marka Rengi",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "tháng",
|
||||
"yearly_one": "năm",
|
||||
"yearly_other": "năm",
|
||||
"plus_more": "+ {{count}} nữa",
|
||||
"plus_more": "{{count}} nữa",
|
||||
"max": "Tối đa",
|
||||
"single_theme": "Chủ đề đơn",
|
||||
"brand_color": "Màu thương hiệu",
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
"monthly_other": "月",
|
||||
"yearly_one": "年",
|
||||
"yearly_other": "年",
|
||||
"plus_more": "+ 另外 {{count}}",
|
||||
"plus_more": "另外 {{count}}",
|
||||
"max": "最大",
|
||||
"single_theme": "主题",
|
||||
"brand_color": "品牌颜色",
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.x",
|
||||
"node": ">=16.x",
|
||||
"npm": ">=7.0.0",
|
||||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
},
|
||||
|
||||
@@ -26,35 +26,42 @@ export default function AppCard({
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div ref={animationRef} className="mb-4 mt-2 rounded-md border border-gray-200 p-4 text-sm sm:p-8">
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
|
||||
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
|
||||
<Link href={"/apps/" + app.slug}>
|
||||
<a className="mr-3 h-auto w-10 rounded-sm">
|
||||
<img src={app?.logo} alt={app?.name} />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold leading-none text-black">{app?.name}</span>
|
||||
<p className="pt-2 text-sm font-normal text-gray-600">{description || app?.description}</p>
|
||||
</div>
|
||||
{app?.isInstalled ? (
|
||||
<div className="ml-auto flex items-center">
|
||||
<Switch
|
||||
onCheckedChange={(enabled) => {
|
||||
if (switchOnClick) {
|
||||
switchOnClick(enabled);
|
||||
}
|
||||
setAppData("enabled", enabled);
|
||||
}}
|
||||
checked={switchChecked}
|
||||
/>
|
||||
<div className="mb-4 mt-2 rounded-md border border-gray-200">
|
||||
<div className="p-4 text-sm sm:p-8">
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
|
||||
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
|
||||
<Link href={"/apps/" + app.slug}>
|
||||
<a className="mr-3 h-auto w-10 rounded-sm">
|
||||
<img className="w-full" src={app?.logo} alt={app?.name} />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base font-semibold leading-4 text-black">{app?.name}</span>
|
||||
<p className="pt-2 text-sm font-normal leading-4 text-gray-600">
|
||||
{description || app?.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<OmniInstallAppButton className="ml-auto flex items-center" appId={app?.slug} />
|
||||
)}
|
||||
{app?.isInstalled ? (
|
||||
<div className="ml-auto flex items-center">
|
||||
<Switch
|
||||
onCheckedChange={(enabled) => {
|
||||
if (switchOnClick) {
|
||||
switchOnClick(enabled);
|
||||
}
|
||||
setAppData("enabled", enabled);
|
||||
}}
|
||||
checked={switchChecked}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<OmniInstallAppButton className="ml-auto flex items-center" appId={app?.slug} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={animationRef}>
|
||||
{app?.isInstalled && switchChecked && <hr />}
|
||||
{app?.isInstalled && switchChecked ? <div className="p-4 text-sm sm:px-8">{children}</div> : null}
|
||||
</div>
|
||||
{app?.isInstalled && switchChecked ? <div className="mt-4">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ import { App } from "@calcom/types/App";
|
||||
|
||||
import getInstalledAppPath from "./getInstalledAppPath";
|
||||
|
||||
function gotoUrl(url: string, newTab?: boolean) {
|
||||
if (newTab) {
|
||||
window.open(url, "_blank");
|
||||
return;
|
||||
}
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeof useMutation>[2]) {
|
||||
const mutation = useMutation<
|
||||
unknown,
|
||||
@@ -41,7 +49,7 @@ function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeo
|
||||
const json = await res.json();
|
||||
|
||||
if (!isOmniInstall) {
|
||||
window.location.href = json.url;
|
||||
gotoUrl(json.url, json.newTab);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,7 +59,7 @@ function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeo
|
||||
// Check first that the URL is absolute, then check that it is of different origin from the current.
|
||||
if (/https?:\/\//.test(json.url) && !json.url.startsWith(window.location.origin)) {
|
||||
// TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks
|
||||
window.location.href = json.url;
|
||||
gotoUrl(json.url, json.newTab);
|
||||
}
|
||||
}, options);
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ const handler: AppDeclarativeHandler = {
|
||||
},
|
||||
});
|
||||
},
|
||||
redirectUrl: "/apps/routing-forms/forms",
|
||||
redirect: {
|
||||
url: "/apps/routing-forms/forms",
|
||||
},
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function FormInputFields(props: Props) {
|
||||
/* @ts-ignore */
|
||||
required={!!field.required}
|
||||
listValues={options}
|
||||
data-testid="field"
|
||||
data-testid="form-field"
|
||||
setValue={(value) => {
|
||||
setResponse((response) => {
|
||||
response = response || {};
|
||||
|
||||
@@ -283,7 +283,10 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button color="secondary" onClick={() => setIsTestPreviewOpen(true)}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="test-preview"
|
||||
onClick={() => setIsTestPreviewOpen(true)}>
|
||||
{t("test_preview")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -326,16 +329,19 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
<div className="mt-2">
|
||||
{RoutingPages.map((page) => {
|
||||
if (page.value === decidedAction.type) {
|
||||
return <>{page.label}</>;
|
||||
return <div data-testid="test-routing-result-type">{page.label}</div>;
|
||||
}
|
||||
})}
|
||||
:{" "}
|
||||
{decidedAction.type === "customPageMessage" ? (
|
||||
<span className="text-gray-700">{decidedAction.value}</span>
|
||||
<span className="text-gray-700" data-testid="test-routing-result">
|
||||
{decidedAction.value}
|
||||
</span>
|
||||
) : decidedAction.type === "externalRedirectUrl" ? (
|
||||
<span className="text-gray-700 underline">
|
||||
<a
|
||||
target="_blank"
|
||||
data-testid="test-routing-result"
|
||||
href={
|
||||
decidedAction.value.includes("https://") ||
|
||||
decidedAction.value.includes("http://")
|
||||
@@ -348,7 +354,11 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-700 underline">
|
||||
<a target="_blank" href={`/${decidedAction.value}`} rel="noreferrer">
|
||||
<a
|
||||
target="_blank"
|
||||
href={`/${decidedAction.value}`}
|
||||
rel="noreferrer"
|
||||
data-testid="test-routing-result">
|
||||
{decidedAction.value}
|
||||
</a>
|
||||
</span>
|
||||
@@ -369,7 +379,9 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
{t("close")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">{t("Test Routing")}</Button>
|
||||
<Button type="submit" data-testid="test-routing">
|
||||
{t("test_routing")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
}
|
||||
});
|
||||
|
||||
const initialConfigCopy = { ...InitialConfig };
|
||||
const initialConfigCopy = { ...InitialConfig, operators: { ...InitialConfig.operators } };
|
||||
if (forReporting) {
|
||||
delete initialConfigCopy.operators.is_empty;
|
||||
delete initialConfigCopy.operators.is_not_empty;
|
||||
|
||||
@@ -283,11 +283,50 @@ test.describe("Routing Forms", () => {
|
||||
expect(firstInputMissingValue).toBe(true);
|
||||
expect(await page.locator('button[type="submit"][disabled]').count()).toBe(0);
|
||||
});
|
||||
|
||||
test("Test preview should return correct route", async ({ page, users }) => {
|
||||
const user = await createUserAndLoginAndInstallApp({ users, page });
|
||||
const routingForm = user.routingForms[0];
|
||||
page.goto(`apps/routing-forms/form-edit/${routingForm.id}`);
|
||||
await page.click('[data-testid="test-preview"]');
|
||||
|
||||
// //event redirect
|
||||
await page.fill('[data-testid="form-field"]', "event-routing");
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("Event Redirect");
|
||||
await expect(route).toBe("pro/30min");
|
||||
|
||||
//custom page
|
||||
await page.fill('[data-testid="form-field"]', "custom-page");
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("Custom Page");
|
||||
await expect(route).toBe("Custom Page Result");
|
||||
|
||||
//external redirect
|
||||
await page.fill('[data-testid="form-field"]', "external-redirect");
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("External Redirect");
|
||||
await expect(route).toBe("https://google.com");
|
||||
|
||||
//fallback route
|
||||
await page.fill('[data-testid="form-field"]', "fallback");
|
||||
await page.click('[data-testid="test-routing"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("Custom Page");
|
||||
await expect(route).toBe("Fallback Message");
|
||||
});
|
||||
});
|
||||
});
|
||||
async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="field"]', "event-routing");
|
||||
await page.fill('[data-testid="form-field"]', "event-routing");
|
||||
page.click('button[type="submit"]');
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
@@ -296,7 +335,7 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
});
|
||||
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="field"]', "external-redirect");
|
||||
await page.fill('[data-testid="form-field"]', "external-redirect");
|
||||
page.click('button[type="submit"]');
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
@@ -305,7 +344,7 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
});
|
||||
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="field"]', "custom-page");
|
||||
await page.fill('[data-testid="form-field"]', "custom-page");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.isVisible("text=Custom Page Result");
|
||||
}
|
||||
|
||||
@@ -56,17 +56,8 @@ export default class LarkCalendarService implements Calendar {
|
||||
const refreshExpireDate = larkAuthCredentials.refresh_expires_date;
|
||||
const refreshToken = larkAuthCredentials.refresh_token;
|
||||
if (isExpired(refreshExpireDate) || !refreshToken) {
|
||||
const res = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: credential.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("disconnection wrong");
|
||||
}
|
||||
throw new Error("refresh token expires");
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
throw new Error("Lark Calendar refresh token expired");
|
||||
}
|
||||
try {
|
||||
const appAccessToken = await getAppAccessToken();
|
||||
|
||||
@@ -10,7 +10,10 @@ const handler: AppDeclarativeHandler = {
|
||||
slug: appConfig.slug,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
redirectUrl: "https://n8n.io/integrations/cal-trigger/",
|
||||
redirect: {
|
||||
url: "https://n8n.io/integrations/cal-trigger/",
|
||||
newTab: true,
|
||||
},
|
||||
createCredential: ({ appType, user, slug }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
|
||||
};
|
||||
|
||||
@@ -10,7 +10,10 @@ const handler: AppDeclarativeHandler = {
|
||||
slug: appConfig.slug,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
redirectUrl: "https://pipedream.com/apps/cal-com",
|
||||
redirect: {
|
||||
newTab: true,
|
||||
url: "https://pipedream.com/apps/cal-com",
|
||||
},
|
||||
createCredential: ({ appType, user, slug }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
|
||||
};
|
||||
|
||||
@@ -15,8 +15,8 @@ import { useAccount, useSignMessage } from "wagmi";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { SkeletonText } from "@calcom/ui/v2";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import { getProviders, ETH_MESSAGE, SUPPORTED_CHAINS } from "../utils/ethereum";
|
||||
@@ -118,8 +118,8 @@ const BalanceCheck: React.FC<RainbowGateProps> = ({ chainId, setToken, tokenAddr
|
||||
<h2 className="mb-2 grow font-semibold text-neutral-900 dark:text-white">Token Gate</h2>
|
||||
{isLoading && (
|
||||
<>
|
||||
<SkeletonText width="[100%]" height="5" className="mb-3" />
|
||||
<SkeletonText width="[100%]" height="5" />
|
||||
<SkeletonText className="mb-3 h-1 w-full" />
|
||||
<SkeletonText className="h-1 w-full" />
|
||||
</>
|
||||
)}
|
||||
{!isLoading && contractData && contractData.data && (
|
||||
|
||||
@@ -10,7 +10,9 @@ const handler: AppDeclarativeHandler = {
|
||||
variant: appConfig.variant,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
redirectUrl: "raycast://extensions/eluce2/cal-com-share-meeting-links?source=webstore",
|
||||
redirect: {
|
||||
url: "raycast://extensions/eluce2/cal-com-share-meeting-links?source=webstore",
|
||||
},
|
||||
createCredential: ({ appType, user, slug }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@ const handler: AppDeclarativeHandler = {
|
||||
variant: appConfig.variant,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
redirectUrl: "/apps/typeform/how-to-use",
|
||||
redirect: {
|
||||
url: "/apps/typeform/how-to-use",
|
||||
},
|
||||
createCredential: ({ appType, user, slug }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
|
||||
};
|
||||
|
||||
@@ -203,6 +203,9 @@ module.exports = {
|
||||
full: "100%",
|
||||
screen: "100vw",
|
||||
}),
|
||||
backgroundImage: {
|
||||
"gradient-primary": "radial-gradient(162.05% 170% at 109.58% 35%, #667593 0%, #E3E3E3 100%)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -29,10 +29,15 @@ export function ManageLink(props: { calEvent: CalendarEvent; attendee: Person })
|
||||
gap: "8px",
|
||||
}}>
|
||||
<>{t("need_to_make_a_change")}</>
|
||||
<a href={getRescheduleLink(props.calEvent)} style={{ color: "#3e3e3e" }}>
|
||||
<>{t("reschedule")}</>
|
||||
</a>
|
||||
<>{t("or_lowercase")}</>
|
||||
{!props.calEvent.recurringEvent && (
|
||||
<>
|
||||
<a href={getRescheduleLink(props.calEvent)} style={{ color: "#3e3e3e" }}>
|
||||
<>{t("reschedule")}</>
|
||||
</a>
|
||||
<>{t("or_lowercase")}</>
|
||||
</>
|
||||
)}
|
||||
|
||||
<a href={getCancelLink(props.calEvent)} style={{ color: "#3e3e3e" }}>
|
||||
<>{t("cancel")}</>
|
||||
</a>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
|
||||
|
||||
export const AttendeeRequestEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
|
||||
<AttendeeScheduledEmail
|
||||
title={props.calEvent.organizer.language.translate(
|
||||
title={props.calEvent.attendees[0].language.translate(
|
||||
props.calEvent.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
|
||||
)}
|
||||
subtitle={
|
||||
<>
|
||||
{props.calEvent.organizer.language.translate(
|
||||
{props.calEvent.attendees[0].language.translate(
|
||||
props.calEvent.recurringEvent?.count
|
||||
? "user_needs_to_confirm_or_reject_booking_recurring"
|
||||
: "user_needs_to_confirm_or_reject_booking",
|
||||
@@ -16,7 +16,7 @@ export const AttendeeRequestEmail = (props: React.ComponentProps<typeof Attendee
|
||||
</>
|
||||
}
|
||||
headerType="calendarCircle"
|
||||
subject="booking_submitted_subject"
|
||||
subject={props.calEvent.attendees[0].language.translate("booking_submitted_subject")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default class AttendeeRequestEmail extends AttendeeScheduledEmail {
|
||||
return {
|
||||
from: `Cal.com <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject: `${this.calEvent.organizer.language.translate("booking_submitted_subject", {
|
||||
subject: `${this.calEvent.attendees[0].language.translate("booking_submitted_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
name: this.calEvent.team?.name || this.calEvent.organizer.name,
|
||||
date: this.getFormattedDate(),
|
||||
|
||||
@@ -510,6 +510,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
uid: reqBody.bookingUid,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
id: true,
|
||||
attendees: true,
|
||||
userId: true,
|
||||
|
||||
@@ -115,6 +115,11 @@ const DestinationCalendarSelector = ({
|
||||
value: `${cal.integration}:${cal.externalId}`,
|
||||
})),
|
||||
})) ?? [];
|
||||
|
||||
// Get primary calendar, which is shown in the placeholder since this is the calendar that will
|
||||
// be used when no destination calendar is selected.
|
||||
const primaryCalendar = query.data.connectedCalendars.filter((cal) => Boolean(cal.primary))[0];
|
||||
|
||||
return (
|
||||
<div className="relative" title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||
<Select
|
||||
@@ -124,7 +129,8 @@ const DestinationCalendarSelector = ({
|
||||
`${t("select_destination_calendar")}`
|
||||
) : (
|
||||
<span>
|
||||
{t("default_calendar_selected")} ({destinationCalendar?.externalId})
|
||||
{t("default_calendar_selected")}{" "}
|
||||
{primaryCalendar?.primary?.externalId && `(${primaryCalendar?.primary?.externalId})`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { TRIAL_LIMIT_DAYS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
const TrialBanner = () => {
|
||||
const { t } = useLocale();
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
if (!user || user.plan !== "TRIAL") return null;
|
||||
|
||||
const trialDaysLeft = user.trialEndsAt
|
||||
? dayjs(user.trialEndsAt).add(1, "day").diff(dayjs(), "day")
|
||||
: dayjs(user.createdDate)
|
||||
.add(TRIAL_LIMIT_DAYS + 1, "day")
|
||||
.diff(dayjs(), "day");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="m-4 hidden rounded-md bg-yellow-200 p-4 text-center text-sm font-medium text-gray-600 lg:block"
|
||||
data-testid="trial-banner">
|
||||
<div className="mb-2 text-left">
|
||||
{trialDaysLeft > 0 ? t("trial_days_left", { days: trialDaysLeft }) : t("trial_expired")}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
href="/api/upgrade"
|
||||
color="minimal"
|
||||
prefetch={false}
|
||||
className="w-full justify-center border-2 border-gray-600 hover:bg-yellow-100">
|
||||
{t("upgrade_now")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialBanner;
|
||||
@@ -36,6 +36,7 @@ type Props = {
|
||||
user: { username: string | null };
|
||||
location?: string | null;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
type States =
|
||||
@@ -47,7 +48,6 @@ type States =
|
||||
export default function PaymentComponent(props: Props) {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { email, name, date } = router.query;
|
||||
const [state, setState] = useState<States>({ status: "idle" });
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
@@ -83,12 +83,7 @@ export default function PaymentComponent(props: Props) {
|
||||
});
|
||||
} else {
|
||||
const params: { [k: string]: any } = {
|
||||
date,
|
||||
type: props.eventType.id,
|
||||
username: props.user.username,
|
||||
email,
|
||||
name,
|
||||
bookingId: props.bookingId,
|
||||
uid: props.bookingUid,
|
||||
};
|
||||
|
||||
if (props.location) {
|
||||
|
||||
@@ -136,6 +136,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
user={props.user}
|
||||
location={props.booking.location}
|
||||
bookingId={props.booking.id}
|
||||
bookingUid={props.booking.uid}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
description: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
|
||||
@@ -39,13 +39,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!team) {
|
||||
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
|
||||
const metadata = teamMetadataSchema.parse(prevTeam.metadata);
|
||||
if (!metadata?.requestedSlug) throw new HttpError({ statusCode: 400, message: "Missing requestedSlug" });
|
||||
|
||||
/** Legacy teams already have a slug, this will allow them to upgrade as well */
|
||||
const slug = prevTeam.slug || metadata?.requestedSlug;
|
||||
if (!slug) throw new HttpError({ statusCode: 400, message: "Missing team slug" });
|
||||
try {
|
||||
team = await prisma.team.update({
|
||||
/** We save the metadata first to prevent duplicate payments */
|
||||
await prisma.team.update({
|
||||
where: { id },
|
||||
data: {
|
||||
slug: metadata.requestedSlug,
|
||||
metadata: {
|
||||
paymentId: checkoutSession.id,
|
||||
subscriptionId: subscription.id || null,
|
||||
@@ -53,8 +54,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
},
|
||||
});
|
||||
/** Then we try to upgrade the slug, which may fail if a conflict came up since team creation */
|
||||
team = await prisma.team.update({ where: { id }, data: { slug } });
|
||||
} catch (error) {
|
||||
const { message, statusCode } = getRequestedSlugError(error, metadata.requestedSlug);
|
||||
const { message, statusCode } = getRequestedSlugError(error, slug);
|
||||
return res.status(statusCode).json({ message });
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ export const CreateANewTeamForm = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form form={newTeamFormMethods} handleSubmit={(v) => createTeamMutation.mutate(v)}>
|
||||
<Form
|
||||
form={newTeamFormMethods}
|
||||
handleSubmit={(v) => {
|
||||
if (!createTeamMutation.isLoading) createTeamMutation.mutate(v);
|
||||
}}>
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
name="name"
|
||||
@@ -113,10 +117,19 @@ export const CreateANewTeamForm = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button color="secondary" href="/settings" className="w-full justify-center">
|
||||
<Button
|
||||
disabled={createTeamMutation.isLoading}
|
||||
color="secondary"
|
||||
href="/settings"
|
||||
className="w-full justify-center">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button color="primary" type="submit" EndIcon={Icon.FiArrowRight} className="w-full justify-center">
|
||||
<Button
|
||||
disabled={createTeamMutation.isLoading}
|
||||
color="primary"
|
||||
type="submit"
|
||||
EndIcon={Icon.FiArrowRight}
|
||||
className="w-full justify-center">
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
import { SkeletonText } from "@calcom/ui/v2";
|
||||
|
||||
function SkeletonLoaderTeamList() {
|
||||
return (
|
||||
@@ -19,18 +19,18 @@ function SkeletonItem() {
|
||||
<li className="group flex w-full items-center justify-between px-3 py-4">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-start space-x-2 px-2">
|
||||
<SkeletonText width="10" height="10" className="rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<SkeletonText height="4" width="32" />
|
||||
<SkeletonText height="4" width="16" />
|
||||
<SkeletonText className="h-10 w-10 rounded-full" />
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 pr-4 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="12" height="4" />
|
||||
<SkeletonText width="4" height="4" />
|
||||
<SkeletonText width="4" height="4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
<SkeletonText className="h-4 w-4" />
|
||||
<SkeletonText className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Avatar } from "@calcom/ui/components";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
@@ -21,8 +22,6 @@ import Dropdown, {
|
||||
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
import { TeamRole } from "./TeamPill";
|
||||
|
||||
interface Props {
|
||||
@@ -65,7 +64,7 @@ export default function TeamListItem(props: Props) {
|
||||
const teamInfo = (
|
||||
<div className="flex px-5 py-5">
|
||||
<Avatar
|
||||
size={9}
|
||||
size="sm"
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="min-h-9 min-w-9 h-9 w-9 rounded-full"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { TopBanner } from "@calcom/ui/components";
|
||||
import { showToast } from "@calcom/ui/v2/core";
|
||||
|
||||
export function TeamsUpgradeBanner() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { data } = trpc.viewer.teams.getUpgradeable.useQuery();
|
||||
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
if (!data) return null;
|
||||
const [membership] = data;
|
||||
if (!membership) return null;
|
||||
return (
|
||||
<TopBanner
|
||||
text={t("team_upgrade_banner_description", { teamName: membership.team.name })}
|
||||
variant="warning"
|
||||
actions={
|
||||
<button
|
||||
className="border-b border-b-black"
|
||||
onClick={() => {
|
||||
publishTeamMutation.mutate({ teamId: membership.team.id });
|
||||
}}>
|
||||
{t("team_upgrade_banner_action")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { CreateANewTeamForm } from "./CreateANewTeamForm";
|
||||
export { TeamsListing } from "./TeamsListing";
|
||||
export { TeamsUpgradeBanner } from "./TeamsUpgradeBanner";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
|
||||
@@ -4,10 +4,28 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
/** Used to prevent double charges for the same team */
|
||||
export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
|
||||
const team = await prisma.team.findUniqueOrThrow({
|
||||
where: { id: teamId },
|
||||
select: { metadata: true },
|
||||
});
|
||||
const metadata = teamMetadataSchema.parse(team.metadata);
|
||||
/** If there's no paymentId, we need to pay this team */
|
||||
if (!metadata?.paymentId) return { url: null };
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(metadata.paymentId);
|
||||
/** If there's a pending session but it isn't paid, we need to pay this team */
|
||||
if (checkoutSession.payment_status === "paid") return { url: null };
|
||||
/** If the session is already paid we return the upgrade URL so team is updated. */
|
||||
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
|
||||
};
|
||||
|
||||
export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; userId: number }) => {
|
||||
const { teamId, seats, userId } = input;
|
||||
const { url } = await checkIfTeamPaymentRequired({ teamId });
|
||||
if (url) return { url };
|
||||
const customer = await getStripeCustomerIdFromUserId(userId);
|
||||
return await stripe.checkout.sessions.create({
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer,
|
||||
mode: "subscription",
|
||||
success_url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`,
|
||||
@@ -30,6 +48,7 @@ export const purchaseTeamSubscription = async (input: { teamId: number; seats: n
|
||||
},
|
||||
},
|
||||
});
|
||||
return { url: session.url };
|
||||
};
|
||||
|
||||
export const cancelTeamSubscriptionFromStripe = async (teamId: number) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user