Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9842c18d50 | ||
|
|
68f05ce2c7 | ||
|
|
1aade4fc57 | ||
|
|
33d71f2390 | ||
|
|
44ef1f80e9 | ||
|
|
9e3e1418c2 | ||
|
|
0284ffc75f | ||
|
|
aee1b3b596 | ||
|
|
c1bc0286d1 | ||
|
|
48d1e95509 | ||
|
|
d8489a662e | ||
|
|
0fc1449dd9 | ||
|
|
d5024d1b70 | ||
|
|
b7161694a5 | ||
|
|
cc7e4b3b33 | ||
|
|
3b9653df0e | ||
|
|
83442caf86 | ||
|
|
e6f912bf0c | ||
|
|
e75f200ff7 | ||
|
|
2d30f673e3 | ||
|
|
2e83c7fbf8 | ||
|
|
1b215e4faf | ||
|
|
49429c87cb | ||
|
|
cf423dda70 | ||
|
|
6ee9de6dd8 | ||
|
|
e54bdb4c46 | ||
|
|
1455a6f603 | ||
|
|
9b980b3b85 | ||
|
|
6420218cb3 | ||
|
|
f9ca763523 | ||
|
|
c4f3dec854 | ||
|
|
ff01da540d | ||
|
|
9021486cd7 | ||
|
|
550f98a636 | ||
|
|
9a4746844f | ||
|
|
271d4319b9 | ||
|
|
215e3c93d6 | ||
|
|
670aed9fe0 | ||
|
|
8583d9b2e6 | ||
|
|
88cad2e15f | ||
|
|
1c165bd934 | ||
|
|
74a16e28f4 | ||
|
|
3e417330b5 | ||
|
|
3af31860bb | ||
|
|
f0aded92db | ||
|
|
a28325e69a | ||
|
|
f949cf1bd1 | ||
|
|
ba31c9dcb5 | ||
|
|
e5af99f572 | ||
|
|
c40ff2e6bb | ||
|
|
b1656c7900 | ||
|
|
a9b18496e0 | ||
|
|
d35ad510c9 | ||
|
+9 |
6e834aa8eb | ||
|
|
5293db29d9 | ||
|
|
e772b4203e | ||
|
|
6cc8fcae52 | ||
|
|
7e5c686e26 | ||
|
|
2c6c768145 | ||
|
|
a5fee47989 | ||
|
|
bceeee70e0 | ||
|
|
baecd923af | ||
|
|
1fe1130af1 | ||
|
|
49805054ba | ||
|
|
e2362d8f2a | ||
|
|
9453c1fc3a | ||
|
|
47e496fded | ||
|
|
0289e727ad | ||
|
|
04b5125121 | ||
|
|
368d1afe27 | ||
|
|
2069fd7248 | ||
|
|
7ed21b692e | ||
|
|
4fcdc7bf5c | ||
|
|
2a0a293f8c | ||
|
|
1b15b71a24 | ||
|
|
5d9889a90c | ||
|
|
a7ee3366eb | ||
|
|
c68e7595fe | ||
|
|
3f6be3b2c0 | ||
|
|
f1647248a2 | ||
|
|
d7db1ad303 | ||
|
|
bd48fd01b1 | ||
|
|
fabf692a08 | ||
|
|
ab62fc5d47 | ||
|
|
0c4cb2e2b0 | ||
|
|
7d77132102 | ||
|
|
50ac5f2892 | ||
|
|
fd12fcc4b8 | ||
|
+2 |
58453d0f2f | ||
|
|
44fd1b9fef | ||
|
|
0098591a56 | ||
|
|
41c1e960f8 | ||
|
|
4e49d32c61 | ||
|
+9 |
0b29c5063a | ||
|
|
95a92567cb | ||
|
+7 |
9e183e6ad1 | ||
|
|
7900f8bab5 | ||
|
|
50904d2588 | ||
|
|
6f811f4f0a | ||
|
|
d9000d0cbe | ||
|
|
ff78e66051 | ||
|
|
7c30f8ca4b | ||
|
|
0ebce77a7b | ||
|
|
4282adac4b | ||
|
|
ef611e2360 | ||
|
|
10ab86b332 | ||
|
|
f04cfef0af |
@@ -97,7 +97,7 @@ LARK_OPEN_VERIFICATION_TOKEN=""
|
||||
|
||||
# - WEB3
|
||||
# Used for the Web3 plugin
|
||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/web3/README.md
|
||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/rainbow/README.md
|
||||
ALCHEMY_API_KEY=""
|
||||
INFURA_API_KEY=""
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Add PRs to project
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-PR-to-project:
|
||||
name: Add PR to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/1
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/1
|
||||
project-url: https://github.com/orgs/calcom/projects/9
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
labeled: 🐛 bug, 🎨 needs design, 🙋🏻♂️help wanted, 🚨 urgent
|
||||
labeled: 🙋🏻♂️help wanted
|
||||
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
|
||||
Vendored
+1
-1
@@ -6,5 +6,5 @@
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"spellright.language": ["en"],
|
||||
"spellright.documentTypes": ["markdown"]
|
||||
"spellright.documentTypes": ["markdown", "typescript"]
|
||||
}
|
||||
|
||||
@@ -4,23 +4,6 @@ Contributions are what make the open source community such an amazing place to l
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
## Areas of expertise
|
||||
|
||||
### Legend
|
||||
|
||||
✅ = has knowledge
|
||||
|
||||
🥇 = is their main priority
|
||||
|
||||
⚠️ = is the only one with knowledge
|
||||
|
||||
👀 = has no knowledge but wants to be onboarded
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://dynamic-svgs.vercel.app/image.svg?dark">
|
||||
<img alt="Areas of expertise table" src="https://dynamic-svgs.vercel.app/image.svg">
|
||||
</picture>
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
|
||||
+1
-1
Submodule apps/api updated: 7e9226fabc...18e96e2a47
+1
-1
Submodule apps/console updated: 995771ebbd...8313b3b402
@@ -11,14 +11,14 @@ export type GateState = {
|
||||
type GateProps = {
|
||||
children: React.ReactNode;
|
||||
gates: Gate[];
|
||||
metadata: JSONObject;
|
||||
appData: JSONObject;
|
||||
dispatch: Dispatch<Partial<GateState>>;
|
||||
};
|
||||
|
||||
const RainbowGate = dynamic(() => import("@calcom/app-store/rainbow/components/RainbowKit"));
|
||||
|
||||
// To add a new Gate just add the gate logic to the switch statement
|
||||
const Gates: React.FC<GateProps> = ({ children, gates, metadata, dispatch }) => {
|
||||
const Gates: React.FC<GateProps> = ({ children, gates, appData, dispatch }) => {
|
||||
const [rainbowToken, setRainbowToken] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,12 +31,12 @@ const Gates: React.FC<GateProps> = ({ children, gates, metadata, dispatch }) =>
|
||||
for (const gate of gates) {
|
||||
switch (gate) {
|
||||
case "rainbow":
|
||||
if (metadata.blockchainId && metadata.smartContractAddress && !rainbowToken) {
|
||||
if (appData.blockchainId && appData.smartContractAddress && !rainbowToken) {
|
||||
gateWrappers = (
|
||||
<RainbowGate
|
||||
setToken={setRainbowToken}
|
||||
chainId={metadata.blockchainId as number}
|
||||
tokenAddress={metadata.smartContractAddress as string}>
|
||||
chainId={appData.blockchainId as number}
|
||||
tokenAddress={appData.smartContractAddress as string}>
|
||||
{gateWrappers}
|
||||
</RainbowGate>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ type AvailableTimesProps = {
|
||||
eventTypeId: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
date: Dayjs;
|
||||
date?: Dayjs;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
@@ -44,8 +44,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
|
||||
}, []);
|
||||
|
||||
if (!date) return null;
|
||||
|
||||
return (
|
||||
<div className="dark:bg-darkgray-100 mt-8 flex flex-col px-4 text-center sm:mt-0 sm:w-1/3 sm:p-5 md:-mb-5">
|
||||
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col px-4 text-center sm:mt-0 sm:p-5 md:-mb-5 md:min-w-[200px] lg:min-w-[300px]">
|
||||
<div className="mb-4 text-left text-base">
|
||||
<span className="text-bookingdarker dark:text-darkgray-800 mb-8 w-1/2 break-words font-semibold text-gray-900">
|
||||
{nameOfDay(i18n.language, Number(date.format("d")))}
|
||||
@@ -55,7 +57,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
||||
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
@@ -150,4 +152,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
AvailableTimes.displayName = "AvailableTimes";
|
||||
|
||||
export default AvailableTimes;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { FC, ReactNode } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../pages/[user]/[type]";
|
||||
import type { BookPageProps } from "../../pages/[user]/book";
|
||||
import type { DynamicAvailabilityPageProps } from "../../pages/d/[link]/[slug]";
|
||||
import type { HashLinkPageProps } from "../../pages/d/[link]/book";
|
||||
import type { AvailabilityTeamPageProps } from "../../pages/team/[slug]/[type]";
|
||||
import type { TeamBookingPageProps } from "../../pages/team/[slug]/book";
|
||||
import { AvailableEventLocations } from "./AvailableEventLocations";
|
||||
|
||||
interface Props {
|
||||
profile:
|
||||
| AvailabilityPageProps["profile"]
|
||||
| HashLinkPageProps["profile"]
|
||||
| TeamBookingPageProps["profile"]
|
||||
| BookPageProps["profile"]
|
||||
| AvailabilityTeamPageProps["profile"]
|
||||
| DynamicAvailabilityPageProps["profile"];
|
||||
eventType:
|
||||
| AvailabilityPageProps["eventType"]
|
||||
| HashLinkPageProps["eventType"]
|
||||
| TeamBookingPageProps["eventType"]
|
||||
| BookPageProps["eventType"]
|
||||
| AvailabilityTeamPageProps["eventType"]
|
||||
| DynamicAvailabilityPageProps["eventType"];
|
||||
isBookingPage?: boolean;
|
||||
children: ReactNode;
|
||||
isMobile?: boolean;
|
||||
rescheduleUid?: string;
|
||||
}
|
||||
|
||||
const BookingDescription: FC<Props> = (props) => {
|
||||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
|
||||
{eventType?.description && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div>
|
||||
<Icon.FiInfo
|
||||
className={classNames(
|
||||
"mr-[10px] ml-[2px] inline-block h-4 w-4",
|
||||
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
</div>
|
||||
)}
|
||||
{!isBookingPage && !props.rescheduleUid ? (
|
||||
<AvailableEventLocations
|
||||
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||
/>
|
||||
) : null}
|
||||
<p
|
||||
className={classNames(
|
||||
"text-sm font-medium",
|
||||
isBookingPage && "dark:text-darkgray-600 text-gray-600"
|
||||
)}>
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDescription;
|
||||
@@ -17,8 +17,8 @@ import Badge from "@calcom/ui/v2/core/Badge";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { extractRecurringDates } from "@lib/parseDate";
|
||||
|
||||
// import { extractRecurringDates } from "@lib/parseDate";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
@@ -29,7 +29,7 @@ type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringBookings?: BookingItem[];
|
||||
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
@@ -53,6 +53,16 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
@@ -61,28 +71,18 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label:
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("reject_all")
|
||||
: t("reject"),
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
onClick: () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
},
|
||||
@@ -91,10 +91,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label:
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("confirm_all")
|
||||
: t("confirm"),
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
},
|
||||
@@ -107,17 +104,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
label:
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("cancel_all_remaining")
|
||||
: t("cancel"),
|
||||
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}${
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? "?allRemainingBookings=true"
|
||||
: ""
|
||||
}`,
|
||||
href: `/cancel/${booking.uid}${isTabRecurring && isRecurring ? "?allRemainingBookings=true" : ""}`,
|
||||
icon: Icon.FiX,
|
||||
},
|
||||
{
|
||||
@@ -151,7 +141,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
];
|
||||
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
if (isTabRecurring && isRecurring) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
@@ -185,16 +175,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
|
||||
// Calculate the booking date(s) and setup recurring event data to show
|
||||
let recurringStrings: string[] = [];
|
||||
let recurringDates: Date[] = [];
|
||||
const recurringStrings: string[] = [];
|
||||
const recurringDates: Date[] = [];
|
||||
|
||||
if (booking.recurringBookings && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||
[recurringStrings, recurringDates] = extractRecurringDates(
|
||||
booking.recurringBookings,
|
||||
user?.timeZone,
|
||||
i18n
|
||||
);
|
||||
}
|
||||
// @FIXME: This is importing the RRULE library which is already heavy. Find out a more optimal way do this.
|
||||
// if (booking.recurringBookings !== undefined && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||
// [recurringStrings, recurringDates] = extractRecurringDates(booking, user?.timeZone, i18n);
|
||||
// }
|
||||
|
||||
const location = booking.location || "";
|
||||
|
||||
@@ -206,7 +193,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
||||
type: booking.eventType.id || 0,
|
||||
eventSlug: booking.eventType.slug,
|
||||
user: user?.username || "",
|
||||
username: user?.username || "",
|
||||
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||
location: location,
|
||||
@@ -219,6 +206,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const utcOffset = dayjs().tz(user?.timeZone).utcOffset();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -271,8 +261,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
{dayjs
|
||||
.utc(booking.startTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "}
|
||||
-{" "}
|
||||
{dayjs
|
||||
.utc(booking.endTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
@@ -307,8 +304,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="pr-2 text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
{dayjs
|
||||
.utc(booking.startTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "}
|
||||
-{" "}
|
||||
{dayjs
|
||||
.utc(booking.endTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,16 +407,21 @@ const RecurringBookingsTooltip = ({
|
||||
recurringDates,
|
||||
}: RecurringBookingsTooltipProps) => {
|
||||
const { t } = useLocale();
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
(booking.recurringBookings &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
|
||||
(booking.listingStatus === "recurring" ||
|
||||
booking.listingStatus === "unconfirmed" ||
|
||||
booking.listingStatus === "cancelled") && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
<p key={key} className={classNames(recurringDates[key] < now && "line-through")}>
|
||||
{aDate}
|
||||
</p>
|
||||
))}>
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCcw
|
||||
@@ -427,7 +436,9 @@ const RecurringBookingsTooltip = ({
|
||||
: getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: booking.recurringBookings.length,
|
||||
recurringCount: recurringDates.filter((date) => {
|
||||
return date >= now;
|
||||
}).length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Switch } from "@calcom/ui/v2";
|
||||
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/v2/core/TimezoneSelect";
|
||||
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
|
||||
@@ -10,9 +10,10 @@ type Props = {
|
||||
onSelectTimeZone: (selectedTimeZone: string) => void;
|
||||
onToggle24hClock: (is24hClock: boolean) => void;
|
||||
timeFormat: string;
|
||||
hideTimeFormatToggle?: boolean;
|
||||
};
|
||||
|
||||
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat }) => {
|
||||
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat, hideTimeFormatToggle }) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [is24hClock, setIs24hClock] = useState(timeFormat === "HH:mm" && true);
|
||||
const { t } = useLocale();
|
||||
@@ -27,6 +28,7 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat
|
||||
onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
}
|
||||
}, [selectedTimeZone, onSelectTimeZone]);
|
||||
|
||||
const handle24hClockToggle = (is24hClock: boolean) => {
|
||||
setIs24hClock(is24hClock);
|
||||
onToggle24hClock(is24h(is24hClock));
|
||||
@@ -36,17 +38,19 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat
|
||||
<div className="dark:border-darkgray-300 dark:bg-darkgray-200 rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
|
||||
<div className="mb-4 flex">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
|
||||
{t("am_pm")}
|
||||
</label>
|
||||
<Switch
|
||||
name="24hClock"
|
||||
label={t("24_h")}
|
||||
defaultChecked={is24hClock}
|
||||
onCheckedChange={handle24hClockToggle}
|
||||
/>
|
||||
</div>
|
||||
{!hideTimeFormatToggle && (
|
||||
<div className="ml-auto flex items-center">
|
||||
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
|
||||
{t("am_pm")}
|
||||
</label>
|
||||
<Switch
|
||||
name="24hClock"
|
||||
label={t("24_h")}
|
||||
defaultChecked={is24hClock}
|
||||
onCheckedChange={handle24hClockToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// Get router variables
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { EventType } from "@prisma/client";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
||||
import { useReducer, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
@@ -36,16 +39,14 @@ import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
|
||||
import Gates, { Gate, GateState } from "@components/Gates";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
import BookingDescription from "@components/booking/BookingDescription";
|
||||
import TimeOptions from "@components/booking/TimeOptions";
|
||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
import { AvailableEventLocations } from "../AvailableEventLocations";
|
||||
|
||||
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
||||
const router = useRouter();
|
||||
@@ -128,6 +129,11 @@ const SlotPicker = ({
|
||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||
const router = useRouter();
|
||||
const slotPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
slotPickerRef.current && autoAnimate(slotPickerRef.current);
|
||||
}, [slotPickerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
@@ -175,10 +181,8 @@ const SlotPicker = ({
|
||||
<DatePicker
|
||||
isLoading={isLoading}
|
||||
className={classNames(
|
||||
"mt-8 w-full px-4 pb-4 sm:mt-0 sm:min-w-[455px] md:px-5",
|
||||
selectedDate
|
||||
? "sm:dark:border-darkgray-200 border-gray-200 sm:w-1/2 sm:border-r sm:p-4 sm:pr-6 md:w-1/3"
|
||||
: "sm:p-4"
|
||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
||||
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||
)}
|
||||
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
@@ -193,10 +197,10 @@ const SlotPicker = ({
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
|
||||
{selectedDate && (
|
||||
<div ref={slotPickerRef}>
|
||||
<AvailableTimes
|
||||
isLoading={isLoading}
|
||||
slots={slots[selectedDate.format("YYYY-MM-DD")]}
|
||||
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
|
||||
date={selectedDate}
|
||||
timeFormat={timeFormat}
|
||||
eventTypeId={eventType.id}
|
||||
@@ -205,7 +209,7 @@ const SlotPicker = ({
|
||||
recurringCount={recurringEventCount}
|
||||
ethSignature={ethSignature}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -215,17 +219,18 @@ function TimezoneDropdown({
|
||||
onChangeTimeZone,
|
||||
timeZone,
|
||||
timeFormat,
|
||||
hideTimeFormatToggle,
|
||||
}: {
|
||||
onChangeTimeFormat: (newTimeFormat: string) => void;
|
||||
onChangeTimeZone: (newTimeZone: string) => void;
|
||||
timeZone?: string;
|
||||
timeFormat: string;
|
||||
hideTimeFormatToggle?: boolean;
|
||||
}) {
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(!!getIs24hClockFromLocalStorage());
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -261,6 +266,7 @@ function TimezoneDropdown({
|
||||
onSelectTimeZone={handleSelectTimeZone}
|
||||
onToggle24hClock={handleToggle24hClock}
|
||||
timeFormat={timeFormat}
|
||||
hideTimeFormatToggle={hideTimeFormatToggle}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
@@ -293,7 +299,14 @@ const useRouterQuery = <T extends string>(name: T) => {
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const timeFormatTotimeFormatString = (timeFormat?: number | null) => {
|
||||
if (!timeFormat) return null;
|
||||
return timeFormat === 24 ? "HH:mm" : "h:mma";
|
||||
};
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
||||
const timeFormatFromProfile = timeFormatTotimeFormatString(user?.timeFormat);
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed();
|
||||
const query = dateQuerySchema.parse(router.query);
|
||||
@@ -306,8 +319,8 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
const [timeZone, setTimeZone] = useState<string>();
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
|
||||
const [timeFormat, setTimeFormat] = useState<string>("HH:mm");
|
||||
|
||||
const [gateState, gateDispatcher] = useReducer(
|
||||
(state: GateState, newState: Partial<GateState>) => ({
|
||||
...state,
|
||||
@@ -318,11 +331,8 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAvailableTimesVisible(!!query.date);
|
||||
}, [query.date]);
|
||||
setTimeFormat(timeFormatFromProfile || detectBrowserTimeFormat);
|
||||
}, [timeFormatFromProfile]);
|
||||
|
||||
// TODO: Improve this;
|
||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||
@@ -342,14 +352,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
|
||||
// get dynamic user list here
|
||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||
// Recurring event sidebar requires more space
|
||||
const maxWidth = isAvailableTimesVisible
|
||||
? recurringEventCount
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl"
|
||||
: recurringEventCount
|
||||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
const timezoneDropdown = useMemo(
|
||||
() => (
|
||||
<TimezoneDropdown
|
||||
@@ -357,10 +360,15 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
onChangeTimeFormat={setTimeFormat}
|
||||
timeZone={timeZone}
|
||||
onChangeTimeZone={setTimeZone}
|
||||
// Currently we don't allow the user to change the timeformat when they're logged in,
|
||||
// the only way to change it is if they go to their profile.
|
||||
hideTimeFormatToggle={!!timeFormatFromProfile}
|
||||
/>
|
||||
),
|
||||
[timeZone, timeFormat]
|
||||
[timeZone, timeFormat, timeFormatFromProfile]
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
const slug = rawSlug.join("/");
|
||||
@@ -368,13 +376,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
// Define conditional gates here
|
||||
const gates = [
|
||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
|
||||
rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress
|
||||
? ("rainbow" as Gate)
|
||||
: undefined,
|
||||
];
|
||||
|
||||
return (
|
||||
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
|
||||
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
|
||||
<HeadSeo
|
||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||
@@ -385,207 +393,80 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
noindex: eventType.hidden,
|
||||
}}
|
||||
/>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? classNames(maxWidth)
|
||||
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||
"flex-col md:mx-4 lg:flex",
|
||||
shouldAlignCentrally ? "items-center" : "items-start",
|
||||
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||
)}>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent
|
||||
? ""
|
||||
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||
"border-bookinglightest overflow-hidden rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block px-4 pt-4 sm:p-8 md:hidden">
|
||||
<div>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="dark:text-darkgray-900 mt-2 gap-2">
|
||||
<p className="break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</p>
|
||||
<h1 className="text-bookingdark dark:text-darkgray-900 mb-4 break-words text-xl font-semibold">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="flex flex-col items-start space-y-3">
|
||||
{eventType?.description && (
|
||||
<div className="dark:text-darkgray-600 flex py-1 text-sm font-medium text-gray-600">
|
||||
<div>
|
||||
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
</div>
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
<div>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent
|
||||
? ""
|
||||
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||
"border-bookinglightest overflow-hidden md:rounded-md md:border",
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="overflow-hidden md:flex">
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
"min-w-full md:w-[280px] md:min-w-[280px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<p className="dark:text-darkgray-600 text-gray-600">
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{t("requires_confirmation")}
|
||||
</p>
|
||||
)}
|
||||
<AvailableEventLocations locations={eventType.locations} />
|
||||
<p className="dark:text-darkgray-600 text-gray-600">
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</div>
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<div className="dark:text-darkgray-600 text-gray-600">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="dark:text-darkgray-600 flex items-center text-gray-600">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 shrink-0 text-gray-500" />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:text-darkgray-600 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="dark:text-darkgray-600 inline text-gray-600">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</BookingDescription>
|
||||
|
||||
<div className="md:hidden">
|
||||
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 text-gray-600 dark:text-darkgray-600"
|
||||
data-testid="former_time_p_mobile">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-darkgray-600">
|
||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
||||
{typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden sm:flex">
|
||||
<div
|
||||
className={
|
||||
"sm:dark:border-darkgray-200 hidden border-gray-200 p-5 sm:border-r md:flex md:flex-col " +
|
||||
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
}>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="break-words text-sm font-medium text-gray-600 dark:text-gray-300 lg:mt-2">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl text-gray-900">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
|
||||
{eventType?.description && (
|
||||
<div className="flex">
|
||||
<div>
|
||||
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
</div>
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
{!isEmbed && (
|
||||
<div className="mt-auto hidden md:block">
|
||||
<GoBackToPreviousPage t={t} />
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
</div>
|
||||
)}
|
||||
<AvailableEventLocations locations={eventType.locations} />
|
||||
<p className="text-sm font-medium">
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
|
||||
{!isEmbed && <GoBackToPreviousPage t={t} />}
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||
@@ -598,26 +479,33 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
? ([
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: profile.weekStart /* Allows providing weekStart as number */
|
||||
}
|
||||
eventType={eventType}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
ethSignature={gateState.rainbowToken}
|
||||
/>
|
||||
</div>
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
? (["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||
profile.weekStart
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: profile.weekStart /* Allows providing weekStart as number */
|
||||
}
|
||||
eventType={eventType}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
ethSignature={gateState.rainbowToken}
|
||||
/>
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
@@ -13,13 +13,16 @@ import { ReactMultiEmail } from "react-multi-email";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import {
|
||||
locationKeyToString,
|
||||
getEventLocationValue,
|
||||
getEventLocationType,
|
||||
EventLocationType,
|
||||
getHumanReadableLocationValue,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import { LocationObject, LocationType } from "@calcom/core/location";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -49,8 +53,7 @@ import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import Gates, { Gate, GateState } from "@components/Gates";
|
||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||
import BookingDescription from "@components/booking/BookingDescription";
|
||||
|
||||
import { BookPageProps } from "../../../pages/[user]/book";
|
||||
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
@@ -97,6 +100,7 @@ const BookingPage = ({
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
@@ -130,7 +134,7 @@ const BookingPage = ({
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
user: profile.slug,
|
||||
username: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
@@ -163,7 +167,7 @@ const BookingPage = ({
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
recur: recurringEventId,
|
||||
user: profile.slug,
|
||||
username: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
@@ -409,17 +413,18 @@ const BookingPage = ({
|
||||
}
|
||||
});
|
||||
}
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
|
||||
// Define conditional gates here
|
||||
const gates = [
|
||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
|
||||
rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress
|
||||
? ("rainbow" as Gate)
|
||||
: undefined,
|
||||
];
|
||||
|
||||
return (
|
||||
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
|
||||
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid
|
||||
@@ -435,6 +440,7 @@ const BookingPage = ({
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<main
|
||||
className={classNames(
|
||||
@@ -450,48 +456,15 @@ const BookingPage = ({
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-2 break-words text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal dark:text-darkgray-900 break-words text-2xl text-gray-900 ">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-col space-y-3 lg:mt-9">
|
||||
{eventType?.description && (
|
||||
<div className="dark:text-darkgray-600 flex text-sm font-medium text-gray-600">
|
||||
<div>
|
||||
<Icon.FiInfo className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<div className="dark:text-darkgray-600 flex items-center text-sm font-medium text-gray-600">
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
</div>
|
||||
)}
|
||||
<p className="dark:text-darkgray-600 text-sm font-medium text-gray-600">
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
@@ -559,7 +532,7 @@ const BookingPage = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BookingDescription>
|
||||
</div>
|
||||
<div className="p-6 sm:w-1/2">
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
@@ -604,37 +577,48 @@ const BookingPage = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{locations.length > 1 && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-4">
|
||||
<>
|
||||
<span className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("location")}
|
||||
</span>
|
||||
{locations.map((location, i) => {
|
||||
const locationString = locationKeyToString(location);
|
||||
// TODO: Right now selectedLocationType isn't send by getSSP. Once that's available defaultChecked should work and show the location in the original booking
|
||||
const defaultChecked = rescheduleUid ? selectedLocationType === location.type : i === 0;
|
||||
if (typeof locationString !== "string") {
|
||||
// It's possible that location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<label key={i} className="block">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={!!disableLocations}
|
||||
className="location dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||
{...bookingForm.register("locationType", { required: true })}
|
||||
value={location.type}
|
||||
defaultChecked={defaultChecked}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
|
||||
{locationKeyToString(location)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{rescheduleUid ? (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{getHumanReadableLocationValue(booking?.location, t)}
|
||||
</p>
|
||||
) : (
|
||||
locations.length > 1 && (
|
||||
<>
|
||||
{locations.map((location, i) => {
|
||||
const locationString = locationKeyToString(location);
|
||||
if (!selectedLocationType) {
|
||||
bookingForm.setValue("locationType", locations[0].type);
|
||||
}
|
||||
if (typeof locationString !== "string") {
|
||||
// It's possible that location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<label key={i} className="block">
|
||||
<input
|
||||
type="radio"
|
||||
disabled={!!disableLocations}
|
||||
className="location dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||
{...bookingForm.register("locationType", { required: true })}
|
||||
value={location.type}
|
||||
defaultChecked={i === 0}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
|
||||
{locationKeyToString(location)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
{/* TODO: Change name and id ="phone" to something generic */}
|
||||
{AttendeeInput && (
|
||||
<div className="mb-4">
|
||||
@@ -712,23 +696,25 @@ const BookingPage = ({
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.BOOL && (
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
required={input.required}
|
||||
id={"custom_" + input.id}
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||
placeholder=""
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
<div className="my-6">
|
||||
<div className="flex">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
required={input.required}
|
||||
id={"custom_" + input.id}
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||
placeholder=""
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="-mt-px block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -829,6 +815,7 @@ const BookingPage = ({
|
||||
) : (
|
||||
<textarea
|
||||
{...bookingForm.register("notes")}
|
||||
required={!!eventType.metadata.additionalNotesRequired}
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Prisma, SchedulingType } from "@prisma/client";
|
||||
import { useMemo } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
select: baseEventTypeSelect,
|
||||
});
|
||||
|
||||
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||
|
||||
export type EventTypeDescriptionProps = {
|
||||
eventType: EventType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const recurringEvent = useMemo(
|
||||
() => parseRecurringEvent(eventType.recurringEvent),
|
||||
[eventType.recurringEvent]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("text-gray-600 dark:text-white", className)}>
|
||||
{eventType.description && (
|
||||
<h2 className="max-w-[280px] overflow-hidden text-ellipsis sm:max-w-[500px]">
|
||||
{eventType.description.substring(0, 100)}
|
||||
{eventType.description.length > 100 && "..."}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="mt-2 flex flex-wrap space-x-1 sm:flex-nowrap ">
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
<Icon.FiClock className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</li>
|
||||
{eventType.schedulingType ? (
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
<Icon.FiUsers className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
||||
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
|
||||
</li>
|
||||
) : (
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
<Icon.FiUser className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("1_on_1")}
|
||||
</li>
|
||||
)}
|
||||
{recurringEvent?.count && recurringEvent.count > 0 && (
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
<Icon.FiRefreshCw className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("repeats_up_to", {
|
||||
count: recurringEvent.count,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
<Icon.FiCreditCard className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</li>
|
||||
)}
|
||||
{eventType.requiresConfirmation && (
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
<Icon.FiCheckSquare className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("requires_confirmation")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeDescription;
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type RecurringEventControllerProps = {
|
||||
recurringEvent: RecurringEvent | null;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
paymentEnabled: boolean;
|
||||
onRecurringEventDefined: (value: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use component from `/apps/web/components/v2/eventtype/RecurringEventController` instead
|
||||
**/
|
||||
export default function RecurringEventController({
|
||||
recurringEvent,
|
||||
formMethods,
|
||||
paymentEnabled,
|
||||
onRecurringEventDefined,
|
||||
}: RecurringEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
||||
|
||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||
const recurringEventFreqOptions = Object.entries(Frequency)
|
||||
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
||||
.map(([key, value]) => ({
|
||||
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
|
||||
value: value.toString(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<span className="flex text-sm font-medium text-neutral-700">{t("recurring_event")}</span>
|
||||
</div>
|
||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||
{paymentEnabled ? (
|
||||
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start">
|
||||
<div className="flex h-5 items-start">
|
||||
<input
|
||||
onChange={(event) => {
|
||||
onRecurringEventDefined(event?.target.checked);
|
||||
if (!event?.target.checked) {
|
||||
formMethods.setValue("recurringEvent", null);
|
||||
setRecurringEventState(null);
|
||||
} else {
|
||||
const newVal = recurringEvent || {
|
||||
interval: 1,
|
||||
count: 12,
|
||||
freq: Frequency.WEEKLY,
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
className="text-primary-600 h-4 w-4 rounded border-gray-300"
|
||||
defaultChecked={recurringEventState !== null}
|
||||
data-testid="recurring-event-check"
|
||||
id="recurringEvent"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<label htmlFor="recurringEvent" className="text-neutral-900">
|
||||
{t("recurring_event_description")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 block min-w-0 rounded-sm text-sm"
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("max")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
defaultValue={recurringEventState.count}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
count: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="mr-2 text-neutral-900">
|
||||
{t("occurrence", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ const TableActions: FC<Props> = ({ actions }) => {
|
||||
{actions.map((action) => {
|
||||
const button = (
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
key={action.id}
|
||||
data-testid={action.id}
|
||||
href={action.href}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import Select from "react-select";
|
||||
import { OptionProps } from "react-select";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
@@ -8,6 +7,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Select } from "@calcom/ui/v2";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -27,7 +27,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
||||
{data.image && (
|
||||
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
|
||||
)}
|
||||
<p>{data.label}</p>
|
||||
<p>{`${t("add")} ${data.label}`}</p>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
@@ -35,7 +35,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
||||
) : (
|
||||
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
|
||||
<Icon.FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
|
||||
<p>{t("add_new_calendar")}...</p>
|
||||
<p>{t("install_new_calendar_app")}</p>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -63,27 +63,43 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
return (
|
||||
<Select
|
||||
name="additionalCalendar"
|
||||
placeholder={t("install_another")}
|
||||
placeholder={
|
||||
<Button className="rounded-md" StartIcon={Icon.FiPlus} color="secondary">
|
||||
{t("add")}
|
||||
</Button>
|
||||
}
|
||||
options={options}
|
||||
styles={{
|
||||
placeholder: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
color: "#3E3E3E",
|
||||
marginLeft: "3px",
|
||||
};
|
||||
},
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "6px",
|
||||
};
|
||||
},
|
||||
}}
|
||||
isSearchable={false}
|
||||
className="min-w-52 block w-full flex-1 rounded-none rounded-r-sm border-gray-300 text-sm font-medium text-gray-700"
|
||||
isLoading={isLoading}
|
||||
components={{ Option: ImageOption }}
|
||||
styles={{
|
||||
menu: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
"@media only screen and (max-width: 640px)": {
|
||||
left: "0",
|
||||
},
|
||||
width: "max-content",
|
||||
right: "0",
|
||||
}),
|
||||
control: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
padding: "0",
|
||||
border: "0",
|
||||
borderRadius: "inherit",
|
||||
}),
|
||||
dropdownIndicator: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
display: "none",
|
||||
}),
|
||||
valueContainer: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
padding: "0",
|
||||
}),
|
||||
placeholder: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
margin: "0",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -6,10 +6,9 @@ import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||
import { Alert, EmptyScreen } from "@calcom/ui/v2";
|
||||
import { Alert, Button, EmptyScreen } from "@calcom/ui/v2";
|
||||
import { List } from "@calcom/ui/v2/core/List";
|
||||
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
@@ -141,6 +140,7 @@ function CalendarList(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
|
||||
function ConnectedCalendarsList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
||||
@@ -163,6 +163,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
title={item.integration.title}
|
||||
logo={item.integration.logo}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
separate={true}
|
||||
actions={
|
||||
<div className="flex w-32 justify-end">
|
||||
<DisconnectIntegration
|
||||
@@ -173,23 +174,27 @@ function ConnectedCalendarsList(props: Props) {
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-gray-200">
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="px-4 pt-4 text-sm text-neutral-500">
|
||||
{t("toggle_calendars_conflict")}
|
||||
</p>
|
||||
<ul className="space-y-2 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</IntegrationListItem>
|
||||
) : (
|
||||
<Alert
|
||||
@@ -258,11 +263,11 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
<ShellSubHeading
|
||||
title={t("calendar")}
|
||||
subtitle={t("installed_app_calendar_description")}
|
||||
className="mb-0 flex flex-wrap items-center gap-4 md:mb-3 md:gap-0"
|
||||
className="mb-0 flex flex-wrap items-center gap-4 sm:flex-nowrap md:mb-3 md:gap-0"
|
||||
actions={
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||
{!!data.connectedCalendars.length && (
|
||||
<div className=" flex items-center">
|
||||
<div className="flex items-center">
|
||||
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ function IntegrationListItem(props: {
|
||||
children?: ReactNode;
|
||||
logo: string;
|
||||
destination?: boolean;
|
||||
separate?: boolean;
|
||||
}): JSX.Element {
|
||||
const router = useRouter();
|
||||
const { hl } = router.query;
|
||||
@@ -32,7 +33,8 @@ function IntegrationListItem(props: {
|
||||
<ListItem
|
||||
expanded={!!props.children}
|
||||
className={classNames(
|
||||
"my-0 flex-col border transition-colors duration-500 first:rounded-t-md last:rounded-b-md",
|
||||
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
||||
"my-0 flex-col border transition-colors duration-500 ",
|
||||
highlight ? "bg-yellow-100" : ""
|
||||
)}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
||||
@@ -45,7 +47,7 @@ function IntegrationListItem(props: {
|
||||
</div>
|
||||
<div>{props.actions}</div>
|
||||
</div>
|
||||
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
||||
{props.children && <div className="w-full">{props.children}</div>}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import useApp from "@calcom/lib/hooks/useApp";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { showToast } from "@calcom/ui/v2";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
|
||||
/**
|
||||
* Use this component to allow installing an app from anywhere on the app.
|
||||
* Use of this component requires you to remove custom InstallAppButtonComponent so that it can manage the redirection itself
|
||||
*/
|
||||
export default function OmniInstallAppButton({ appId, className }: { appId: string; className: string }) {
|
||||
const { t } = useLocale();
|
||||
const { data: app } = useApp(appId);
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: () => {
|
||||
//TODO: viewer.appById might be replaced with viewer.apps so that a single query needs to be invalidated.
|
||||
utils.invalidateQueries(["viewer.appById", { appId }]);
|
||||
utils.invalidateQueries(["viewer.apps", { extendsFeature: "EventType" }]);
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InstallAppButton
|
||||
type={app.type}
|
||||
isProOnly={app.isProOnly}
|
||||
wrapperClassName={classNames("[@media(max-width:260px)]:w-full", className)}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug, isOmniInstall: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
loading={mutation.isLoading}
|
||||
color="secondary"
|
||||
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
|
||||
StartIcon={Icon.FiPlus}
|
||||
{...props}>
|
||||
{t("install")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FormValues } from "pages/v2/event-types/[type]";
|
||||
import { FormValues } from "pages/event-types/[type]";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -8,7 +8,7 @@ import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import Select from "@calcom/ui/v2/core/form/Select";
|
||||
import Select from "@calcom/ui/v2/core/form/select";
|
||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { EventTypeCustomInput } from "@prisma/client/";
|
||||
import Link from "next/link";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
@@ -40,6 +40,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
const { t } = useLocale();
|
||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
const [seatsInputVisible, setSeatsInputVisible] = useState(!!eventType.seatsPerTimeSlot);
|
||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||
@@ -248,6 +249,71 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="metadata.additionalNotesRequired"
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.metadata.additionalNotesRequired}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex space-x-3 ">
|
||||
<Switch
|
||||
name="additionalNotesRequired"
|
||||
fitToHeight={true}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("require_additional_notes")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("require_additional_notes_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
defaultValue={hashedUrl}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<div className="flex space-x-3 ">
|
||||
<Switch
|
||||
name="successRedirectUrlCheck"
|
||||
fitToHeight={true}
|
||||
defaultChecked={redirectUrlVisible}
|
||||
onCheckedChange={(e) => {
|
||||
setRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("redirect_success_booking")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("redirect_url_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
{redirectUrlVisible && (
|
||||
<div className="">
|
||||
<TextField
|
||||
label={t("redirect_success_booking")}
|
||||
placeholder={t("external_redirect_url")}
|
||||
required={redirectUrlVisible}
|
||||
type="text"
|
||||
defaultValue={eventType.successRedirectUrl || ""}
|
||||
{...formMethods.register("successRedirectUrl")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="hashedLink"
|
||||
control={formMethods.control}
|
||||
@@ -335,8 +401,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
fitToHeight={true}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm font-semibold leading-none text-black">{t("offer_seats")}</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("offer_seats_description")}</p>
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("offer_seats")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("offer_seats_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,208 +1,134 @@
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import React, { useState } from "react";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
||||
import RainbowInstallForm from "@calcom/app-store/rainbow/components/RainbowInstallForm";
|
||||
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||
import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
|
||||
import { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||
import { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Alert, Button, EmptyScreen, Select, Switch, TextField } from "@calcom/ui/v2";
|
||||
import ErrorBoundary from "@calcom/ui/ErrorBoundary";
|
||||
import { Button, EmptyScreen } from "@calcom/ui/v2";
|
||||
|
||||
const AppCard = ({
|
||||
logo,
|
||||
name,
|
||||
description,
|
||||
switchOnClick,
|
||||
switchChecked,
|
||||
children,
|
||||
}: {
|
||||
logo: string;
|
||||
name: string;
|
||||
description: React.ReactNode;
|
||||
switchChecked?: boolean;
|
||||
switchOnClick?: (e: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-4 rounded-md border border-gray-200 p-8">
|
||||
<div className="flex w-full">
|
||||
<img src={logo} alt={name} className="mr-3 h-auto w-[42px] rounded-sm" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold leading-none text-black">{name}</span>
|
||||
<p className="pt-2 text-sm font-normal text-gray-600">{description}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Switch onCheckedChange={switchOnClick} checked={switchChecked} />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
|
||||
EventTypeAppCardComponentProps["eventType"];
|
||||
|
||||
export const EventAppsTab = ({
|
||||
hasPaymentIntegration,
|
||||
currency,
|
||||
function AppCardWrapper({
|
||||
app,
|
||||
eventType,
|
||||
hasGiphyIntegration,
|
||||
hasRainbowIntegration,
|
||||
}: Pick<
|
||||
EventTypeSetupInfered,
|
||||
"eventType" | "hasPaymentIntegration" | "hasGiphyIntegration" | "hasRainbowIntegration" | "currency"
|
||||
>) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [showGifSelection, setShowGifSelection] = useState(
|
||||
hasGiphyIntegration && !!eventType.metadata["giphyThankYouPage"]
|
||||
);
|
||||
const [showRainbowSection, setShowRainbowSection] = useState(
|
||||
hasRainbowIntegration &&
|
||||
!!eventType.metadata["blockchainId"] &&
|
||||
!!eventType.metadata["smartContractAddress"]
|
||||
);
|
||||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
getAppData,
|
||||
setAppData,
|
||||
}: {
|
||||
app: inferQueryOutput<"viewer.apps">[number];
|
||||
eventType: EventType;
|
||||
getAppData: GetAppData;
|
||||
setAppData: SetAppData;
|
||||
}) {
|
||||
const dirName = app.slug === "stripe" ? "stripepayment" : app.slug;
|
||||
const Component = EventTypeAddonMap[dirName as keyof typeof EventTypeAddonMap];
|
||||
|
||||
const getCurrencySymbol = (locale: string, currency: string) =>
|
||||
(0)
|
||||
.toLocaleString(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
.replace(/\d/g, "")
|
||||
.trim();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const installedApps = [hasPaymentIntegration, hasGiphyIntegration].filter(Boolean).length;
|
||||
|
||||
if (installedApps === 0) {
|
||||
return (
|
||||
<div className="pt-4 before:border-0">
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiGrid}
|
||||
headline={t("empty_installed_apps_headline")}
|
||||
description={t("empty_installed_apps_description")}
|
||||
buttonRaw={
|
||||
<Button target="_blank" color="secondary" href="/apps">
|
||||
{t("empty_installed_apps_button")}{" "}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (!Component) {
|
||||
throw new Error('No component found for "' + dirName + '"');
|
||||
}
|
||||
return (
|
||||
<div className="pt-4 before:border-0">
|
||||
{/* TODO:Strip isnt fully setup yet */}
|
||||
{hasPaymentIntegration && (
|
||||
<AppCard
|
||||
name="Stripe"
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("price", 0);
|
||||
setRequirePayment(false);
|
||||
} else {
|
||||
setRequirePayment(true);
|
||||
}
|
||||
}}
|
||||
description={
|
||||
<>
|
||||
<div className="">
|
||||
{t("require_payment")} (0.5% +{" "}
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber value={0.1} style="currency" currency={currency} />
|
||||
</IntlProvider>{" "}
|
||||
{t("commission_per_transaction")})
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
logo="/api/app-store/stripepayment/icon.svg">
|
||||
<>
|
||||
{recurringEventDefined ? (
|
||||
<Alert severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<div className="block items-center sm:flex">
|
||||
<Controller
|
||||
defaultValue={eventType.price}
|
||||
control={formMethods.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label=""
|
||||
addOnLeading={<>{getCurrencySymbol("en", currency)}</>}
|
||||
{...field}
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
type="number"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.valueAsNumber * 100);
|
||||
}}
|
||||
value={field.value > 0 ? field.value / 100 : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</AppCard>
|
||||
)}
|
||||
{hasGiphyIntegration && (
|
||||
<AppCard
|
||||
name="Giphy"
|
||||
description={t("confirmation_page_gif")}
|
||||
logo="/api/app-store/giphy/icon.svg"
|
||||
switchOnClick={(e) => {
|
||||
if (!e) {
|
||||
setShowGifSelection(false);
|
||||
formMethods.setValue("giphyThankYouPage", "");
|
||||
} else {
|
||||
setShowGifSelection(true);
|
||||
}
|
||||
}}
|
||||
switchChecked={showGifSelection}>
|
||||
{showGifSelection && (
|
||||
<SelectGifInput
|
||||
defaultValue={eventType.metadata["giphyThankYouPage"] as string}
|
||||
onChange={(url: string) => {
|
||||
formMethods.setValue("giphyThankYouPage", url);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
)}
|
||||
{hasRainbowIntegration && (
|
||||
<AppCard
|
||||
name="Rainbow"
|
||||
description={t("confirmation_page_rainbow")}
|
||||
logo="/api/app-store/rainbow/icon.svg"
|
||||
switchOnClick={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("blockchainId", 1);
|
||||
formMethods.setValue("smartContractAddress", "");
|
||||
}
|
||||
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
|
||||
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
|
||||
<Component key={app.slug} app={app} eventType={eventType} />
|
||||
</EventTypeAppContext.Provider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
setShowRainbowSection(e);
|
||||
}}
|
||||
switchChecked={showRainbowSection}>
|
||||
{showRainbowSection && (
|
||||
<RainbowInstallForm
|
||||
formMethods={formMethods}
|
||||
blockchainId={(eventType.metadata.blockchainId as number) || 1}
|
||||
smartContractAddress={(eventType.metadata.smartContractAddress as string) || ""}
|
||||
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
const { t } = useLocale();
|
||||
const { data: eventTypeApps, isLoading } = trpc.useQuery([
|
||||
"viewer.apps",
|
||||
{
|
||||
extendsFeature: "EventType",
|
||||
},
|
||||
]);
|
||||
const methods = useFormContext<FormValues>();
|
||||
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
||||
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
||||
const allAppsData = methods.watch("metadata")?.apps || {};
|
||||
|
||||
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
||||
methods.setValue("metadata", {
|
||||
...methods.getValues("metadata"),
|
||||
apps: _allAppsData,
|
||||
});
|
||||
};
|
||||
|
||||
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
|
||||
return function (key) {
|
||||
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
|
||||
if (key) {
|
||||
return appData[key as keyof typeof appData];
|
||||
}
|
||||
return appData;
|
||||
};
|
||||
};
|
||||
|
||||
const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => {
|
||||
return function (key, value) {
|
||||
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
||||
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
|
||||
const appData = allAppsDataFromForm[appId];
|
||||
setAllAppsData({
|
||||
...allAppsDataFromForm,
|
||||
[appId]: {
|
||||
...appData,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="before:border-0">
|
||||
{!isLoading && !installedApps?.length ? (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiGrid}
|
||||
headline={t("empty_installed_apps_headline")}
|
||||
description={t("empty_installed_apps_description")}
|
||||
buttonRaw={
|
||||
<Button target="_blank" color="secondary" href="/apps">
|
||||
{t("empty_installed_apps_button")}{" "}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{installedApps?.map((app) => (
|
||||
<AppCardWrapper
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{!isLoading && notInstalledApps?.length ? (
|
||||
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">Available Apps</h2>
|
||||
) : null}
|
||||
<div className="before:border-0">
|
||||
{notInstalledApps?.map((app) => (
|
||||
<AppCardWrapper
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFormContext, Controller, useWatch } from "react-hook-form";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PeriodType } from "@calcom/prisma/client";
|
||||
import { Select, Switch, Label, Input, MinutesField } from "@calcom/ui/v2";
|
||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Select, Switch, Label, Input, MinutesField, Button } from "@calcom/ui/v2";
|
||||
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
|
||||
|
||||
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType } = props;
|
||||
|
||||
const PERIOD_TYPES = [
|
||||
{
|
||||
type: "ROLLING" as const,
|
||||
@@ -154,7 +156,49 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<fieldset className="block flex-col sm:flex">
|
||||
<div className="flex space-x-3">
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<Switch
|
||||
fitToHeight={true}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("bookingLimits", {
|
||||
PER_DAY: 1,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="">
|
||||
<Label className="text-sm font-semibold leading-none text-black">
|
||||
{t("limit_booking_frequency")}
|
||||
</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("limit_booking_frequency_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 lg:ml-14">
|
||||
<BookingLimits />
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<fieldset className="block flex-col sm:flex">
|
||||
<div className="flex space-x-3">
|
||||
@@ -261,3 +305,130 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
|
||||
type BookingLimitsKey = keyof BookingLimit;
|
||||
const BookingLimits = () => {
|
||||
const { watch, setValue, control } = useFormContext<FormValues>();
|
||||
const watchBookingLimits = watch("bookingLimits");
|
||||
const animateRef = useRef(null);
|
||||
const { t } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
animateRef.current && autoAnimate(animateRef.current);
|
||||
}, [animateRef]);
|
||||
|
||||
const BOOKING_LIMIT_OPTIONS: {
|
||||
value: keyof BookingLimit;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "PER_DAY",
|
||||
label: "Per Day",
|
||||
},
|
||||
{
|
||||
value: "PER_WEEK",
|
||||
label: "Per Week",
|
||||
},
|
||||
{
|
||||
value: "PER_MONTH",
|
||||
label: "Per Month",
|
||||
},
|
||||
{
|
||||
value: "PER_YEAR",
|
||||
label: "Per Year",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const currentBookingLimits = value;
|
||||
return (
|
||||
<ul ref={animateRef}>
|
||||
{currentBookingLimits &&
|
||||
watchBookingLimits &&
|
||||
Object.entries(currentBookingLimits)
|
||||
.sort(([limitkeyA], [limitKeyB]) => {
|
||||
return (
|
||||
validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) -
|
||||
validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, bookingAmount]) => {
|
||||
const bookingLimitKey = key as BookingLimitsKey;
|
||||
return (
|
||||
<div className="mb-2 flex items-center space-x-2 text-sm" key={bookingLimitKey}>
|
||||
<Input
|
||||
id={`${bookingLimitKey}-limit`}
|
||||
type="number"
|
||||
className="mb-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||
placeholder="1"
|
||||
min={1}
|
||||
defaultValue={bookingAmount}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={BOOKING_LIMIT_OPTIONS.filter(
|
||||
(option) => !Object.keys(currentBookingLimits).includes(option.value)
|
||||
)}
|
||||
isSearchable={false}
|
||||
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
||||
onChange={(val) => {
|
||||
const current = currentBookingLimits;
|
||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||
delete current[bookingLimitKey];
|
||||
const newData = {
|
||||
...current,
|
||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||
[val?.value as BookingLimitsKey]: watchBookingLimits[bookingLimitKey],
|
||||
};
|
||||
onChange(newData);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
StartIcon={Icon.FiTrash}
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
const current = currentBookingLimits;
|
||||
delete current[key as BookingLimitsKey];
|
||||
onChange(current);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => {
|
||||
if (!currentBookingLimits || !watchBookingLimits) return;
|
||||
const currentKeys = Object.keys(watchBookingLimits);
|
||||
|
||||
const rest = Object.values(BOOKING_LIMIT_OPTIONS).filter(
|
||||
(option) => !currentKeys.includes(option.value)
|
||||
);
|
||||
if (!rest || !currentKeys) return;
|
||||
//currentBookingLimits is always defined so can be casted
|
||||
|
||||
setValue("bookingLimits", {
|
||||
...watchBookingLimits,
|
||||
[rest[0].value]: undefined,
|
||||
});
|
||||
}}>
|
||||
{t("add_limit")}
|
||||
</Button>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import { EventTypeSetupInfered } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
|
||||
import RecurringEventController from "./RecurringEventController";
|
||||
|
||||
export const EventRecurringTab = ({
|
||||
eventType,
|
||||
hasPaymentIntegration,
|
||||
}: Pick<EventTypeSetupInfered, "eventType" | "hasPaymentIntegration">) => {
|
||||
const requirePayment = eventType.price > 0;
|
||||
const [recurringEventDefined, setRecurringEventDefined] = useState(
|
||||
eventType.recurringEvent?.count !== undefined
|
||||
);
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
|
||||
const requirePayment = stripeAppData.price > 0;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<RecurringEventController
|
||||
paymentEnabled={hasPaymentIntegration && requirePayment}
|
||||
onRecurringEventDefined={setRecurringEventDefined}
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
/>
|
||||
<RecurringEventController paymentEnabled={requirePayment} recurringEvent={eventType.recurringEvent} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SchedulingType } from "@prisma/client/";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Loader } from "react-feather";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
@@ -42,6 +42,7 @@ type Props = {
|
||||
team: EventTypeSetupInfered["team"];
|
||||
disableBorder?: boolean;
|
||||
enabledAppsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
enabledWorkflowsNumber: number;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
};
|
||||
@@ -51,8 +52,9 @@ function getNavigation(props: {
|
||||
eventType: Props["eventType"];
|
||||
enabledAppsNumber: number;
|
||||
enabledWorkflowsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
}) {
|
||||
const { eventType, t, enabledAppsNumber, enabledWorkflowsNumber } = props;
|
||||
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props;
|
||||
return [
|
||||
{
|
||||
name: "event_setup_tab_title",
|
||||
@@ -88,7 +90,8 @@ function getNavigation(props: {
|
||||
name: "apps",
|
||||
href: `/event-types/${eventType.id}?tabName=apps`,
|
||||
icon: Icon.FiGrid,
|
||||
info: `${enabledAppsNumber} ${t("active")}`,
|
||||
//TODO: Handle proper translation with count handling
|
||||
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
|
||||
},
|
||||
{
|
||||
name: "workflows",
|
||||
@@ -106,6 +109,7 @@ function EventTypeSingleLayout({
|
||||
team,
|
||||
disableBorder,
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
formMethods,
|
||||
}: Props) {
|
||||
@@ -136,7 +140,13 @@ function EventTypeSingleLayout({
|
||||
|
||||
// Define tab navigation here
|
||||
const EventTypeTabs = useMemo(() => {
|
||||
const navigation = getNavigation({ t, eventType, enabledAppsNumber, enabledWorkflowsNumber });
|
||||
const navigation = getNavigation({
|
||||
t,
|
||||
eventType,
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
});
|
||||
// If there is a team put this navigation item within the tabs
|
||||
if (team)
|
||||
navigation.splice(2, 0, {
|
||||
@@ -146,7 +156,7 @@ function EventTypeSingleLayout({
|
||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||
});
|
||||
return navigation;
|
||||
}, [t, eventType, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||
|
||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
@@ -228,27 +238,29 @@ function EventTypeSingleLayout({
|
||||
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
target="_blank"
|
||||
href={permalink}
|
||||
rel="noreferrer">
|
||||
rel="noreferrer"
|
||||
className="min-w-full">
|
||||
{t("preview")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<Button color="minimal" StartIcon={Icon.FiLink}>
|
||||
{t("copy_link")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiTrash}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="min-w-full">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -11,13 +11,11 @@ import { Label, Select, Switch } from "@calcom/ui/v2";
|
||||
type RecurringEventControllerProps = {
|
||||
recurringEvent: RecurringEvent | null;
|
||||
paymentEnabled: boolean;
|
||||
onRecurringEventDefined: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export default function RecurringEventController({
|
||||
recurringEvent,
|
||||
paymentEnabled,
|
||||
onRecurringEventDefined,
|
||||
}: RecurringEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import customTemplate, { hasTemplateIntegration } from "@calcom/features/webhooks/utils/integrationTemplate";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP } from "@lib/webhooks/constants";
|
||||
|
||||
import { TWebhook } from "@components/webhook/WebhookListItem";
|
||||
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
|
||||
|
||||
/** @deprecated Moved to `packages/features/webhooks` */
|
||||
export default function WebhookDialogForm(props: {
|
||||
eventTypeId?: number;
|
||||
defaultValues?: TWebhook;
|
||||
app?: string;
|
||||
handleClose: () => void;
|
||||
webhooks: Webhook[];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const appId = props.app;
|
||||
const webhooks = props.webhooks;
|
||||
|
||||
const triggers = !appId
|
||||
? WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["core"]
|
||||
: WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP[appId as keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP];
|
||||
const {
|
||||
defaultValues = {
|
||||
id: "",
|
||||
eventTriggers: triggers,
|
||||
subscriberUrl: "",
|
||||
active: true,
|
||||
payloadTemplate: null,
|
||||
secret: null,
|
||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
|
||||
} = props;
|
||||
|
||||
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
|
||||
const [changeSecret, setChangeSecret] = useState(false);
|
||||
const [newSecret, setNewSecret] = useState("");
|
||||
const hasSecretKey = !!defaultValues.secret;
|
||||
const currentSecret = defaultValues.secret;
|
||||
|
||||
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
|
||||
return !!webhooks.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleInput = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
setNewSecret(event.currentTarget.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (changeSecret) {
|
||||
form.unregister("secret", { keepDefaultValue: false });
|
||||
}
|
||||
}, [changeSecret]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
data-testid="WebhookDialogForm"
|
||||
form={form}
|
||||
handleSubmit={async (event) => {
|
||||
if (subscriberUrlReserved(event.subscriberUrl, event.id)) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
const e = changeSecret
|
||||
? { ...event, eventTypeId: props.eventTypeId, appId }
|
||||
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId, appId };
|
||||
if (!useCustomPayloadTemplate && event.payloadTemplate) {
|
||||
event.payloadTemplate = null;
|
||||
}
|
||||
if (event.id) {
|
||||
await utils.client.mutation("viewer.webhook.edit", e);
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
} else {
|
||||
await utils.client.mutation("viewer.webhook.create", e);
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
}
|
||||
props.handleClose();
|
||||
}}
|
||||
className="space-y-4">
|
||||
<div>
|
||||
<input type="hidden" {...form.register("id")} />
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="active"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue("active", isChecked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<div>
|
||||
<TextField
|
||||
label={t("subscriber_url")}
|
||||
{...form.register("subscriberUrl")}
|
||||
required
|
||||
type="url"
|
||||
onChange={(e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
{triggers.map((key) => (
|
||||
<Controller
|
||||
key={key}
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
label={t(key.toLowerCase())}
|
||||
defaultChecked={field.value.includes(key)}
|
||||
onCheckedChange={(isChecked) => {
|
||||
const value = field.value;
|
||||
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
|
||||
|
||||
form.setValue("eventTriggers", newValue, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
{!!hasSecretKey && !changeSecret && (
|
||||
<>
|
||||
<FieldsetLegend>{t("secret")}</FieldsetLegend>
|
||||
<div className="rounded-sm bg-gray-50 p-2 text-xs text-neutral-900">
|
||||
{t("forgotten_secret_description")}
|
||||
</div>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="py-1 text-xs"
|
||||
onClick={() => {
|
||||
setChangeSecret(true);
|
||||
}}>
|
||||
{t("change_secret")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!!hasSecretKey && changeSecret && (
|
||||
<>
|
||||
<TextField
|
||||
autoComplete="off"
|
||||
label={t("secret")}
|
||||
{...form.register("secret")}
|
||||
value={newSecret}
|
||||
onChange={handleInput}
|
||||
type="text"
|
||||
placeholder={t("leave_blank_to_remove_secret")}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="py-1 text-xs"
|
||||
onClick={() => {
|
||||
setChangeSecret(false);
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!hasSecretKey && (
|
||||
<TextField autoComplete="off" label={t("secret")} {...form.register("secret")} type="text" />
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
|
||||
<div className="space-x-3 text-sm rtl:space-x-reverse">
|
||||
<label>
|
||||
<input
|
||||
className="text-neutral-900 focus:ring-neutral-500"
|
||||
type="radio"
|
||||
name="useCustomPayloadTemplate"
|
||||
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
|
||||
defaultChecked={!useCustomPayloadTemplate}
|
||||
/>{" "}
|
||||
Default
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
className="text-neutral-900 focus:ring-neutral-500"
|
||||
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
|
||||
name="useCustomPayloadTemplate"
|
||||
type="radio"
|
||||
defaultChecked={useCustomPayloadTemplate}
|
||||
/>{" "}
|
||||
Custom
|
||||
</label>
|
||||
</div>
|
||||
{useCustomPayloadTemplate && (
|
||||
<TextArea
|
||||
{...form.register("payloadTemplate")}
|
||||
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
<WebhookTestDisclosure />
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { List } from "@calcom/ui/List";
|
||||
import { ShellSubHeading } from "@calcom/ui/Shell";
|
||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
|
||||
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
|
||||
|
||||
export type WebhookListContainerType = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
eventTypeId?: number;
|
||||
appId?: string;
|
||||
};
|
||||
|
||||
/** @deprecated Moved to `packages/features/webhooks` */
|
||||
export default function WebhookListContainer(props: WebhookListContainerType) {
|
||||
const router = useRouter();
|
||||
const query = trpc.useQuery(
|
||||
["viewer.webhook.list", { eventTypeId: props.eventTypeId, appId: props.appId }],
|
||||
{
|
||||
suspense: true,
|
||||
enabled: router.isReady,
|
||||
}
|
||||
);
|
||||
const [newWebhookModal, setNewWebhookModal] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<TWebhook | null>(null);
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => (
|
||||
<div className="border-b border-gray-200 py-8 pl-2 pr-1">
|
||||
<ShellSubHeading
|
||||
className="mt-2"
|
||||
title={props.title}
|
||||
subtitle={props.subtitle}
|
||||
actions={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => setNewWebhookModal(true)}
|
||||
data-testid="new_webhook"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{data.length ? (
|
||||
<List className="mt-6">
|
||||
{data.map((item) => (
|
||||
<WebhookListItem
|
||||
key={item.id}
|
||||
webhook={item}
|
||||
onEditWebhook={() => {
|
||||
setEditing(item);
|
||||
setEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
) : null}
|
||||
|
||||
{/* New webhook dialog */}
|
||||
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
|
||||
<DialogContent>
|
||||
<WebhookDialogForm
|
||||
app={props.appId}
|
||||
webhooks={data}
|
||||
eventTypeId={props.eventTypeId}
|
||||
handleClose={() => setNewWebhookModal(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit webhook dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<WebhookDialogForm
|
||||
app={props.appId}
|
||||
key={editing.id}
|
||||
webhooks={data}
|
||||
eventTypeId={props.eventTypeId || undefined}
|
||||
handleClose={() => setEditModalOpen(false)}
|
||||
defaultValues={editing}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { ListItem } from "@calcom/ui/List";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
||||
|
||||
/** @deprecated Moved to `packages/features/webhooks` */
|
||||
export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ListItem className={classNames("flex w-full p-4", props.webhook.active ? "bg-white" : "bg-gray-100")}>
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex max-w-full flex-col truncate">
|
||||
<div className="flex space-y-1">
|
||||
<span
|
||||
className={classNames(
|
||||
"truncate text-sm",
|
||||
props.webhook.active ? "text-neutral-700" : "text-neutral-400"
|
||||
)}>
|
||||
{props.webhook.subscriberUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex">
|
||||
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
|
||||
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className={classNames(
|
||||
"w-max rounded-sm px-1 text-xs",
|
||||
props.webhook.active ? "text-grey-200 bg-gray-200" : "bg-grey-50 text-neutral-400"
|
||||
)}>
|
||||
{t(`${eventTrigger.toLowerCase()}`)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Tooltip side="top" content={t("edit_webhook")}>
|
||||
<Button
|
||||
onClick={() => props.onEditWebhook()}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiEdit2}
|
||||
className="ml-4 w-full self-center p-2"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dialog>
|
||||
<Tooltip side="top" content={t("delete_webhook")}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
size="icon"
|
||||
color="warn"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="ml-2 w-full self-center p-2"
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Tooltip>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_webhook")}
|
||||
confirmBtnText={t("confirm_delete_webhook")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() =>
|
||||
deleteWebhook.mutate({
|
||||
id: props.webhook.id,
|
||||
eventTypeId: props.webhook.eventTypeId || undefined,
|
||||
})
|
||||
}>
|
||||
{t("delete_webhook_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { InputGroupBox } from "@calcom/ui/form/fields";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
/** @deprecated Moved to `packages/features/webhooks` */
|
||||
export default function WebhookTestDisclosure() {
|
||||
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
|
||||
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
|
||||
const { t } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
|
||||
onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
|
||||
<CollapsibleTrigger type="button" className="flex w-full cursor-pointer">
|
||||
<Icon.FiChevronRight className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
|
||||
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<InputGroupBox className="space-y-0 border-0 px-0">
|
||||
<div className="flex justify-between bg-gray-50 p-2">
|
||||
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
disabled={mutation.isLoading}
|
||||
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
|
||||
{t("ping_test")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-8 border-gray-50 p-2 text-gray-500">
|
||||
{!mutation.data && <em>{t("no_data_yet")}</em>}
|
||||
{mutation.status === "success" && (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"ml-auto w-max px-2 py-1 text-xs",
|
||||
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
|
||||
)}>
|
||||
{mutation.data.ok ? t("success") : t("failed")}
|
||||
</div>
|
||||
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</InputGroupBox>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
description: true,
|
||||
customInputs: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
|
||||
@@ -39,22 +39,46 @@ export const parseRecurringDates = (
|
||||
const rule = new RRule({
|
||||
...restRecurringEvent,
|
||||
count: recurringCount,
|
||||
dtstart: dayjs(startDate).toDate(),
|
||||
dtstart: new Date(
|
||||
Date.UTC(
|
||||
dayjs.utc(startDate).get("year"),
|
||||
dayjs.utc(startDate).get("month"),
|
||||
dayjs.utc(startDate).get("date"),
|
||||
dayjs.utc(startDate).get("hour"),
|
||||
dayjs.utc(startDate).get("minute")
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
const utcOffset = dayjs(startDate).tz(timeZone).utcOffset();
|
||||
const dateStrings = rule.all().map((r) => {
|
||||
return processDate(dayjs(r).tz(timeZone), i18n);
|
||||
return processDate(dayjs.utc(r).utcOffset(utcOffset), i18n);
|
||||
});
|
||||
return [dateStrings, rule.all()];
|
||||
};
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
export const extractRecurringDates = (
|
||||
bookings: BookingItem[],
|
||||
booking: inferQueryOutput<"viewer.bookings">["bookings"][number] & {
|
||||
eventType: { recurringEvent: RecurringEvent | null };
|
||||
recurringEventId: string | null;
|
||||
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
|
||||
},
|
||||
timeZone: string | undefined,
|
||||
i18n: I18n
|
||||
): [string[], Date[]] => {
|
||||
const dateStrings = bookings.map((booking) => processDate(dayjs(booking.startTime).tz(timeZone), i18n));
|
||||
const allDates = dateStrings.map((dateString) => dayjs(dateString).tz(timeZone).toDate());
|
||||
const { count = 0, ...rest } =
|
||||
booking.eventType.recurringEvent !== null ? booking.eventType.recurringEvent : {};
|
||||
const recurringInfo = booking.recurringBookings.find(
|
||||
(val) => val.recurringEventId === booking.recurringEventId
|
||||
);
|
||||
const allDates = new RRule({
|
||||
...rest,
|
||||
count: recurringInfo?._count.recurringEventId,
|
||||
dtstart: recurringInfo?._min.startTime,
|
||||
}).all();
|
||||
const utcOffset = dayjs(recurringInfo?._min.startTime).tz(timeZone).utcOffset();
|
||||
const dateStrings = allDates.map((r) => {
|
||||
return processDate(dayjs.utc(r).utcOffset(utcOffset), i18n);
|
||||
});
|
||||
return [dateStrings, allDates];
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { WebhookTriggerEvents } from "@prisma/client";
|
||||
|
||||
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
|
||||
|
||||
export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
|
||||
core: [
|
||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
WebhookTriggerEvents.BOOKING_CREATED,
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
WebhookTriggerEvents.MEETING_ENDED,
|
||||
] as const,
|
||||
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
|
||||
};
|
||||
|
||||
export const WEBHOOK_TRIGGER_EVENTS = [
|
||||
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
|
||||
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["routing-forms"],
|
||||
] as const;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { WebhookTriggerEvents } from "@prisma/client";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export type GetSubscriberOptions = {
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
};
|
||||
|
||||
/** @deprecated use `packages/lib/webhooks/subscriptions.tsx` */
|
||||
const getWebhooks = async (options: GetSubscriberOptions) => {
|
||||
const { userId, eventTypeId } = options;
|
||||
const allWebhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
eventTypeId,
|
||||
},
|
||||
],
|
||||
AND: {
|
||||
eventTriggers: {
|
||||
has: options.triggerEvent,
|
||||
},
|
||||
active: {
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
appId: true,
|
||||
secret: true,
|
||||
},
|
||||
});
|
||||
|
||||
return allWebhooks;
|
||||
};
|
||||
|
||||
export default getWebhooks;
|
||||
@@ -5,29 +5,6 @@ import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { isIpInBanlist } from "@calcom/lib/getIP";
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
const V2_WHITELIST = [
|
||||
"/settings/admin",
|
||||
"/settings/billing",
|
||||
"/settings/developer/webhooks",
|
||||
"/settings/developer/api-keys",
|
||||
"/settings/my-account",
|
||||
"/settings/security",
|
||||
"/settings/teams",
|
||||
"/availability",
|
||||
"/bookings",
|
||||
"/event-types",
|
||||
"/workflows",
|
||||
"/apps",
|
||||
"/teams",
|
||||
"/success",
|
||||
"/auth/login",
|
||||
];
|
||||
|
||||
// For pages
|
||||
// - which has V1 versions being modified as V2
|
||||
// - Add routing_forms to keep old links working
|
||||
const V2_BLACKLIST = ["/apps/routing_forms/", "/apps/routing-forms/", "/apps/typeform/"];
|
||||
|
||||
const middleware: NextMiddleware = async (req) => {
|
||||
const url = req.nextUrl;
|
||||
|
||||
@@ -52,16 +29,6 @@ const middleware: NextMiddleware = async (req) => {
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
/** Display available V2 pages */
|
||||
if (
|
||||
!V2_BLACKLIST.some((p) => url.pathname.startsWith(p)) &&
|
||||
V2_WHITELIST.some((p) => url.pathname.startsWith(p))
|
||||
) {
|
||||
// rewrite to the current subdomain under the pages/sites folder
|
||||
url.pathname = `/v2${url.pathname}`;
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
};
|
||||
|
||||
|
||||
@@ -154,6 +154,11 @@ const nextConfig = {
|
||||
destination: "/app-store/:path*",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/auth/signup",
|
||||
destination: "/signup",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings",
|
||||
destination: "/settings/my-account/profile",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.0.4",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@hookform/error-message": "^2.0.0",
|
||||
"@hookform/resolvers": "^2.9.7",
|
||||
"@next-auth/prisma-adapter": "^1.0.4",
|
||||
"@next/bundle-analyzer": "12.1.6",
|
||||
"@next/bundle-analyzer": "^12.2.5",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
|
||||
+39
-35
@@ -6,7 +6,6 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import {
|
||||
sdkActionManager,
|
||||
@@ -26,6 +25,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { BadgeCheckIcon, Icon } from "@calcom/ui/Icon";
|
||||
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
@@ -182,7 +182,6 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
<h2 className="dark:text-darkgray-700 pr-2 text-sm font-semibold text-gray-700">
|
||||
{type.title}
|
||||
</h2>
|
||||
<p className="dark:text-darkgray-600 hidden text-sm font-normal leading-none text-gray-600 md:block">{`/${user.username}/${type.slug}`}</p>
|
||||
</div>
|
||||
<EventTypeDescription eventType={type} />
|
||||
</a>
|
||||
@@ -209,42 +208,47 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
}
|
||||
User.isThemeSupported = true;
|
||||
|
||||
const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) => {
|
||||
return await prisma.eventType.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
teamId: null,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
users: {
|
||||
some: {
|
||||
id: userId,
|
||||
const getEventTypesWithHiddenFromDB = async (userId: number) => {
|
||||
return (
|
||||
await prisma.eventType.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
teamId: null,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
users: {
|
||||
some: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
select: {
|
||||
...baseEventTypeSelect,
|
||||
metadata: true,
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
select: {
|
||||
metadata: true,
|
||||
...baseEventTypeSelect,
|
||||
},
|
||||
});
|
||||
})
|
||||
).map((eventType) => ({
|
||||
...eventType,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
@@ -310,7 +314,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
|
||||
const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id, user.plan);
|
||||
const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id);
|
||||
const dataFetchEnd = Date.now();
|
||||
if (context.query.log === "1") {
|
||||
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
|
||||
@@ -319,7 +323,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const eventTypes = eventTypesRaw.map((eventType) => ({
|
||||
...eventType,
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
}));
|
||||
|
||||
const isSingleUser = users.length === 1;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { GetStaticPaths, GetStaticPropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
@@ -9,6 +8,7 @@ import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defa
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -148,7 +148,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
//TODO: Use zodSchema to verify it instead of using Type Assertion
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: privacyFilteredLocations(locations),
|
||||
users: eventType.users.map((user) => ({
|
||||
|
||||
@@ -35,6 +35,12 @@ function toRunBeforeReactOnClient() {
|
||||
const namespace = url.searchParams.get("embed");
|
||||
return namespace;
|
||||
};
|
||||
|
||||
window.isPageOptimizedForEmbed = () => {
|
||||
// Those pages are considered optimized, which know at backend that they are rendering for embed.
|
||||
// Such pages can be shown straightaway without a loader for a better embed experience
|
||||
return location.pathname.includes("forms/");
|
||||
};
|
||||
}
|
||||
|
||||
class MyDocument extends Document<Props> {
|
||||
@@ -76,7 +82,6 @@ class MyDocument extends Document<Props> {
|
||||
/>
|
||||
</Head>
|
||||
|
||||
{/* Keep the embed hidden till parent initializes and gives it the appropriate styles */}
|
||||
<body className="dark:bg-darkgray-50 desktop-transparent bg-gray-100">
|
||||
<Main />
|
||||
<NextScript />
|
||||
@@ -86,7 +91,9 @@ class MyDocument extends Document<Props> {
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
if (isEmbed()) {
|
||||
document.body.style.display="none";
|
||||
if(!isPageOptimizedForEmbed()) {
|
||||
document.body.style.display="none";
|
||||
}
|
||||
document.body.style.background="transparent";
|
||||
}`,
|
||||
}}
|
||||
|
||||
@@ -1,915 +1,14 @@
|
||||
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
|
||||
import async from "async";
|
||||
import type { NextApiRequest } from "next";
|
||||
import { RRule } from "rrule";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
|
||||
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
|
||||
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
sendAttendeeRequestEmail,
|
||||
sendOrganizerRequestEmail,
|
||||
sendRescheduledEmails,
|
||||
sendScheduledEmails,
|
||||
sendScheduledSeatsEmails,
|
||||
} from "@calcom/emails";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { defaultResponder, getLuckyUser } from "@calcom/lib/server";
|
||||
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma, { userSelect } from "@calcom/prisma";
|
||||
import { extendedBookingCreateBody, requiredCustomInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
|
||||
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import sendPayload, { EventTypeInfo } from "@lib/webhooks/sendPayload";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
const translator = short();
|
||||
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
||||
|
||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||
type BufferedBusyTimes = BufferedBusyTime[];
|
||||
|
||||
/**
|
||||
* Refreshes a Credential with fresh data from the database.
|
||||
*
|
||||
* @param credential
|
||||
*/
|
||||
async function refreshCredential(credential: Credential): Promise<Credential> {
|
||||
const newCredential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!newCredential) {
|
||||
return credential;
|
||||
} else {
|
||||
return newCredential;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the given set of credentials.
|
||||
*
|
||||
* @param credentials
|
||||
*/
|
||||
async function refreshCredentials(credentials: Array<Credential>): Promise<Array<Credential>> {
|
||||
return await async.mapLimit(credentials, 5, refreshCredential);
|
||||
}
|
||||
|
||||
function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
|
||||
// Check for conflicts
|
||||
let t = true;
|
||||
|
||||
// Early return
|
||||
if (!Array.isArray(busyTimes) || busyTimes.length < 1) return t;
|
||||
|
||||
let i = 0;
|
||||
while (t === true && i < busyTimes.length) {
|
||||
const busyTime = busyTimes[i];
|
||||
i++;
|
||||
const startTime = dayjs(busyTime.start);
|
||||
const endTime = dayjs(busyTime.end);
|
||||
|
||||
// Check if time is between start and end times
|
||||
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
|
||||
t = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if slot end time is between start and end time
|
||||
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
|
||||
t = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if startTime is between slot
|
||||
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
|
||||
t = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
const eventType = await prisma.eventType.findUniqueOrThrow({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
customInputs: true,
|
||||
users: userSelect,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
title: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
schedulingType: true,
|
||||
description: true,
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
requiresConfirmation: true,
|
||||
userId: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
destinationCalendar: true,
|
||||
hideCalendarNotes: true,
|
||||
seatsPerTimeSlot: true,
|
||||
recurringEvent: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
locations: true,
|
||||
timeZone: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
select: {
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
days: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...eventType,
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: (eventType.locations ?? []) as LocationObject[],
|
||||
};
|
||||
};
|
||||
|
||||
async function ensureAvailableUsers(
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
|
||||
users: User[];
|
||||
},
|
||||
input: { dateFrom: string; dateTo: string }
|
||||
) {
|
||||
const availableUsers: typeof eventType.users = [];
|
||||
/** Let's start checking for availability */
|
||||
for (const user of eventType.users) {
|
||||
const { busy: bufferedBusyTimes } = await getUserAvailability(
|
||||
{
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
...input,
|
||||
},
|
||||
{ user, eventType }
|
||||
);
|
||||
|
||||
console.log("calendarBusyTimes==>>>", bufferedBusyTimes);
|
||||
|
||||
let isAvailableToBeBooked = true;
|
||||
try {
|
||||
if (eventType.recurringEvent) {
|
||||
const recurringEvent = parseRecurringEvent(eventType.recurringEvent);
|
||||
const allBookingDates = new RRule({ dtstart: new Date(input.dateFrom), ...recurringEvent }).all();
|
||||
// Go through each date for the recurring event and check if each one's availability
|
||||
// DONE: Decreased computational complexity from O(2^n) to O(n) by refactoring this loop to stop
|
||||
// running at the first unavailable time.
|
||||
let i = 0;
|
||||
while (isAvailableToBeBooked === true && i < allBookingDates.length) {
|
||||
const aDate = allBookingDates[i];
|
||||
i++;
|
||||
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, aDate, eventType.length);
|
||||
// We bail at the first false, we don't need to keep checking
|
||||
if (!isAvailableToBeBooked) break;
|
||||
}
|
||||
} else {
|
||||
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, input.dateFrom, eventType.length);
|
||||
}
|
||||
} catch {
|
||||
log.debug({
|
||||
message: "Unable set isAvailableToBeBooked. Using true. ",
|
||||
});
|
||||
}
|
||||
|
||||
if (isAvailableToBeBooked) {
|
||||
availableUsers.push(user);
|
||||
}
|
||||
}
|
||||
if (!availableUsers.length) {
|
||||
throw new Error("No available users found.");
|
||||
}
|
||||
return availableUsers;
|
||||
}
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const session = await getSession({ req });
|
||||
const currentUser = session?.user;
|
||||
|
||||
const { recurringCount, noEmail, eventTypeSlug, eventTypeId, hasHashedBookingLink, language, ...reqBody } =
|
||||
extendedBookingCreateBody.parse(req.body);
|
||||
|
||||
// handle dynamic user
|
||||
const dynamicUserList = Array.isArray(reqBody.user)
|
||||
? getGroupName(reqBody.user)
|
||||
: getUsernameList(reqBody.user);
|
||||
const tAttendees = await getTranslation(language ?? "en", "common");
|
||||
const tGuests = await getTranslation("en", "common");
|
||||
log.debug(`Booking eventType ${eventTypeId} started`);
|
||||
|
||||
const eventType =
|
||||
!eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
|
||||
|
||||
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
|
||||
|
||||
// Check if required custom inputs exist
|
||||
if (eventType.customInputs) {
|
||||
eventType.customInputs.forEach((customInput) => {
|
||||
if (customInput.required) {
|
||||
requiredCustomInputSchema.parse(
|
||||
reqBody.customInputs.find((userInput) => userInput.label === customInput.label)?.value
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let timeOutOfBounds = false;
|
||||
try {
|
||||
timeOutOfBounds = isOutOfBounds(reqBody.start, {
|
||||
periodType: eventType.periodType,
|
||||
periodDays: eventType.periodDays,
|
||||
periodEndDate: eventType.periodEndDate,
|
||||
periodStartDate: eventType.periodStartDate,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BookingDateInPastError) {
|
||||
// TODO: HttpError should not bleed through to the console.
|
||||
log.info(`Booking eventType ${eventTypeId} failed`, error);
|
||||
throw new HttpError({ statusCode: 400, message: error.message });
|
||||
}
|
||||
log.debug({
|
||||
message: "Unable set timeOutOfBounds. Using false. ",
|
||||
});
|
||||
}
|
||||
|
||||
if (timeOutOfBounds) {
|
||||
const error = {
|
||||
errorCode: "BookingTimeOutOfBounds",
|
||||
message: `EventType '${eventType.eventName}' cannot be booked at this time.`,
|
||||
};
|
||||
|
||||
throw new HttpError({ statusCode: 400, message: error.message });
|
||||
}
|
||||
|
||||
let users = !eventTypeId
|
||||
? await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: dynamicUserList,
|
||||
},
|
||||
},
|
||||
...userSelect,
|
||||
})
|
||||
: eventType.users;
|
||||
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
|
||||
if (!isDynamicAllowed && !eventTypeId) {
|
||||
throw new HttpError({
|
||||
message: "Some of the users in this group do not allow dynamic booking",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// If this event was pre-relationship migration
|
||||
// TODO: Establish whether this is dead code.
|
||||
if (!users.length && eventType.userId) {
|
||||
const eventTypeUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: eventType.userId,
|
||||
},
|
||||
...userSelect,
|
||||
});
|
||||
if (!eventTypeUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
|
||||
users.push(eventTypeUser);
|
||||
}
|
||||
|
||||
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
|
||||
|
||||
if (!eventType.seatsPerTimeSlot) {
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
{
|
||||
...eventType,
|
||||
users,
|
||||
},
|
||||
{
|
||||
dateFrom: reqBody.start,
|
||||
dateTo: reqBody.end,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an if conditional if there are no seats on the event type
|
||||
// Assign to only one user when ROUND_ROBIN
|
||||
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })];
|
||||
} else {
|
||||
// excluding ROUND_ROBIN, all users have availability required.
|
||||
if (availableUsers.length !== users.length) {
|
||||
throw new Error("Some users are unavailable for booking.");
|
||||
}
|
||||
users = availableUsers;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("available users", users);
|
||||
|
||||
// @TODO: use the returned address somewhere in booking creation?
|
||||
// const address: string | undefined = await ...
|
||||
await handleEthSignature(eventType.metadata, reqBody.ethSignature);
|
||||
|
||||
const [organizerUser] = users;
|
||||
const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common");
|
||||
|
||||
const invitee = [
|
||||
{
|
||||
email: reqBody.email,
|
||||
name: reqBody.name,
|
||||
timeZone: reqBody.timeZone,
|
||||
language: { translate: tAttendees, locale: language ?? "en" },
|
||||
},
|
||||
];
|
||||
const guests = (reqBody.guests || []).map((guest) => ({
|
||||
email: guest,
|
||||
name: "",
|
||||
timeZone: reqBody.timeZone,
|
||||
language: { translate: tGuests, locale: "en" },
|
||||
}));
|
||||
|
||||
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
|
||||
const bookingLocation = getLocationValueForDB(reqBody.location, eventType.locations);
|
||||
console.log(bookingLocation, reqBody.location, eventType.locations);
|
||||
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
|
||||
|
||||
const teamMemberPromises =
|
||||
eventType.schedulingType === SchedulingType.COLLECTIVE
|
||||
? users.slice(1).map(async function (user) {
|
||||
return {
|
||||
email: user.email || "",
|
||||
name: user.name || "",
|
||||
timeZone: user.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(user.locale ?? "en", "common"),
|
||||
locale: user.locale ?? "en",
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const teamMembers = await Promise.all(teamMemberPromises);
|
||||
|
||||
const attendeesList = [...invitee, ...guests, ...teamMembers];
|
||||
|
||||
const eventNameObject = {
|
||||
attendeeName: reqBody.name || "Nameless",
|
||||
eventType: eventType.title,
|
||||
eventName: eventType.eventName,
|
||||
host: organizerUser.name || "Nameless",
|
||||
location: bookingLocation,
|
||||
t: tOrganizer,
|
||||
};
|
||||
|
||||
const additionalNotes = reqBody.notes;
|
||||
|
||||
let evt: CalendarEvent = {
|
||||
type: eventType.title,
|
||||
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
|
||||
description: eventType.description,
|
||||
additionalNotes,
|
||||
customInputs,
|
||||
startTime: dayjs(reqBody.start).utc().format(),
|
||||
endTime: dayjs(reqBody.end).utc().format(),
|
||||
organizer: {
|
||||
name: organizerUser.name || "Nameless",
|
||||
email: organizerUser.email || "Email-less",
|
||||
timeZone: organizerUser.timeZone,
|
||||
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: bookingLocation, // Will be processed by the EventManager later.
|
||||
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
|
||||
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
|
||||
hideCalendarNotes: eventType.hideCalendarNotes,
|
||||
requiresConfirmation: eventType.requiresConfirmation ?? false,
|
||||
eventTypeId: eventType.id,
|
||||
};
|
||||
|
||||
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
|
||||
if (reqBody.bookingUid) {
|
||||
if (!eventType.seatsPerTimeSlot)
|
||||
throw new HttpError({ statusCode: 404, message: "Event type does not have seats" });
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: reqBody.bookingUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attendees: true,
|
||||
userId: true,
|
||||
references: {
|
||||
select: {
|
||||
type: true,
|
||||
uid: true,
|
||||
meetingId: true,
|
||||
meetingPassword: true,
|
||||
meetingUrl: true,
|
||||
externalCalendarId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" });
|
||||
|
||||
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
|
||||
const bookingAttendees = booking.attendees.map((attendee) => {
|
||||
return { ...attendee, language: { translate: tAttendees, locale: language ?? "en" } };
|
||||
});
|
||||
|
||||
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
|
||||
|
||||
if (eventType.seatsPerTimeSlot <= booking.attendees.length)
|
||||
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
|
||||
|
||||
if (booking.attendees.some((attendee) => attendee.email === invitee[0].email))
|
||||
throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" });
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: reqBody.bookingUid,
|
||||
},
|
||||
data: {
|
||||
attendees: {
|
||||
create: {
|
||||
email: invitee[0].email,
|
||||
name: invitee[0].name,
|
||||
timeZone: invitee[0].timeZone,
|
||||
locale: invitee[0].language.locale,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newSeat = booking.attendees.length !== 0;
|
||||
|
||||
await sendScheduledSeatsEmails(evt, invitee[0], newSeat);
|
||||
|
||||
const credentials = await refreshCredentials(organizerUser.credentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
await eventManager.updateCalendarAttendees(evt, booking);
|
||||
|
||||
req.statusCode = 201;
|
||||
return booking;
|
||||
}
|
||||
|
||||
if (reqBody.customInputs.length > 0) {
|
||||
reqBody.customInputs.forEach(({ label, value }) => {
|
||||
customInputs[label] = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
|
||||
evt.team = {
|
||||
members: users.map((user) => user.name || user.username || "Nameless"),
|
||||
name: eventType.team?.name || "Nameless",
|
||||
}; // used for invitee emails
|
||||
}
|
||||
|
||||
if (reqBody.recurringEventId && eventType.recurringEvent) {
|
||||
// Overriding the recurring event configuration count to be the actual number of events booked for
|
||||
// the recurring event (equal or less than recurring event configuration count)
|
||||
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
|
||||
evt.recurringEvent = eventType.recurringEvent;
|
||||
}
|
||||
|
||||
// Initialize EventManager with credentials
|
||||
const rescheduleUid = reqBody.rescheduleUid;
|
||||
async function getOriginalRescheduledBooking(uid: string) {
|
||||
return prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
status: {
|
||||
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
|
||||
let originalRescheduledBooking: BookingType = null;
|
||||
if (rescheduleUid) {
|
||||
originalRescheduledBooking = await getOriginalRescheduledBooking(rescheduleUid);
|
||||
}
|
||||
|
||||
async function createBooking() {
|
||||
if (originalRescheduledBooking) {
|
||||
evt.title = originalRescheduledBooking?.title || evt.title;
|
||||
evt.description = originalRescheduledBooking?.description || evt.additionalNotes;
|
||||
evt.location = originalRescheduledBooking?.location;
|
||||
}
|
||||
|
||||
const eventTypeRel = !eventTypeId
|
||||
? {}
|
||||
: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
};
|
||||
|
||||
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
|
||||
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
|
||||
|
||||
// If the user is not the owner of the event, new booking should be always pending.
|
||||
// Otherwise, an owner rescheduling should be always accepted.
|
||||
const userReschedulingIsOwner = currentUser
|
||||
? originalRescheduledBooking?.user?.id === currentUser.id
|
||||
: false;
|
||||
const isConfirmedByDefault =
|
||||
(!eventType.requiresConfirmation && !eventType.price) || userReschedulingIsOwner;
|
||||
const newBookingData: Prisma.BookingCreateInput = {
|
||||
uid,
|
||||
title: evt.title,
|
||||
startTime: dayjs.utc(evt.startTime).toDate(),
|
||||
endTime: dayjs.utc(evt.endTime).toDate(),
|
||||
description: evt.additionalNotes,
|
||||
customInputs: isPrismaObjOrUndefined(evt.customInputs),
|
||||
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
|
||||
location: evt.location,
|
||||
eventType: eventTypeRel,
|
||||
smsReminderNumber: reqBody.smsReminderNumber,
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: evt.attendees.map((attendee) => {
|
||||
//if attendee is team member, it should fetch their locale not booker's locale
|
||||
//perhaps make email fetch request to see if his locale is stored, else
|
||||
const retObj = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
locale: attendee.language.locale,
|
||||
};
|
||||
return retObj;
|
||||
}),
|
||||
},
|
||||
},
|
||||
dynamicEventSlugRef,
|
||||
dynamicGroupSlugRef,
|
||||
user: {
|
||||
connect: {
|
||||
id: organizerUser.id,
|
||||
},
|
||||
},
|
||||
destinationCalendar: evt.destinationCalendar
|
||||
? {
|
||||
connect: { id: evt.destinationCalendar.id },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
if (reqBody.recurringEventId) {
|
||||
newBookingData.recurringEventId = reqBody.recurringEventId;
|
||||
}
|
||||
if (originalRescheduledBooking) {
|
||||
newBookingData["paid"] = originalRescheduledBooking.paid;
|
||||
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
|
||||
if (newBookingData.attendees?.createMany?.data) {
|
||||
newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees;
|
||||
}
|
||||
if (originalRescheduledBooking.recurringEventId) {
|
||||
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
|
||||
}
|
||||
}
|
||||
const createBookingObj = {
|
||||
include: {
|
||||
user: {
|
||||
select: { email: true, name: true, timeZone: true },
|
||||
},
|
||||
attendees: true,
|
||||
payment: true,
|
||||
},
|
||||
data: newBookingData,
|
||||
};
|
||||
|
||||
if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
|
||||
const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
|
||||
|
||||
if (bookingPayment) {
|
||||
createBookingObj.data.payment = {
|
||||
connect: { id: bookingPayment.id },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof eventType.price === "number" && eventType.price > 0) {
|
||||
/* Validate if there is any stripe_payment credential for this user */
|
||||
/* note: removes custom error message about stripe */
|
||||
await prisma.credential.findFirstOrThrow({
|
||||
where: {
|
||||
type: "stripe_payment",
|
||||
userId: organizerUser.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.booking.create(createBookingObj);
|
||||
}
|
||||
|
||||
let results: EventResult<AdditionalInformation>[] = [];
|
||||
let referencesToCreate: PartialReference[] = [];
|
||||
|
||||
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
|
||||
let booking: Booking | null = null;
|
||||
try {
|
||||
booking = await createBooking();
|
||||
// Sync Services
|
||||
await syncServicesUpdateWebUser(
|
||||
currentUser &&
|
||||
(await prisma.user.findFirst({
|
||||
where: { id: currentUser.id },
|
||||
select: { id: true, email: true, name: true, plan: true, username: true, createdDate: true },
|
||||
}))
|
||||
);
|
||||
evt.uid = booking?.uid ?? null;
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
|
||||
if (err.code === "P2002") {
|
||||
throw new HttpError({ statusCode: 409, message: "booking.conflict" });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
|
||||
const credentials = await refreshCredentials(organizerUser.credentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
|
||||
if (originalRescheduledBooking?.uid) {
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const updateManager = await eventManager.reschedule(
|
||||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
booking?.id,
|
||||
reqBody.rescheduleReason
|
||||
);
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
|
||||
results = updateManager.results;
|
||||
referencesToCreate = updateManager.referencesToCreate;
|
||||
if (results.length > 0 && results.some((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
|
||||
log.error(`Booking ${organizerUser.name} failed`, error, results);
|
||||
} else {
|
||||
const metadata: AdditionalInformation = {};
|
||||
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
|
||||
? results[0].updatedEvent
|
||||
: [results[0].updatedEvent];
|
||||
if (updatedEvent) {
|
||||
metadata.hangoutLink = updatedEvent.hangoutLink;
|
||||
metadata.conferenceData = updatedEvent.conferenceData;
|
||||
metadata.entryPoints = updatedEvent.entryPoints;
|
||||
}
|
||||
}
|
||||
|
||||
if (noEmail !== true) {
|
||||
await sendRescheduledEmails({
|
||||
...evt,
|
||||
additionalInformation: metadata,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: reqBody.rescheduleReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||
// Create a booking
|
||||
} else if (!eventType.requiresConfirmation && !eventType.price) {
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const createManager = await eventManager.create(evt);
|
||||
|
||||
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
|
||||
results = createManager.results;
|
||||
referencesToCreate = createManager.referencesToCreate;
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingCreatingMeetingFailed",
|
||||
message: "Booking failed",
|
||||
};
|
||||
|
||||
log.error(`Booking ${organizerUser.username} failed`, error, results);
|
||||
} else {
|
||||
const metadata: AdditionalInformation = {};
|
||||
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
|
||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
if (noEmail !== true) {
|
||||
await sendScheduledEmails({
|
||||
...evt,
|
||||
additionalInformation: metadata,
|
||||
additionalNotes,
|
||||
customInputs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
|
||||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
||||
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isNaN(eventType.price) &&
|
||||
eventType.price > 0 &&
|
||||
!originalRescheduledBooking?.paid &&
|
||||
!!booking
|
||||
) {
|
||||
const [firstStripeCredential] = organizerUser.credentials.filter((cred) => cred.type == "stripe_payment");
|
||||
|
||||
if (!firstStripeCredential)
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
|
||||
|
||||
if (!booking.user) booking.user = organizerUser;
|
||||
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
|
||||
|
||||
req.statusCode = 201;
|
||||
return { ...booking, message: "Payment required", paymentUid: payment.uid };
|
||||
}
|
||||
|
||||
log.debug(`Booking ${organizerUser.username} completed`);
|
||||
|
||||
const eventTrigger: WebhookTriggerEvents = rescheduleUid
|
||||
? WebhookTriggerEvents.BOOKING_RESCHEDULED
|
||||
: WebhookTriggerEvents.BOOKING_CREATED;
|
||||
const subscriberOptions = {
|
||||
userId: organizerUser.id,
|
||||
eventTypeId,
|
||||
triggerEvent: eventTrigger,
|
||||
};
|
||||
|
||||
const subscriberOptionsMeetingEnded = {
|
||||
userId: organizerUser.id,
|
||||
eventTypeId,
|
||||
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
||||
};
|
||||
|
||||
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
||||
|
||||
subscribersMeetingEnded.forEach((subscriber) => {
|
||||
if (rescheduleUid && originalRescheduledBooking) {
|
||||
cancelScheduledJobs(originalRescheduledBooking);
|
||||
}
|
||||
if (booking && booking.status === BookingStatus.ACCEPTED) {
|
||||
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
|
||||
}
|
||||
});
|
||||
|
||||
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
|
||||
const subscribers = await getWebhooks(subscriberOptions);
|
||||
console.log("evt:", {
|
||||
...evt,
|
||||
metadata: reqBody.metadata,
|
||||
});
|
||||
const bookingId = booking?.id;
|
||||
|
||||
const eventTypeInfo: EventTypeInfo = {
|
||||
eventTitle: eventType.title,
|
||||
eventDescription: eventType.description,
|
||||
requiresConfirmation: eventType.requiresConfirmation || null,
|
||||
price: eventType.price,
|
||||
currency: eventType.currency,
|
||||
length: eventType.length,
|
||||
};
|
||||
|
||||
const promises = subscribers.map((sub) =>
|
||||
sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
bookingId,
|
||||
rescheduleUid,
|
||||
metadata: reqBody.metadata,
|
||||
eventTypeId,
|
||||
status: eventType.requiresConfirmation ? "PENDING" : "ACCEPTED",
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
// Avoid passing referencesToCreate with id unique constrain values
|
||||
// refresh hashed link if used
|
||||
const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`;
|
||||
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
|
||||
|
||||
if (hasHashedBookingLink) {
|
||||
await prisma.hashedLink.update({
|
||||
where: {
|
||||
link: reqBody.hashedLink as string,
|
||||
},
|
||||
data: {
|
||||
link: hashedUid,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: booking.uid,
|
||||
},
|
||||
data: {
|
||||
references: {
|
||||
createMany: {
|
||||
data: referencesToCreate,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await scheduleWorkflowReminders(
|
||||
eventType.workflows,
|
||||
reqBody.smsReminderNumber as string | null,
|
||||
evt,
|
||||
evt.requiresConfirmation || false,
|
||||
rescheduleUid ? true : false
|
||||
);
|
||||
// booking successful
|
||||
req.statusCode = 201;
|
||||
/* To mimic API behavior */
|
||||
req.userId = session?.user?.id;
|
||||
const booking = await handleNewBooking(req);
|
||||
return booking;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,13 @@ import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/direc
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendRequestRescheduleEmail } from "@calcom/emails";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import sendPayload from "@lib/webhooks/sendPayload";
|
||||
import getWebhooks from "@lib/webhooks/subscriptions";
|
||||
|
||||
export type RescheduleResponse = Booking & {
|
||||
attendees: Attendee[];
|
||||
};
|
||||
|
||||
@@ -20,16 +20,15 @@ import { sendCancelledEmails } from "@calcom/emails";
|
||||
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import sendPayload, { EventTypeInfo } from "@lib/webhooks/sendPayload";
|
||||
import getWebhooks from "@lib/webhooks/subscriptions";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
const bodySchema = z.object({
|
||||
|
||||
@@ -10,11 +10,9 @@ import path from "path";
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import useMediaQuery from "@lib/hooks/useMediaQuery";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import App from "@components/App";
|
||||
import Slider from "@components/apps/Slider";
|
||||
import App from "@components/v2/apps/App";
|
||||
|
||||
const components = {
|
||||
a: ({ href = "", ...otherProps }: JSX.IntrinsicElements["a"]) => (
|
||||
@@ -25,42 +23,11 @@ const components = {
|
||||
img: ({ src = "", alt = "", placeholder, ...rest }: JSX.IntrinsicElements["img"]) => (
|
||||
<Image src={src} alt={alt} {...rest} />
|
||||
),
|
||||
Slider: ({ items }: { items: string[] }) => {
|
||||
const isTabletAndUp = useMediaQuery("(min-width: 960px)");
|
||||
return (
|
||||
<Slider<string>
|
||||
items={items}
|
||||
title="Screenshots"
|
||||
options={{
|
||||
perView: 1,
|
||||
}}
|
||||
renderItem={(item) =>
|
||||
isTabletAndUp ? (
|
||||
<Image
|
||||
src={item}
|
||||
alt=""
|
||||
loading="eager"
|
||||
layout="fixed"
|
||||
objectFit="contain"
|
||||
objectPosition="center center"
|
||||
width={573}
|
||||
height={382}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={item}
|
||||
alt=""
|
||||
layout="responsive"
|
||||
objectFit="contain"
|
||||
objectPosition="center center"
|
||||
width={573}
|
||||
height={382}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// @TODO: In v2 the slider isn't shown anymore. However, to ensure the v1 pages keep
|
||||
// working, this component is still rendered in the MDX content. To skip them in the v2
|
||||
// content we have to render null here. In v2 the gallery is shown by directly
|
||||
// using the `items` property from the MDX's meta data.
|
||||
Slider: () => <></>,
|
||||
};
|
||||
|
||||
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
|
||||
@@ -68,6 +35,8 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
|
||||
<App
|
||||
name={data.name}
|
||||
isGlobal={data.isGlobal}
|
||||
slug={data.slug}
|
||||
variant={data.variant}
|
||||
type={data.type}
|
||||
logo={data.logo}
|
||||
categories={data.categories ?? [data.category]}
|
||||
@@ -80,6 +49,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
|
||||
email={data.email}
|
||||
licenseRequired={data.licenseRequired}
|
||||
isProOnly={data.isProOnly}
|
||||
images={source?.scope?.items as string[] | undefined}
|
||||
// tos="https://zoom.us/terms"
|
||||
// privacy="https://zoom.us/privacy"
|
||||
body={<MDXRemote {...source} components={components} />}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { GetStaticPaths, InferGetStaticPropsType } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
|
||||
import Loader from "@calcom/ui/Loader";
|
||||
import { AppSetupPage } from "@calcom/app-store/_pages/v2/setup";
|
||||
|
||||
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const router = useRouter();
|
||||
@@ -12,11 +11,7 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
@@ -28,7 +23,7 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
||||
});
|
||||
}
|
||||
|
||||
return <AppSetupPage slug={slug} {...props} />;
|
||||
return <AppSetupPage slug={`${slug}-V2`} {...props} />;
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
|
||||
@@ -7,31 +7,40 @@ import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
|
||||
import AppCard from "@components/apps/AppCard";
|
||||
import { SkeletonText } from "@calcom/ui/v2";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import AppCard from "@calcom/ui/v2/core/apps/AppCard";
|
||||
|
||||
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const { t } = useLocale();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const router = useRouter();
|
||||
const { category } = router.query;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Shell isPublic large>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="mb-10 bg-gray-50 px-4 pb-2">
|
||||
<Shell
|
||||
isPublic
|
||||
backPath="/apps"
|
||||
heading={
|
||||
<>
|
||||
<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 className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
|
||||
{isLocaleReady ? t("app_store") : <SkeletonText className="h-4 w-24" />}{" "}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{category && (
|
||||
<span className="gap-1 text-gray-600">
|
||||
<span> / </span>
|
||||
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
large>
|
||||
<div className="mb-16">
|
||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">All {router.query.category} apps</h2>
|
||||
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{apps.map((app) => {
|
||||
return <AppCard key={app.name} app={app} />;
|
||||
return <AppCard key={app.slug} app={app} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,25 +5,39 @@ import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
|
||||
import AppStoreCategories from "@components/apps/Categories";
|
||||
import { SkeletonText } from "@calcom/ui/v2";
|
||||
|
||||
export default function Apps({ categories }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const { t } = useLocale();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell isPublic large>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="mb-10 bg-gray-50 px-4 pb-2">
|
||||
<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>
|
||||
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
|
||||
<Link href="/apps">
|
||||
<a className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
|
||||
<Icon.FiArrowLeft className="h-4 w-4" />
|
||||
{isLocaleReady ? t("app_store") : <SkeletonText className="h-6 w-24" />}{" "}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-16">
|
||||
<AppStoreCategories categories={categories} />
|
||||
<div className="grid h-auto w-full grid-cols-5 gap-3">
|
||||
{categories.map((category) => (
|
||||
<Link key={category.name} href={"/apps/categories/" + category.name}>
|
||||
<a
|
||||
data-testid={`app-store-category-${category.name}`}
|
||||
className="relative flex rounded-sm bg-gray-100 px-6 py-4 sm:block">
|
||||
<div className="self-center">
|
||||
<h3 className="font-medium capitalize">{category.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("number_apps", { count: category.count })}{" "}
|
||||
<Icon.FiArrowRight className="inline-block h-4 w-4" />
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import { InferGetStaticPropsType } from "next";
|
||||
import { InferGetStaticPropsType, NextPageContext } from "next";
|
||||
|
||||
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import type { AppCategories } from "@calcom/prisma/client";
|
||||
import AllApps from "@calcom/ui/v2/core/apps/AllApps";
|
||||
import AppStoreCategories from "@calcom/ui/v2/core/apps/Categories";
|
||||
import TrendingAppsSlider from "@calcom/ui/v2/core/apps/TrendingAppsSlider";
|
||||
import AppsLayout from "@calcom/ui/v2/core/layouts/AppsLayout";
|
||||
|
||||
import AppsShell from "@components/AppsShell";
|
||||
import AllApps from "@components/apps/AllApps";
|
||||
import AppStoreCategories from "@components/apps/Categories";
|
||||
import TrendingAppsSlider from "@components/apps/TrendingAppsSlider";
|
||||
|
||||
export default function Apps({ appStore, categories }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function Apps({ appStore, categories }: InferGetStaticPropsType<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell heading={t("app_store")} subtitle={t("app_store_description")} large isPublic>
|
||||
<AppsShell>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<TrendingAppsSlider items={appStore} />
|
||||
<AllApps apps={appStore} />
|
||||
</AppsShell>
|
||||
</Shell>
|
||||
<AppsLayout isPublic heading={t("app_store")} subtitle={t("app_store_description")}>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<TrendingAppsSlider items={appStore} />
|
||||
<AllApps apps={appStore} />
|
||||
</AppsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const appStore = await getAppRegistry();
|
||||
export const getServerSideProps = async (context: NextPageContext) => {
|
||||
const session = await getSession(context);
|
||||
|
||||
const categoryQuery = await prisma.app.findMany({
|
||||
select: {
|
||||
categories: true,
|
||||
},
|
||||
});
|
||||
let appStore;
|
||||
if (session?.user?.id) {
|
||||
appStore = await getAppRegistryWithCredentials(session.user.id);
|
||||
} else {
|
||||
appStore = await getAppRegistry();
|
||||
}
|
||||
|
||||
const categoryQuery = appStore.map(({ categories }) => ({
|
||||
categories: categories || [],
|
||||
}));
|
||||
const categories = categoryQuery.reduce((c, app) => {
|
||||
for (const category of app.categories) {
|
||||
c[category] = c[category] ? c[category] + 1 : 1;
|
||||
@@ -40,7 +42,14 @@ export const getStaticProps = async () => {
|
||||
}, {} as Record<string, number>);
|
||||
return {
|
||||
props: {
|
||||
categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
|
||||
categories: Object.entries(categories)
|
||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||
name: name as AppCategories,
|
||||
count,
|
||||
}))
|
||||
.sort(function (a, b) {
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { List } from "@calcom/ui/List";
|
||||
import Shell, { ShellSubHeading } from "@calcom/ui/Shell";
|
||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import AppsShell from "@components/AppsShell";
|
||||
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||
import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
||||
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||
|
||||
function ConnectOrDisconnectIntegrationButton(props: {
|
||||
credentialIds: number[];
|
||||
type: App["type"];
|
||||
isGlobal?: boolean;
|
||||
installed?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [credentialId] = props.credentialIds;
|
||||
const type = props.type;
|
||||
const utils = trpc.useContext();
|
||||
const handleOpenChange = () => {
|
||||
utils.invalidateQueries(["viewer.integrations"]);
|
||||
};
|
||||
|
||||
if (credentialId) {
|
||||
if (type === "stripe_payment") {
|
||||
return (
|
||||
<DisconnectIntegration
|
||||
id={credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DisconnectIntegration
|
||||
id={credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!props.installed) {
|
||||
return (
|
||||
<div className="flex items-center truncate">
|
||||
<Alert severity="warning" title={t("not_installed")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/** We don't need to "Connect", just show that it's installed */
|
||||
if (props.isGlobal) {
|
||||
return (
|
||||
<div className="truncate px-3 py-2">
|
||||
<h3 className="text-sm font-medium text-gray-700">{t("installed")}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<InstallAppButton
|
||||
type={props.type}
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
onChanged={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationsContainerProps {
|
||||
variant: App["variant"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const IntegrationsContainer = ({ variant, className = "" }: IntegrationsContainerProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { variant, onlyInstalled: true }]);
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader className={className} />}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<>
|
||||
{data.items.length > 0 && (
|
||||
<div className={className}>
|
||||
<ShellSubHeading
|
||||
title={
|
||||
<SubHeadingTitleWithConnections title={t(variant)} numConnections={data.items.length} />
|
||||
}
|
||||
/>
|
||||
<List>
|
||||
{data.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
name={item.name}
|
||||
slug={item.slug}
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
logo={item.logo}
|
||||
description={item.description}
|
||||
actions={
|
||||
<ConnectOrDisconnectIntegrationButton
|
||||
credentialIds={item.credentialIds}
|
||||
type={item.type}
|
||||
isGlobal={item.isGlobal}
|
||||
installed
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Server side rendering
|
||||
export async function getServerSideProps(ctx: AppGetServerSidePropsContext) {
|
||||
// get return-to cookie and redirect if needed
|
||||
const { cookies } = ctx.req;
|
||||
if (cookies && cookies["return-to"]) {
|
||||
const returnTo = cookies["return-to"];
|
||||
if (returnTo) {
|
||||
ctx.res.setHeader("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
||||
return {
|
||||
redirect: {
|
||||
destination: `${returnTo}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { onlyInstalled: true }]);
|
||||
return (
|
||||
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")} large>
|
||||
<AppsShell>
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
return data.items.length > 0 ? (
|
||||
<>
|
||||
<IntegrationsContainer variant="conferencing" />
|
||||
<CalendarListContainer />
|
||||
<IntegrationsContainer variant="payment" className="mt-8" />
|
||||
<IntegrationsContainer variant="other" className="mt-8" />
|
||||
<IntegrationsContainer variant="web3" className="mt-8" />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiGrid}
|
||||
headline={t("empty_installed_apps_headline")}
|
||||
description={
|
||||
<>
|
||||
<span className="mb-6 block">{t("empty_installed_apps_description")}</span>
|
||||
<Button href="/apps" EndIcon={Icon.FiArrowRight}>
|
||||
{t("empty_installed_apps_button")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AppsShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
+30
-16
@@ -1,6 +1,7 @@
|
||||
import { InferGetServerSidePropsType } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -85,13 +86,18 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
||||
}
|
||||
|
||||
interface IntegrationsContainerProps {
|
||||
variant?: "calendar" | "conferencing" | "payment";
|
||||
exclude?: App["variant"][];
|
||||
variant?: keyof typeof InstalledAppVariants;
|
||||
exclude?: (keyof typeof InstalledAppVariants)[];
|
||||
}
|
||||
|
||||
const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integrations"> }) => {
|
||||
interface IntegrationsListProps {
|
||||
variant?: IntegrationsContainerProps["variant"];
|
||||
data: inferQueryOutput<"viewer.integrations">;
|
||||
}
|
||||
|
||||
const IntegrationsList = ({ data }: IntegrationsListProps) => {
|
||||
return (
|
||||
<List noBorderTreatment>
|
||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
||||
{data.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
name={item.name}
|
||||
@@ -100,8 +106,9 @@ const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integration
|
||||
title={item.title}
|
||||
logo={item.logo}
|
||||
description={item.description}
|
||||
separate={true}
|
||||
actions={
|
||||
<div className="flex w-32 justify-end">
|
||||
<div className="flex w-16 justify-end">
|
||||
<ConnectOrDisconnectIntegrationButton
|
||||
credentialIds={item.credentialIds}
|
||||
type={item.type}
|
||||
@@ -109,8 +116,9 @@ const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integration
|
||||
installed
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}>
|
||||
<AppSettings slug={item.slug} />
|
||||
</IntegrationListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
@@ -122,6 +130,8 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps)
|
||||
const emptyIcon = {
|
||||
calendar: Icon.FiCalendar,
|
||||
conferencing: Icon.FiVideo,
|
||||
automation: Icon.FiShare2,
|
||||
analytics: Icon.FiBarChart,
|
||||
payment: Icon.FiCreditCard,
|
||||
other: Icon.FiGrid,
|
||||
};
|
||||
@@ -151,7 +161,7 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps)
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<IntegrationsList data={data} />
|
||||
<IntegrationsList data={data} variant={variant} />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
@@ -186,16 +196,20 @@ export default function InstalledApps({ category }: InferGetServerSidePropsType<
|
||||
{(category === InstalledAppVariants.payment || category === InstalledAppVariants.conferencing) && (
|
||||
<IntegrationsContainer variant={category} />
|
||||
)}
|
||||
{category === InstalledAppVariants.other && (
|
||||
<IntegrationsContainer
|
||||
exclude={
|
||||
Object.keys(InstalledAppVariants).filter(
|
||||
(variant) => variant !== InstalledAppVariants.other
|
||||
) as App["variant"][]
|
||||
}
|
||||
/>
|
||||
{(category === InstalledAppVariants.automation || category === InstalledAppVariants.analytics) && (
|
||||
<IntegrationsContainer variant={category} />
|
||||
)}
|
||||
{category === InstalledAppVariants.calendar && <CalendarListContainer />}
|
||||
{category === InstalledAppVariants.other && (
|
||||
<IntegrationsContainer
|
||||
exclude={[
|
||||
InstalledAppVariants.conferencing,
|
||||
InstalledAppVariants.calendar,
|
||||
InstalledAppVariants.analytics,
|
||||
InstalledAppVariants.automation,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</InstalledAppsLayout>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,16 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { FaGoogle } from "react-icons/fa";
|
||||
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { EmailField, Form, PasswordField } from "@calcom/ui/form/fields";
|
||||
import { Button, EmailField, Form, PasswordField } from "@calcom/ui/v2";
|
||||
import SAMLLogin from "@calcom/ui/v2/modules/auth/SAMLLogin";
|
||||
|
||||
import { ErrorCode, getSession } from "@lib/auth";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
|
||||
@@ -21,9 +22,8 @@ import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@lib
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import SAMLLogin from "@components/auth/SAMLLogin";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
import AuthContainer from "@components/v2/ui/AuthContainer";
|
||||
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
@@ -53,8 +53,7 @@ export default function Login({
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
[ErrorCode.RateLimitExceeded]: t("rate_limit_exceeded"),
|
||||
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
||||
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
||||
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
|
||||
[ErrorCode.UserNotFound]: t("no_account_exists"),
|
||||
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
|
||||
@@ -78,12 +77,9 @@ export default function Login({
|
||||
callbackUrl = safeCallbackUrl || "";
|
||||
|
||||
const LoginFooter = (
|
||||
<span>
|
||||
{t("dont_have_an_account")}{" "}
|
||||
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900">
|
||||
{t("create_an_account")}
|
||||
</a>
|
||||
</span>
|
||||
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||
{t("dont_have_an_account")}
|
||||
</a>
|
||||
);
|
||||
|
||||
const TwoFactorFooter = (
|
||||
@@ -104,11 +100,10 @@ export default function Login({
|
||||
title={t("login")}
|
||||
description={t("login")}
|
||||
showLogo
|
||||
heading={twoFactorRequired ? t("2fa_code") : t("sign_in_account")}
|
||||
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
|
||||
footerText={twoFactorRequired ? TwoFactorFooter : LoginFooter}>
|
||||
<Form
|
||||
form={form}
|
||||
className="space-y-6"
|
||||
handleSubmit={async (values) => {
|
||||
setErrorMessage(null);
|
||||
telemetry.event(telemetryEventTypes.login, collectPageParameters());
|
||||
@@ -134,51 +129,52 @@ export default function Login({
|
||||
{...form.register("csrfToken")}
|
||||
/>
|
||||
</div>
|
||||
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
|
||||
<EmailField
|
||||
id="email"
|
||||
label={t("email_address")}
|
||||
defaultValue={router.query.email as string}
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
{...form.register("email")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 -top-[2px]">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a tabIndex={-1} className="text-primary-600 text-sm font-medium">
|
||||
{t("forgot")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
<div className="space-y-6">
|
||||
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
|
||||
<EmailField
|
||||
id="email"
|
||||
label={t("email_address")}
|
||||
defaultValue={router.query.email as string}
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
{...form.register("password")}
|
||||
{...form.register("email")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 -top-[6px] z-10">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a tabIndex={-1} className="text-sm font-medium text-gray-600">
|
||||
{t("forgot")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordField
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="mb-0"
|
||||
{...form.register("password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{twoFactorRequired && <TwoFactor center />}
|
||||
{twoFactorRequired && <TwoFactor center />}
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="flex space-y-2">
|
||||
<Button className="flex w-full justify-center" type="submit" disabled={isSubmitting}>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<Button type="submit" color="primary" disabled={isSubmitting} className="w-full justify-center">
|
||||
{twoFactorRequired ? t("submit") : t("sign_in")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{!twoFactorRequired && (
|
||||
<>
|
||||
{isGoogleLoginEnabled && (
|
||||
<div className="mt-5">
|
||||
{(isGoogleLoginEnabled || isSAMLLoginEnabled) && <hr className="my-8" />}
|
||||
<div className="space-y-3">
|
||||
{isGoogleLoginEnabled && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="flex w-full justify-center"
|
||||
className="w-full justify-center"
|
||||
data-testid="google"
|
||||
StartIcon={FaGoogle}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
// track Google logins. Without personal data/payload
|
||||
@@ -187,17 +183,17 @@ export default function Login({
|
||||
}}>
|
||||
{t("signin_with_google")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isSAMLLoginEnabled && (
|
||||
<SAMLLogin
|
||||
email={form.getValues("email")}
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
hostedCal={hostedCal}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
{isSAMLLoginEnabled && (
|
||||
<SAMLLogin
|
||||
email={form.getValues("email")}
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
hostedCal={hostedCal}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AuthContainer>
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { EmailField, PasswordField, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { isSAMLLoginEnabled } from "@lib/saml";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
type FormValues = {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
passwordcheck: string;
|
||||
apiError: string;
|
||||
};
|
||||
|
||||
export default function Signup({ email }: Props) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const methods = useForm<FormValues>();
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
} = methods;
|
||||
|
||||
methods.setValue("email", email);
|
||||
|
||||
const handleErrors = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (data) => {
|
||||
await fetch("/api/auth/signup", {
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
})
|
||||
.then(handleErrors)
|
||||
.then(
|
||||
async () =>
|
||||
await signIn("Cal.com", {
|
||||
callbackUrl: (`${NEXT_PUBLIC_BASE_URL}/${router.query.callbackUrl}` || "") as string,
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
methods.setError("apiError", { message: err.message });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen flex-col justify-center bg-gray-50 py-12 sm:px-6 lg:px-8"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="font-cal text-center text-3xl font-extrabold text-gray-900">
|
||||
{t("create_your_account")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="mx-2 bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
|
||||
{/* TODO: Refactor as soon as /availability is live */}
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(signUp)} className="space-y-6 bg-white">
|
||||
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
|
||||
<div className="space-y-2">
|
||||
<TextField
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
}
|
||||
labelProps={{ className: "block text-sm font-medium text-gray-700" }}
|
||||
className="block w-full min-w-0 flex-grow rounded-none rounded-r-sm border-gray-300 text-sm lowercase focus:border-black focus:ring-black"
|
||||
{...register("username")}
|
||||
required
|
||||
/>
|
||||
<EmailField
|
||||
{...register("email")}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-100 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
<PasswordField
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-gray-700",
|
||||
}}
|
||||
{...register("password")}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
<PasswordField
|
||||
label={t("confirm_password")}
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-gray-700",
|
||||
}}
|
||||
{...register("passwordcheck", {
|
||||
validate: (value) =>
|
||||
value === methods.watch("password") || (t("error_password_mismatch") as string),
|
||||
})}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button loading={isSubmitting} className="w-7/12 justify-center">
|
||||
{t("create_account")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="w-5/12 justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: (`${NEXT_PUBLIC_BASE_URL}/${router.query.callbackUrl}` || "") as string,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(ctx);
|
||||
const token = asStringOrNull(ctx.query.token);
|
||||
if (!token) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
// for now, disable if no verificationToken given or token expired
|
||||
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: verificationToken.identifier,
|
||||
},
|
||||
{
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/auth/login?callbackUrl=" + `${NEXT_PUBLIC_BASE_URL}/${ctx.query.callbackUrl}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
|
||||
isSAMLLoginEnabled,
|
||||
email: verificationToken.identifier,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,53 +1,68 @@
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DEFAULT_SCHEDULE, availabilityAsString } from "@calcom/lib/availability";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { BadgeCheckIcon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { Schedule as ScheduleType } from "@calcom/types/schedule";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect";
|
||||
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
||||
import { Form, Label } from "@calcom/ui/v2/core/form/fields";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { Skeleton, SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import Schedule from "@components/availability/Schedule";
|
||||
import EditableHeading from "@components/ui/EditableHeading";
|
||||
import { SelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
|
||||
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
|
||||
*/
|
||||
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
|
||||
const { t } = useLocale();
|
||||
const querySchema = z.object({
|
||||
schedule: stringOrNumber,
|
||||
});
|
||||
|
||||
type AvailabilityFormValues = {
|
||||
name: string;
|
||||
schedule: ScheduleType;
|
||||
timeZone: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export default function Availability({ schedule }: { schedule: number }) {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const me = useMeQuery();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
schedule: props.availability || DEFAULT_SCHEDULE,
|
||||
isDefault: !!props.isDefault,
|
||||
timeZone: props.timeZone,
|
||||
},
|
||||
});
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.availability.schedule", { scheduleId: schedule }]);
|
||||
|
||||
const form = useForm<AvailabilityFormValues>();
|
||||
const { control, reset } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && data) {
|
||||
reset({
|
||||
name: data?.schedule?.name,
|
||||
schedule: data.availability,
|
||||
timeZone: data.timeZone,
|
||||
isDefault: data.isDefault,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading, reset]);
|
||||
|
||||
const updateMutation = trpc.useMutation("viewer.availability.schedule.update", {
|
||||
onSuccess: async ({ schedule }) => {
|
||||
await utils.invalidateQueries(["viewer.availability.schedule"]);
|
||||
await utils.refetchQueries(["viewer.availability.schedule"]);
|
||||
await router.push("/availability");
|
||||
showToast(
|
||||
t("availability_updated_successfully", {
|
||||
@@ -65,110 +80,123 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.sc
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={async (values) => {
|
||||
updateMutation.mutate({
|
||||
scheduleId: parseInt(router.query.schedule as string, 10),
|
||||
name: props.schedule.name,
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-3 space-y-2 lg:col-span-2">
|
||||
<div className="divide-y rounded-sm border border-gray-200 bg-white px-4 py-5 sm:p-6">
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
|
||||
<Schedule name="schedule" />
|
||||
</div>
|
||||
<div className="space-x-2 text-right">
|
||||
<Button color="secondary" href="/availability" tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button>{t("save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 ml-2 space-y-2 lg:col-span-1">
|
||||
{props.isDefault ? (
|
||||
<div className="inline-block cursor-default rounded border border-gray-300 bg-gray-200 px-2 py-0.5 pl-1.5 text-sm font-medium text-neutral-800">
|
||||
<span className="flex items-center">
|
||||
<BadgeCheckIcon className="mr-1 h-4 w-4" /> {t("default")}
|
||||
<Shell
|
||||
backPath="/availability"
|
||||
title={data?.schedule.name && data.schedule.name + " | " + t("availability")}
|
||||
heading={
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => <EditableHeading isReady={!isLoading} {...field} />}
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
data ? (
|
||||
data.schedule.availability.map((availability) => (
|
||||
<span key={availability.id}>
|
||||
{availabilityAsString(availability, { locale: i18n.language })}
|
||||
<br />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Controller
|
||||
name="isDefault"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch label={t("set_to_default")} onCheckedChange={onChange} checked={value} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Controller
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
)}
|
||||
<SkeletonText className="h-4 w-48" />
|
||||
)
|
||||
}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center rounded-md px-2 sm:hover:bg-gray-100">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
|
||||
{t("set_to_default")}
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
disabled={isLoading}
|
||||
checked={form.watch("isDefault")}
|
||||
onCheckedChange={(e) => {
|
||||
form.setValue("isDefault", e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VerticalDivider />
|
||||
|
||||
<div className="border-l-2 border-gray-300" />
|
||||
<Button className="ml-4 lg:ml-0" type="submit" form="availability-form">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 rounded-sm border border-gray-200 px-4 py-5 sm:p-6 ">
|
||||
<h3 className="text-base font-medium leading-6 text-gray-900">
|
||||
{t("something_doesnt_look_right")}
|
||||
</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>{t("troubleshoot_availability")}</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex items-baseline sm:mt-0">
|
||||
{/* TODO: Find a better way to guarantee alignment, but for now this'll do. */}
|
||||
<Icon.FiArrowLeft className=" mr-3 text-transparent hover:cursor-pointer" />
|
||||
<div className="w-full">
|
||||
<Form
|
||||
form={form}
|
||||
id="availability-form"
|
||||
handleSubmit={async (values) => {
|
||||
updateMutation.mutate({
|
||||
scheduleId: schedule,
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
className="-mx-4 flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="flex-1">
|
||||
<div className="rounded-md border-gray-200 bg-white py-5 pr-4 sm:border sm:p-6">
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">
|
||||
{t("change_start_end")}
|
||||
</h3>
|
||||
{typeof me.data?.weekStart === "string" && (
|
||||
<Schedule
|
||||
control={control}
|
||||
name="schedule"
|
||||
weekStart={
|
||||
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||
me.data?.weekStart
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:p-0">
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) =>
|
||||
value ? (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand mt-1 block w-72 rounded-md border-gray-300 text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
) : (
|
||||
<SelectSkeletonLoader className="w-72" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<hr className="my-8" />
|
||||
<div className="rounded-md">
|
||||
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
||||
<div className="mt-3 flex">
|
||||
<Button href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
schedule: stringOrNumber,
|
||||
});
|
||||
|
||||
export default function Availability() {
|
||||
const router = useRouter();
|
||||
const { i18n } = useLocale();
|
||||
const { schedule: scheduleId } = router.isReady ? querySchema.parse(router.query) : { schedule: -1 };
|
||||
const query = trpc.useQuery(["viewer.availability.schedule", { scheduleId }], { enabled: router.isReady });
|
||||
const [name, setName] = useState<string>();
|
||||
return (
|
||||
<div>
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<Shell
|
||||
heading={<EditableHeading title={name || data.schedule.name} onChange={setName} />}
|
||||
subtitle={data.schedule.availability.map((availability) => (
|
||||
<span key={availability.id}>
|
||||
{availabilityAsString(availability, { locale: i18n.language })}
|
||||
<br />
|
||||
</span>
|
||||
))}>
|
||||
<AvailabilityForm
|
||||
{...{ ...data, schedule: { ...data.schedule, name: name || data.schedule.name } }}
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +1,79 @@
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/index.tsx` instead
|
||||
*/
|
||||
import { ScheduleListItem } from "@calcom/features/schedules/components/ScheduleListItem";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { EmptyScreen, showToast } from "@calcom/ui/v2";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
|
||||
import SkeletonLoader from "@components/availability/SkeletonLoader";
|
||||
import SkeletonLoader from "@components/v2/availability/SkeletonLoader";
|
||||
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/index.tsx` instead
|
||||
*/
|
||||
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const animationParentRef = useRef(null);
|
||||
|
||||
const meQuery = trpc.useQuery(["viewer.me"]);
|
||||
|
||||
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
|
||||
onSuccess: async () => {
|
||||
await utils.invalidateQueries(["viewer.availability.list"]);
|
||||
showToast(t("schedule_deleted_successfully"), "success");
|
||||
onMutate: async ({ scheduleId }) => {
|
||||
await utils.cancelQuery(["viewer.availability.list"]);
|
||||
const previousValue = utils.getQueryData(["viewer.availability.list"]);
|
||||
if (previousValue) {
|
||||
const filteredValue = previousValue.schedules.filter(({ id }) => id !== scheduleId);
|
||||
utils.setQueryData(["viewer.availability.list"], { ...previousValue, schedules: filteredValue });
|
||||
}
|
||||
|
||||
return { previousValue };
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.setQueryData(["viewer.availability.list"], context.previousValue);
|
||||
}
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.invalidateQueries(["viewer.availability.list"]);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast(t("schedule_deleted_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
// Adds smooth delete button - item fades and old item slides into place
|
||||
useEffect(() => {
|
||||
animationParentRef.current && autoAnimate(animationParentRef.current);
|
||||
}, [animationParentRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{schedules.length === 0 ? (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiClock}
|
||||
headline={t("new_schedule_heading")}
|
||||
description={t("new_schedule_description")}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiClock}
|
||||
headline={t("new_schedule_heading")}
|
||||
description={t("new_schedule_description")}
|
||||
buttonRaw={<NewScheduleButton />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
|
||||
<ul className="divide-y divide-neutral-200" data-testid="schedules">
|
||||
<div className="mb-16 overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<ul className="divide-y divide-neutral-200" data-testid="schedules" ref={animationParentRef}>
|
||||
{schedules.map((schedule) => (
|
||||
<ScheduleListItem
|
||||
displayOptions={{
|
||||
hour12: meQuery.data?.timeFormat ? meQuery.data.timeFormat === 12 : undefined,
|
||||
timeZone: meQuery.data?.timeZone,
|
||||
}}
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
deleteFunction={deleteMutation.mutate}
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
|
||||
*/
|
||||
import type { IBusySlot } from "pages/v2/availability/troubleshoot";
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
type User = inferQueryOutput<"viewer.me">;
|
||||
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
|
||||
*/
|
||||
export interface IBusySlot {
|
||||
start: string | Date;
|
||||
end: string | Date;
|
||||
title?: string;
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
const AvailabilityView = ({ user }: { user: User }) => {
|
||||
const { t } = useLocale();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
@@ -40,7 +35,7 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl overflow-hidden rounded-sm bg-white shadow">
|
||||
<div className="max-w-xl overflow-hidden rounded-md bg-white shadow">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
{t("overview_of_day")}{" "}
|
||||
<input
|
||||
@@ -53,27 +48,30 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
||||
/>
|
||||
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
|
||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
||||
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
||||
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
<>
|
||||
<SkeletonText className="block h-16 w-full" />
|
||||
<SkeletonText className="block h-16 w-full" />
|
||||
</>
|
||||
) : data && data.busy.length > 0 ? (
|
||||
data.busy
|
||||
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
|
||||
.map((slot: IBusySlot) => (
|
||||
<div
|
||||
key={`${slot.start}-${slot.title ?? "untitled"}`}
|
||||
className="overflow-hidden rounded-sm bg-neutral-100">
|
||||
key={dayjs(slot.start).format("HH:mm")}
|
||||
className="overflow-hidden rounded-md bg-neutral-100">
|
||||
<div className="px-4 py-5 text-black sm:p-6">
|
||||
{t("calendar_shows_busy_between")}{" "}
|
||||
<span className="font-medium text-neutral-800" title={slot.start}>
|
||||
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}>
|
||||
{dayjs(slot.start).format("HH:mm")}
|
||||
</span>{" "}
|
||||
{t("and")}{" "}
|
||||
<span className="font-medium text-neutral-800" title={slot.end}>
|
||||
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
|
||||
{dayjs(slot.end).format("HH:mm")}
|
||||
</span>{" "}
|
||||
{t("on")} {dayjs(slot.start).format("D")}{" "}
|
||||
@@ -84,12 +82,12 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-sm bg-neutral-100">
|
||||
<div className="overflow-hidden rounded-md bg-neutral-100">
|
||||
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
|
||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
||||
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
||||
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
|
||||
</div>
|
||||
@@ -101,12 +99,12 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
||||
};
|
||||
|
||||
export default function Troubleshoot() {
|
||||
const query = trpc.useQuery(["viewer.me"]);
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.me"]);
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<Shell heading={t("troubleshoot")} subtitle={t("troubleshoot_description")}>
|
||||
<QueryCell query={query} success={({ data }) => <AvailabilityView user={data} />} />
|
||||
{!isLoading && data && <AvailabilityView user={data} />}
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useEffect, useRef } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { Button, EmptyScreen } from "@calcom/ui/v2";
|
||||
import BookingLayout from "@calcom/ui/v2/core/layouts/BookingLayout";
|
||||
|
||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||
|
||||
import BookingsShell from "@components/BookingsShell";
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||
import SkeletonLoader from "@components/v2/bookings/SkeletonLoader";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
|
||||
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled"] as const;
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
upcoming: "upcoming_bookings",
|
||||
@@ -46,6 +45,8 @@ export default function Bookings() {
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
// Animate page (tab) tranistions to look smoothing
|
||||
const animationParentRef = useRef(null);
|
||||
const buttonInView = useInViewObserver(() => {
|
||||
if (!query.isFetching && query.hasNextPage && query.status === "success") {
|
||||
query.fetchNextPage();
|
||||
@@ -54,20 +55,9 @@ export default function Bookings() {
|
||||
|
||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
||||
|
||||
// Get all recurring events of the series with the same recurringEventId
|
||||
const defineRecurrentBookings = (
|
||||
booking: BookingOutput,
|
||||
groupedBookings: Record<string, BookingOutput[]>
|
||||
) => {
|
||||
let recurringBookings = undefined;
|
||||
if (booking.recurringEventId !== null) {
|
||||
recurringBookings = groupedBookings[booking.recurringEventId];
|
||||
}
|
||||
return { recurringBookings };
|
||||
};
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
if (status === "recurring" || status === "cancelled") {
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
@@ -82,63 +72,68 @@ export default function Bookings() {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
animationParentRef.current && autoAnimate(animationParentRef.current);
|
||||
}, [animationParentRef]);
|
||||
|
||||
return (
|
||||
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
<BookingsShell>
|
||||
<div className="-mx-4 flex flex-col sm:mx-auto">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<>
|
||||
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
{...defineRecurrentBookings(booking, shownBookings)}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{query.status === "success" && isEmpty && (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiCalendar}
|
||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||
description={t("no_status_bookings_yet_description", {
|
||||
status: t(status).toLowerCase(),
|
||||
description: t(descriptionByStatus[status]),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<BookingLayout heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<div className="flex w-full flex-col" ref={animationParentRef}>
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
{/* TODO: add today only for the current day
|
||||
<p className="pb-3 text-xs font-medium leading-4 text-gray-500 uppercase">{t("today")}</p>
|
||||
*/}
|
||||
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringBookings={page.recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BookingsShell>
|
||||
</Shell>
|
||||
)}
|
||||
{query.status === "success" && isEmpty && (
|
||||
<div className="flex items-center justify-center pt-2 xl:mx-6 xl:pt-0">
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiCalendar}
|
||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||
description={t("no_status_bookings_yet_description", {
|
||||
status: t(status).toLowerCase(),
|
||||
description: t(descriptionByStatus[status]),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BookingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
|
||||
import { privacyFilteredLocations, LocationObject } from "@calcom/core/location";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import { GetBookingType } from "@lib/getBooking";
|
||||
@@ -100,7 +100,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
: [];
|
||||
|
||||
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
||||
metadata: {} as JSONObject,
|
||||
metadata: EventTypeMetaDataSchema.parse(hashedLink.eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(hashedLink.eventType.recurringEvent),
|
||||
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+71
-44
@@ -5,18 +5,19 @@ import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
|
||||
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
||||
import getApps, { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils";
|
||||
import { LocationObject, EventLocationType } from "@calcom/core/location";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { parseRecurringEvent, parseBookingLimit, validateBookingLimitOrder } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { showToast } from "@calcom/ui/v2";
|
||||
|
||||
@@ -49,8 +50,6 @@ export type FormValues = {
|
||||
requiresConfirmation: boolean;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
schedulingType: SchedulingType | null;
|
||||
price: number;
|
||||
currency: string;
|
||||
hidden: boolean;
|
||||
hideCalendarNotes: boolean;
|
||||
hashedLink: string | undefined;
|
||||
@@ -75,14 +74,13 @@ export type FormValues = {
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
slotInterval: number | null;
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
destinationCalendar: {
|
||||
integration: string;
|
||||
externalId: string;
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
giphyThankYouPage: string;
|
||||
blockchainId: number;
|
||||
smartContractAddress: string;
|
||||
bookingLimits?: BookingLimit;
|
||||
};
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -96,8 +94,16 @@ export type EventTypeSetupInfered = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { t } = useLocale();
|
||||
const { data: eventTypeApps } = trpc.useQuery([
|
||||
"viewer.apps",
|
||||
{
|
||||
extendsFeature: "EventType",
|
||||
},
|
||||
]);
|
||||
|
||||
const { eventType: dbEventType, locationOptions, team, teamMembers } = props;
|
||||
// TODO: It isn't a good idea to maintain state using setEventType. If we want to connect the SSR'd data to tRPC, we should useQuery(["viewer.eventTypes.get"]) with initialData
|
||||
// Due to this change, when Form is saved, there is no way to propagate that info to eventType (e.g. disabling stripe app doesn't allow recurring tab to be enabled without refresh).
|
||||
const [eventType, setEventType] = useState(dbEventType);
|
||||
const animationParentRef = useRef(null);
|
||||
const router = useRouter();
|
||||
@@ -150,6 +156,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
recurringEvent: eventType.recurringEvent || null,
|
||||
description: eventType.description ?? undefined,
|
||||
schedule: eventType.schedule || undefined,
|
||||
bookingLimits: eventType.bookingLimits || undefined,
|
||||
hidden: eventType.hidden,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
@@ -157,9 +164,24 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
},
|
||||
schedulingType: eventType.schedulingType,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
metadata: eventType.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||
const numberOfInstalledApps = eventTypeApps?.filter((app) => app.isInstalled).length || 0;
|
||||
let numberOfActiveApps = 0;
|
||||
|
||||
if (appsMetadata) {
|
||||
numberOfActiveApps = Object.entries(appsMetadata).filter(
|
||||
([appId, appData]) => eventTypeApps?.find((app) => app.slug === appId)?.isInstalled && appData.enabled
|
||||
).length;
|
||||
}
|
||||
|
||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
}`;
|
||||
|
||||
const tabMap = {
|
||||
setup: (
|
||||
<EventSetupTab
|
||||
@@ -180,18 +202,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
),
|
||||
limits: <EventLimitsTab eventType={eventType} />,
|
||||
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
|
||||
recurring: (
|
||||
<EventRecurringTab eventType={eventType} hasPaymentIntegration={props.hasPaymentIntegration} />
|
||||
),
|
||||
apps: (
|
||||
<EventAppsTab
|
||||
currency={props.currency}
|
||||
eventType={eventType}
|
||||
hasPaymentIntegration={props.hasPaymentIntegration}
|
||||
hasGiphyIntegration={props.hasGiphyIntegration}
|
||||
hasRainbowIntegration={props.hasRainbowIntegration}
|
||||
/>
|
||||
),
|
||||
recurring: <EventRecurringTab eventType={eventType} />,
|
||||
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
|
||||
workflows: (
|
||||
<EventWorkflowsTab
|
||||
eventType={eventType}
|
||||
@@ -202,7 +214,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
|
||||
return (
|
||||
<EventTypeSingleLayout
|
||||
enabledAppsNumber={[props.hasGiphyIntegration, props.hasPaymentIntegration].filter(Boolean).length}
|
||||
enabledAppsNumber={numberOfActiveApps}
|
||||
installedAppsNumber={numberOfInstalledApps}
|
||||
enabledWorkflowsNumber={eventType.workflows.length}
|
||||
eventType={eventType}
|
||||
team={team}
|
||||
@@ -216,20 +229,24 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const {
|
||||
periodDates,
|
||||
periodCountCalendarDays,
|
||||
giphyThankYouPage,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
bookingLimits,
|
||||
recurringEvent,
|
||||
locations,
|
||||
blockchainId,
|
||||
smartContractAddress,
|
||||
metadata,
|
||||
// We don't need to send it to the backend
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
seatsPerTimeSlotEnabled,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateBookingLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error("Booking limits must be in accending order. [day,week,month,year]");
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
...input,
|
||||
locations,
|
||||
@@ -240,12 +257,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
id: eventType.id,
|
||||
beforeEventBuffer: beforeBufferTime,
|
||||
afterEventBuffer: afterBufferTime,
|
||||
bookingLimits,
|
||||
seatsPerTimeSlot,
|
||||
metadata: {
|
||||
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
|
||||
...(smartContractAddress ? { smartContractAddress } : {}),
|
||||
...(blockchainId ? { blockchainId } : { blockchainId: 1 }),
|
||||
},
|
||||
metadata,
|
||||
});
|
||||
}}>
|
||||
<div ref={animationParentRef} className="space-y-6">
|
||||
@@ -344,7 +358,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
afterEventBuffer: true,
|
||||
slotInterval: true,
|
||||
hashedLink: true,
|
||||
bookingLimits: true,
|
||||
successRedirectUrl: true,
|
||||
currency: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -373,7 +389,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
userId: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
destinationCalendar: true,
|
||||
seatsPerTimeSlot: true,
|
||||
workflows: {
|
||||
@@ -418,18 +433,38 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
});
|
||||
|
||||
const { locations, metadata, ...restEventType } = rawEventType;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {})!;
|
||||
const apps = newMetadata.apps || {};
|
||||
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
|
||||
newMetadata.apps = {
|
||||
...apps,
|
||||
stripe: {
|
||||
...getStripeAppData(eventTypeWithParsedMetadata, true),
|
||||
currency:
|
||||
(
|
||||
credentials.find((integration) => integration.type === "stripe_payment")
|
||||
?.key as unknown as StripeData
|
||||
)?.default_currency || "usd",
|
||||
},
|
||||
giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true),
|
||||
rainbow: getEventTypeAppData(eventTypeWithParsedMetadata, "rainbow", true),
|
||||
};
|
||||
|
||||
// TODO: How to extract metadata schema from _EventTypeModel to be able to parse it?
|
||||
// const parsedMetaData = _EventTypeModel.parse(newMetadata);
|
||||
const parsedMetaData = newMetadata;
|
||||
|
||||
const eventType = {
|
||||
...restEventType,
|
||||
schedule: rawEventType.schedule?.id || rawEventType.users[0].defaultScheduleId,
|
||||
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
|
||||
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
|
||||
locations: locations as unknown as LocationObject[],
|
||||
metadata: (metadata || {}) as JSONObject,
|
||||
metadata: parsedMetaData,
|
||||
};
|
||||
|
||||
const hasGiphyIntegration = !!credentials.find((credential) => credential.type === "giphy_other");
|
||||
|
||||
const hasRainbowIntegration = !!credentials.find((credential) => credential.type === "rainbow_web3");
|
||||
|
||||
// backwards compat
|
||||
if (eventType.users.length === 0 && !eventType.team) {
|
||||
const fallbackUser = await prisma.user.findUnique({
|
||||
@@ -445,10 +480,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const t = await getTranslation(currentUser?.locale ?? "en", "common");
|
||||
const integrations = getApps(credentials);
|
||||
const locationOptions = getLocationOptions(integrations, t);
|
||||
const hasPaymentIntegration = !!credentials.find((credential) => credential.type === "stripe_payment");
|
||||
const currency =
|
||||
(credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData)
|
||||
?.default_currency || "usd";
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
@@ -475,10 +506,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
locationOptions,
|
||||
team: eventTypeObject.team || null,
|
||||
teamMembers,
|
||||
hasPaymentIntegration,
|
||||
hasGiphyIntegration,
|
||||
hasRainbowIntegration,
|
||||
currency,
|
||||
currentUserMembership,
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* @deprecated file is not used anymore
|
||||
* Use `/apps/web/pages/v2/event-types/index.tsx` instead
|
||||
*/
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -11,41 +6,34 @@ import React, { Fragment, useEffect, useState } from "react";
|
||||
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { TRPCClientError } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Badge from "@calcom/ui/Badge";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Badge, Button, ButtonGroup, Dialog, EmptyScreen, showToast, Switch, Tooltip } from "@calcom/ui/v2";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import Dropdown, {
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
} from "@calcom/ui/v2/core/Dropdown";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import CreateEventTypeButton from "@calcom/ui/v2/modules/event-types/CreateEventType";
|
||||
import EventTypeDescription from "@calcom/ui/v2/modules/event-types/EventTypeDescription";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import classNames from "@lib/classNames";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import { LinkText } from "@components/ui/LinkText";
|
||||
import NoCalendarConnectedAlert from "@components/ui/NoCalendarConnectedAlert";
|
||||
import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader";
|
||||
|
||||
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
|
||||
interface EventTypeListHeadingProps {
|
||||
profile: EventTypeGroupProfile;
|
||||
membershipCount: number;
|
||||
@@ -64,25 +52,19 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Link href={"/event-types/" + type.id}>
|
||||
<Link href={`/event-types/${type.id}?tabName=setup`}>
|
||||
<a
|
||||
className="flex-grow truncate text-sm"
|
||||
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
|
||||
<div>
|
||||
<span
|
||||
className="truncate font-medium text-neutral-900 ltr:mr-1 rtl:ml-1"
|
||||
className="truncate font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"
|
||||
data-testid={"event-type-title-" + type.id}>
|
||||
{type.title}
|
||||
</span>
|
||||
<small
|
||||
className="hidden text-neutral-500 sm:inline"
|
||||
className="hidden font-normal leading-4 text-gray-600 sm:inline"
|
||||
data-testid={"event-type-slug-" + type.id}>{`/${group.profile.slug}/${type.slug}`}</small>
|
||||
{type.hidden && (
|
||||
<span className="rtl:mr-2inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ltr:ml-2">
|
||||
{t("hidden") as string}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{readOnly && (
|
||||
<span className="rtl:mr-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2">
|
||||
{t("readonly") as string}
|
||||
@@ -96,17 +78,26 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
};
|
||||
|
||||
const MemoizedItem = React.memo(Item);
|
||||
/**
|
||||
* @deprecated
|
||||
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
|
||||
*/
|
||||
|
||||
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
|
||||
const utils = trpc.useContext();
|
||||
const mutation = trpc.useMutation("viewer.eventTypeOrder", {
|
||||
onError: async (err) => {
|
||||
console.error(err.message);
|
||||
await utils.cancelQuery(["viewer.eventTypes"]);
|
||||
await utils.invalidateQueries(["viewer.eventTypes"]);
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.invalidateQueries(["viewer.eventTypes"]);
|
||||
},
|
||||
});
|
||||
|
||||
const setHiddenMutation = trpc.useMutation("viewer.eventTypes.update", {
|
||||
onError: async (err) => {
|
||||
console.error(err.message);
|
||||
await utils.cancelQuery(["viewer.eventTypes"]);
|
||||
@@ -117,7 +108,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
},
|
||||
});
|
||||
|
||||
function moveEventType(index: number, increment: 1 | -1) {
|
||||
async function moveEventType(index: number, increment: 1 | -1) {
|
||||
const newList = [...types];
|
||||
|
||||
const type = types[index];
|
||||
@@ -127,24 +118,19 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
newList[index + increment] = type;
|
||||
}
|
||||
|
||||
utils.cancelQuery(["viewer.eventTypes"]);
|
||||
utils.setQueryData(["viewer.eventTypes"], (data) => {
|
||||
// tRPC is very strict with the return signature...
|
||||
if (!data)
|
||||
return {
|
||||
eventTypeGroups: [],
|
||||
profiles: [],
|
||||
viewer: { canAddEvents: true, plan: UserPlan.PRO },
|
||||
};
|
||||
return {
|
||||
...data,
|
||||
eventTypesGroups: [
|
||||
...data.eventTypeGroups.slice(0, groupIndex),
|
||||
await utils.cancelQuery(["viewer.eventTypes"]);
|
||||
|
||||
const previousValue = utils.getQueryData(["viewer.eventTypes"]);
|
||||
if (previousValue) {
|
||||
utils.setQueryData(["viewer.eventTypes"], {
|
||||
...previousValue,
|
||||
eventTypeGroups: [
|
||||
...previousValue.eventTypeGroups.slice(0, groupIndex),
|
||||
{ ...group, eventTypes: newList },
|
||||
...data.eventTypeGroups.slice(groupIndex + 1),
|
||||
...previousValue.eventTypeGroups.slice(groupIndex + 1),
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
ids: newList.map((type) => type.id),
|
||||
@@ -207,28 +193,33 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
}
|
||||
}, []);
|
||||
|
||||
const firstItem = types[0];
|
||||
const lastItem = types[types.length - 1];
|
||||
return (
|
||||
<div className="mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
|
||||
<ul className="divide-y divide-neutral-200" data-testid="event-types">
|
||||
<div className="mb-16 flex overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<ul ref={parent} className="!static w-full divide-y divide-neutral-200" data-testid="event-types">
|
||||
{types.map((type, index) => {
|
||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||
const calLink = `${CAL_URL}/${embedLink}`;
|
||||
return (
|
||||
<li key={type.id}>
|
||||
<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 hover:bg-neutral-50 sm:px-6">
|
||||
<button
|
||||
className="invisible absolute left-[3px] -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 items-center justify-center rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[34px]"
|
||||
onClick={() => moveEventType(index, -1)}>
|
||||
<Icon.FiArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="invisible absolute left-[3px] mt-8 -ml-4 hidden h-7 w-7 scale-0 items-center justify-center rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[34px]"
|
||||
onClick={() => moveEventType(index, 1)}>
|
||||
<Icon.FiArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="group flex w-full items-center justify-between px-4 py-4 pr-0 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]"
|
||||
onClick={() => moveEventType(index, -1)}>
|
||||
<Icon.FiArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === type.id) && (
|
||||
<button
|
||||
className="invisible absolute left-[5px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow 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]"
|
||||
onClick={() => moveEventType(index, 1)}>
|
||||
<Icon.FiArrowDown className="h-5 w-5" />
|
||||
</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="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
@@ -245,127 +236,132 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<Tooltip side="top" content={t("preview") as string}>
|
||||
<Button
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
type="button"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
className="group-hover:text-black"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
href={calLink}
|
||||
/>
|
||||
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.hidden && (
|
||||
<Badge variant="gray" size="lg">
|
||||
{t("hidden")}
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip content={t("show_eventtype_on_profile") as string}>
|
||||
<div className="self-center rounded-md p-2 hover:bg-gray-200">
|
||||
<Switch
|
||||
name="Hidden"
|
||||
checked={!type.hidden}
|
||||
onCheckedChange={() => {
|
||||
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip side="top" content={t("copy_link") as string}>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
className="group-hover:text-black"
|
||||
StartIcon={Icon.FiLink}
|
||||
onClick={() => {
|
||||
showToast(t("link_copied"), "success");
|
||||
navigator.clipboard.writeText(calLink);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
className="group-hover:text-black"
|
||||
StartIcon={Icon.FiMoreHorizontal}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Link href={"/event-types/" + type.id} passHref={true}>
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("preview") as string}>
|
||||
<Button
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
size="icon"
|
||||
href={calLink}
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
combined
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t("copy_link") as string}>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiLink}
|
||||
onClick={() => {
|
||||
showToast(t("link_copied"), "success");
|
||||
navigator.clipboard.writeText(calLink);
|
||||
}}
|
||||
combined
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiEdit2}
|
||||
className="w-full">
|
||||
{t("edit") as string}
|
||||
</Button>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={() => openModal(group, type)}>
|
||||
{t("duplicate") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<EmbedButton
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
as={Button}
|
||||
StartIcon={Icon.FiCode}
|
||||
className="w-full rounded-none"
|
||||
embedUrl={encodeURIComponent(embedLink)}>
|
||||
{t("embed")}
|
||||
</EmbedButton>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) && (
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
color="warn"
|
||||
size="sm"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
size="icon"
|
||||
color="secondary"
|
||||
combined
|
||||
StartIcon={Icon.FiMoreHorizontal}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={"event-type-edit-" + type.id}
|
||||
StartIcon={Icon.FiEdit2}
|
||||
onClick={() => router.push("/event-types/" + type.id)}>
|
||||
{t("edit") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={() => openModal(group, type)}>
|
||||
{t("duplicate") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<EmbedButton
|
||||
as={DropdownItem}
|
||||
type="button"
|
||||
StartIcon={Icon.FiCode}
|
||||
className="w-full rounded-none"
|
||||
embedUrl={encodeURIComponent(embedLink)}>
|
||||
{t("embed")}
|
||||
</EmbedButton>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-5 flex flex-shrink-0 sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button type="button" size="icon" color="minimal" StartIcon={Icon.FiMoreHorizontal} />
|
||||
<Button type="button" size="icon" color="secondary" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Link href={calLink}>
|
||||
<Button
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview") as string}
|
||||
</Button>
|
||||
<a target="_blank">
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview") as string}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none text-left"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiClipboard}
|
||||
@@ -381,7 +377,6 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiUpload}
|
||||
@@ -402,11 +397,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
href={"/event-types/" + type.id}
|
||||
onClick={() => router.push("/event-types/" + type.id)}
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
StartIcon={Icon.FiEdit}>
|
||||
{t("edit") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
@@ -414,7 +408,6 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiCopy}
|
||||
@@ -429,8 +422,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
color="warn"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete") as string}
|
||||
@@ -464,7 +456,6 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
};
|
||||
|
||||
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => {
|
||||
console.log(profile.slug);
|
||||
return (
|
||||
<div className="mb-4 flex">
|
||||
<Link href="/settings/teams">
|
||||
@@ -504,16 +495,13 @@ const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeading
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @deprecated
|
||||
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
|
||||
*/
|
||||
|
||||
const CreateFirstEventTypeView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiCalendar}
|
||||
Icon={Icon.FiLink}
|
||||
headline={t("new_event_type_heading")}
|
||||
description={t("new_event_type_description")}
|
||||
/>
|
||||
@@ -529,20 +517,15 @@ const CTA = () => {
|
||||
};
|
||||
|
||||
const WithQuery = withQuery(["viewer.eventTypes"]);
|
||||
/**
|
||||
* @deprecated
|
||||
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
|
||||
*/
|
||||
|
||||
const EventTypesPage = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Home | Cal.com</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Shell
|
||||
heading={t("event_types_page_title") as string}
|
||||
subtitle={t("event_types_page_subtitle") as string}
|
||||
@@ -551,8 +534,6 @@ const EventTypesPage = () => {
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
<NoCalendarConnectedAlert />
|
||||
|
||||
{data.eventTypeGroups.map((group, index) => (
|
||||
<Fragment key={group.profile.slug}>
|
||||
{/* hide list heading when there is only one (current user) */}
|
||||
@@ -562,7 +543,6 @@ const EventTypesPage = () => {
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
|
||||
@@ -92,7 +92,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="mx-auto px-4 py-24">
|
||||
<div className="mx-auto px-4 py-6 md:py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-[520px]">
|
||||
@@ -129,7 +129,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||
event.preventDefault();
|
||||
goToIndex(currentStepIndex + 1);
|
||||
}}
|
||||
className="mt-24 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{headers[currentStepIndex]?.skipText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
|
||||
function AdminView() {
|
||||
const { t } = useLocale();
|
||||
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="py-6 lg:pb-8">
|
||||
<form
|
||||
className="mb-6 w-full sm:w-1/2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const enteredUsername = usernameRef.current?.value.toLowerCase();
|
||||
signIn("impersonation-auth", { username: enteredUsername }).then((res) => {
|
||||
console.log(res);
|
||||
});
|
||||
}}>
|
||||
<TextField
|
||||
name="Impersonate User"
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
}
|
||||
ref={usernameRef}
|
||||
defaultValue={undefined}
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||
{t("impersonate_user_tip")}
|
||||
</p>
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit">{t("impersonate")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<hr className="mt-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<SettingsShell heading={t("admin")}>
|
||||
<AdminView />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const session = await getSession(context);
|
||||
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
return { redirect: { permanent: false, destination: "/settings/profile" } };
|
||||
}
|
||||
return { props: {} };
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { HelpScout, useChat } from "react-live-chat-loader";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
|
||||
type CardProps = { title: string; description: string; className?: string; children: ReactNode };
|
||||
const Card = ({ title, description, className = "", children }: CardProps): JSX.Element => (
|
||||
<div className={`border bg-white sm:rounded-sm ${className}`}>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">{title}</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function Billing() {
|
||||
const { t } = useLocale();
|
||||
const query = useMeQuery();
|
||||
const { data } = query;
|
||||
const [active, setActive] = useState(false);
|
||||
const [, loadChat] = useChat();
|
||||
|
||||
const handleClick = () => {
|
||||
setActive(true);
|
||||
loadChat({ open: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsShell heading={t("billing")} subtitle={t("manage_your_billing_info")}>
|
||||
<>
|
||||
<div className="py-6 lg:col-span-9 lg:pb-8">
|
||||
{data?.plan && ["FREE", "TRIAL"].includes(data.plan) && (
|
||||
<Card
|
||||
title={t("plan_description", { plan: data.plan })}
|
||||
description={t("plan_upgrade_invitation")}
|
||||
className="mb-4">
|
||||
<form method="POST" action="/api/upgrade">
|
||||
<Button type="submit">
|
||||
{t("upgrade_now")} <Icon.FiExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||
<form method="POST" action="/api/integrations/stripepayment/portal">
|
||||
<Button type="submit" color="secondary">
|
||||
{t("go_to_billing_portal")} <Icon.FiExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<div className="mt-4 border bg-gray-50 sm:rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">{t("need_anything_else")}</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>{t("further_billing_help")}</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button onClick={handleClick} color="secondary">
|
||||
{t("contact_our_support_team")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{active && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
|
||||
</>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import ApiKeyListContainer from "@calcom/features/ee/api-keys/components/ApiKeyListContainer";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import WebhookListContainer from "@components/webhook/WebhookListContainer";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useLocale();
|
||||
const { data: routingForms } = trpc.useQuery([
|
||||
"viewer.appById",
|
||||
{
|
||||
appId: "routing-forms",
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsShell heading={t("developer")} subtitle={t("manage_developer_settings")}>
|
||||
<WebhookListContainer title="Event Webhooks" subtitle={t("receive_cal_meeting_data")} />
|
||||
{routingForms && (
|
||||
<WebhookListContainer
|
||||
appId="routing-forms"
|
||||
title="Routing Webhooks"
|
||||
subtitle="Receive Routing Form responses at a specified URL, in real-time, when a Routing Form is submitted"
|
||||
/>
|
||||
)}
|
||||
<ApiKeyListContainer />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
+17
-12
@@ -4,12 +4,12 @@ import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { setIs24hClockInLocalStorage } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/v2/core/Button";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect";
|
||||
import Select from "@calcom/ui/v2/core/form/Select";
|
||||
import { Form, Label } from "@calcom/ui/v2/core/form/fields";
|
||||
import Select from "@calcom/ui/v2/core/form/select";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { SkeletonContainer, SkeletonText, SkeletonButton } from "@calcom/ui/v2/core/skeleton";
|
||||
@@ -34,25 +34,32 @@ const SkeletonLoader = () => {
|
||||
|
||||
interface GeneralViewProps {
|
||||
localeProp: string;
|
||||
user: inferQueryOutput<"viewer.me">;
|
||||
}
|
||||
|
||||
const WithQuery = withQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } });
|
||||
|
||||
const GeneralQueryView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: user, isLoading } = trpc.useQuery(["viewer.me"]);
|
||||
if (isLoading) return <SkeletonLoader />;
|
||||
if (!user) {
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
return (
|
||||
<WithQuery
|
||||
success={({ data }) => <GeneralView localeProp={data.locale} />}
|
||||
success={({ data }) => <GeneralView user={user} localeProp={data.locale} />}
|
||||
customLoader={<SkeletonLoader />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GeneralView = ({ localeProp }: GeneralViewProps) => {
|
||||
const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: user, isLoading } = trpc.useQuery(["viewer.me"]);
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: () => {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
@@ -93,20 +100,18 @@ const GeneralView = ({ localeProp }: GeneralViewProps) => {
|
||||
value: localeProp || "",
|
||||
label: localeOptions.find((option) => option.value === localeProp)?.label || "",
|
||||
},
|
||||
timeZone: user?.timeZone || "",
|
||||
timeZone: user.timeZone || "",
|
||||
timeFormat: {
|
||||
value: user?.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === user?.timeFormat)?.label || 12,
|
||||
value: user.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === user.timeFormat)?.label || 12,
|
||||
},
|
||||
weekStart: {
|
||||
value: user?.weekStart,
|
||||
label: nameOfDay(localeProp, user?.weekStart === "Sunday" ? 0 : 1),
|
||||
value: user.weekStart,
|
||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <SkeletonLoader />;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
@@ -1,635 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
ComponentProps,
|
||||
RefObject,
|
||||
FormEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
BaseSyntheticEvent,
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { setIs24hClockInLocalStorage } from "@calcom/lib/timeFormat";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Badge from "@calcom/ui/Badge";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { UpgradeToProDialog } from "@calcom/ui/UpgradeToProDialog";
|
||||
import { Form, PasswordField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
|
||||
import { ErrorCode, getSession } from "@lib/auth";
|
||||
import { nameOfDay } from "@lib/core/i18n/weekday";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import InfoBadge from "@components/ui/InfoBadge";
|
||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||
import ColorPicker from "@components/ui/colorpicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
|
||||
const { user } = props;
|
||||
const { t } = useLocale();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
id="hide-branding"
|
||||
name="hide-branding"
|
||||
type="checkbox"
|
||||
ref={props.hideBrandingRef}
|
||||
defaultChecked={isBrandingHidden(user)}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800 disabled:opacity-50"
|
||||
onClick={(e) => {
|
||||
if (!e.currentTarget.checked || user.plan !== "FREE") {
|
||||
return;
|
||||
}
|
||||
|
||||
// prevent checking the input
|
||||
e.preventDefault();
|
||||
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
|
||||
{t("remove_cal_branding_description")}
|
||||
</UpgradeToProDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
interface DeleteAccountValues {
|
||||
totpCode: string;
|
||||
}
|
||||
|
||||
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
|
||||
const { user } = props;
|
||||
const form = useForm<DeleteAccountValues>();
|
||||
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const onSuccessMutation = async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
};
|
||||
|
||||
const onErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(error.message);
|
||||
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: onSuccessMutation,
|
||||
onError: onErrorMutation,
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
|
||||
const onDeleteMeSuccessMutation = async () => {
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
showToast(t("Your account was deleted"), "success");
|
||||
|
||||
setHasDeleteErrors(false); // dismiss any open errors
|
||||
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||
signOut({ callbackUrl: "/auth/logout?survey=true" });
|
||||
} else {
|
||||
signOut({ callbackUrl: "/auth/logout" });
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteMeErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
|
||||
setHasDeleteErrors(true);
|
||||
setDeleteErrorMessage(errorMessages[error.message]);
|
||||
};
|
||||
const deleteMeMutation = trpc.useMutation("viewer.deleteMe", {
|
||||
onSuccess: onDeleteMeSuccessMutation,
|
||||
onError: onDeleteMeErrorMutation,
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
},
|
||||
});
|
||||
|
||||
const localeOptions = useMemo(() => {
|
||||
return (router.locales || []).map((locale) => ({
|
||||
value: locale,
|
||||
label: new Intl.DisplayNames(props.localeProp, { type: "language" }).of(locale) || "",
|
||||
}));
|
||||
}, [props.localeProp, router.locales]);
|
||||
|
||||
const themeOptions = [
|
||||
{ value: "light", label: t("light") },
|
||||
{ value: "dark", label: t("dark") },
|
||||
];
|
||||
|
||||
const timeFormatOptions = [
|
||||
{ value: 12, label: t("12_hour") },
|
||||
{ value: 24, label: t("24_hour") },
|
||||
];
|
||||
const usernameRef = useRef<HTMLInputElement>(null!);
|
||||
const passwordRef = useRef<HTMLInputElement>(null!);
|
||||
const nameRef = useRef<HTMLInputElement>(null!);
|
||||
const emailRef = useRef<HTMLInputElement>(null!);
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
|
||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||
const hideBrandingRef = useRef<HTMLInputElement>(null!);
|
||||
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
|
||||
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
|
||||
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
|
||||
value: user.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === user.timeFormat)?.label || 12,
|
||||
});
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(user.timeZone);
|
||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
|
||||
value: user.weekStart,
|
||||
label: nameOfDay(props.localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||
});
|
||||
|
||||
const [selectedLanguage, setSelectedLanguage] = useState({
|
||||
value: props.localeProp || "",
|
||||
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
|
||||
});
|
||||
const [imageSrc, setImageSrc] = useState<string>(user.avatar || "");
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
||||
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
|
||||
[ErrorCode.UserNotFound]: t("no_account_exists"),
|
||||
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
|
||||
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
|
||||
const [brandColor, setBrandColor] = useState(user.brandColor);
|
||||
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.theme) return;
|
||||
const userTheme = themeOptions.find((theme) => theme.value === user.theme);
|
||||
if (!userTheme) return;
|
||||
setSelectedTheme(userTheme);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onConfirmButton = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const totpCode = form.getValues("totpCode");
|
||||
const password = passwordRef.current.value;
|
||||
deleteMeMutation.mutate({ password, totpCode });
|
||||
};
|
||||
const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
|
||||
e?.preventDefault();
|
||||
const password = passwordRef.current.value;
|
||||
deleteMeMutation.mutate({ password, totpCode });
|
||||
};
|
||||
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredUsername = usernameRef.current.value.toLowerCase();
|
||||
const enteredName = nameRef.current.value;
|
||||
const enteredEmail = emailRef.current.value;
|
||||
const enteredDescription = descriptionRef.current.value;
|
||||
const enteredAvatar = avatarRef.current.value;
|
||||
const enteredBrandColor = brandColor;
|
||||
const enteredDarkBrandColor = darkBrandColor;
|
||||
const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value;
|
||||
const enteredWeekStartDay = selectedWeekStartDay.value;
|
||||
const enteredHideBranding = hideBrandingRef.current.checked;
|
||||
const enteredAllowDynamicGroupBooking = allowDynamicGroupBookingRef.current.checked;
|
||||
const enteredLanguage = selectedLanguage.value;
|
||||
const enteredTimeFormat = selectedTimeFormat.value;
|
||||
|
||||
// Write time format to localStorage if available
|
||||
// Embed isn't applicable to profile pages. So ignore the rule
|
||||
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||
setIs24hClockInLocalStorage(selectedTimeFormat.value === 24);
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
mutation.mutate({
|
||||
username: enteredUsername,
|
||||
name: enteredName,
|
||||
email: enteredEmail,
|
||||
bio: enteredDescription,
|
||||
avatar: enteredAvatar,
|
||||
timeZone: enteredTimeZone,
|
||||
weekStart: asStringOrUndefined(enteredWeekStartDay),
|
||||
hideBranding: enteredHideBranding,
|
||||
allowDynamicBooking: enteredAllowDynamicGroupBooking,
|
||||
theme: asStringOrNull(selectedTheme?.value),
|
||||
brandColor: enteredBrandColor,
|
||||
darkBrandColor: enteredDarkBrandColor,
|
||||
locale: enteredLanguage,
|
||||
timeFormat: enteredTimeFormat,
|
||||
});
|
||||
}
|
||||
const [currentUsername, setCurrentUsername] = useState(user.username || undefined);
|
||||
const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pt-6 pb-4 lg:pb-8">
|
||||
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
|
||||
<div className="w-full">
|
||||
<UsernameAvailability
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={inputUsernameValue}
|
||||
usernameRef={usernameRef}
|
||||
setInputUsernameValue={setInputUsernameValue}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="pb-6 lg:pb-8">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="block sm:flex">
|
||||
<div className="w-full">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("full_name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
autoComplete="given-name"
|
||||
placeholder={t("your_name")}
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800"
|
||||
defaultValue={user.name || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block sm:flex">
|
||||
<div className="mb-6 w-full sm:w-1/2">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder={t("your_email")}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm focus:border-neutral-800 focus:ring-neutral-800"
|
||||
defaultValue={user.email}
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||
{t("change_email_tip")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
||||
{t("about")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
id="about"
|
||||
name="about"
|
||||
placeholder={t("little_something_about")}
|
||||
rows={3}
|
||||
defaultValue={user.bio || undefined}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm focus:border-neutral-800 focus:ring-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-1 flex">
|
||||
<Avatar
|
||||
alt={user.name || ""}
|
||||
className="relative h-10 w-10 rounded-full"
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<div className="flex items-center px-5">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("change_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
avatarRef.current.value = newAvatar;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current.dispatchEvent(ev2);
|
||||
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
|
||||
setImageSrc(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="language" className="block text-sm font-medium text-gray-700">
|
||||
{t("language")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
id="languageSelect"
|
||||
value={selectedLanguage || props.localeProp}
|
||||
onChange={(v) => v && setSelectedLanguage(v)}
|
||||
className="mt-1 block w-full rounded-sm text-sm capitalize"
|
||||
options={localeOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(v) => v && setSelectedTimeZone(v)}
|
||||
className="mt-1 block w-full rounded-sm text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="timeFormat" className="block text-sm font-medium text-gray-700">
|
||||
{t("time_format")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
id="timeFormatSelect"
|
||||
value={selectedTimeFormat || user.timeFormat}
|
||||
onChange={(v) => v && setSelectedTimeFormat(v)}
|
||||
className="mt-1 block w-full rounded-sm text-sm capitalize"
|
||||
options={timeFormatOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="weekStart" className="block text-sm font-medium text-gray-700">
|
||||
{t("first_day_of_week")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
id="weekStart"
|
||||
value={selectedWeekStartDay}
|
||||
onChange={(v) => v && setSelectedWeekStartDay(v)}
|
||||
className="mt-1 block w-full rounded-sm text-sm capitalize"
|
||||
options={[
|
||||
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
|
||||
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
|
||||
{ value: "Tuesday", label: nameOfDay(props.localeProp, 2) },
|
||||
{ value: "Wednesday", label: nameOfDay(props.localeProp, 3) },
|
||||
{ value: "Thursday", label: nameOfDay(props.localeProp, 4) },
|
||||
{ value: "Friday", label: nameOfDay(props.localeProp, 5) },
|
||||
{ value: "Saturday", label: nameOfDay(props.localeProp, 6) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-8 flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="dynamic-group-booking"
|
||||
name="dynamic-group-booking"
|
||||
type="checkbox"
|
||||
ref={allowDynamicGroupBookingRef}
|
||||
defaultChecked={props.user.allowDynamicBooking || false}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<label
|
||||
htmlFor="dynamic-group-booking"
|
||||
className="flex items-center font-medium text-gray-700">
|
||||
{t("allow_dynamic_booking")} <InfoBadge content={t("allow_dynamic_booking_tooltip")} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
|
||||
{t("single_theme")}
|
||||
</label>
|
||||
<div className="my-1">
|
||||
<Select
|
||||
id="theme"
|
||||
isDisabled={!selectedTheme}
|
||||
defaultValue={selectedTheme || themeOptions[0]}
|
||||
value={selectedTheme || themeOptions[0]}
|
||||
onChange={(v) => v && setSelectedTheme(v)}
|
||||
className="mt-1 block w-full rounded-sm text-sm"
|
||||
options={themeOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative mt-8 flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="theme-adjust-os"
|
||||
name="theme-adjust-os"
|
||||
type="checkbox"
|
||||
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
|
||||
checked={!selectedTheme}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<label htmlFor="theme-adjust-os" className="font-medium text-gray-700">
|
||||
{t("automatically_adjust_theme")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
|
||||
<div className="mb-2 sm:w-1/2">
|
||||
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
|
||||
{t("light_brand_color")}
|
||||
</label>
|
||||
<ColorPicker defaultValue={user.brandColor} onChange={setBrandColor} />
|
||||
</div>
|
||||
<div className="mb-2 sm:w-1/2">
|
||||
<label htmlFor="darkBrandColor" className="block text-sm font-medium text-gray-700">
|
||||
{t("dark_brand_color")}
|
||||
</label>
|
||||
<ColorPicker defaultValue={user.darkBrandColor} onChange={setDarkBrandColor} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<HideBrandingInput user={user} hideBrandingRef={hideBrandingRef} />
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}{" "}
|
||||
{user.plan !== "PRO" && <Badge variant="default">TEAM</Badge>}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-md mt-7 font-bold leading-6 text-red-700">{t("danger_zone")}</h3>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="warn"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="border-2 border-red-700 text-red-700"
|
||||
data-testid="delete-account">
|
||||
{t("delete_account")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_account")}
|
||||
confirmBtn={
|
||||
<Button color="warn" data-testid="delete-account-confirm">
|
||||
{t("confirm_delete_account")}
|
||||
</Button>
|
||||
}
|
||||
onConfirm={onConfirmButton}>
|
||||
<p className="mb-7">{t("delete_account_confirmation_message")}</p>
|
||||
<PasswordField
|
||||
data-testid="password"
|
||||
name="password"
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
label="Password"
|
||||
ref={passwordRef}
|
||||
/>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<Form handleSubmit={onConfirm} className="pb-4" form={form}>
|
||||
<TwoFactor center={false} />
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{hasDeleteErrors && <Alert severity="error" title={deleteErrorMessage} />}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-8" />
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
* We intend to not cache i18n query
|
||||
**/
|
||||
const WithQuery = withQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } });
|
||||
|
||||
export default function Settings(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<SettingsShell heading={t("profile")} subtitle={t("edit_profile_info_description")}>
|
||||
<WithQuery success={({ data }) => <SettingsView {...props} localeProp={data.locale} />} />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const session = await getSession(context);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
hideBranding: true,
|
||||
theme: true,
|
||||
plan: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
metadata: true,
|
||||
twoFactorEnabled: true,
|
||||
timeFormat: true,
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User seems logged in but cannot be found in the db");
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
user: {
|
||||
...user,
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import React from "react";
|
||||
|
||||
import SAMLConfiguration from "@calcom/features/ee/common/components/SamlConfiguration";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { identityProviderNameMap } from "@lib/auth";
|
||||
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import ChangePasswordSection from "@components/security/ChangePasswordSection";
|
||||
import DisableUserImpersonation from "@components/security/DisableUserImpersonation";
|
||||
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
|
||||
|
||||
export default function Security() {
|
||||
const user = trpc.useQuery(["viewer.me"]).data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<SettingsShell heading={t("security")} subtitle={t("manage_account_security")}>
|
||||
<>
|
||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">
|
||||
{t("account_managed_by_identity_provider", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{t("account_managed_by_identity_provider_description", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2 divide-y">
|
||||
<ChangePasswordSection />
|
||||
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} />
|
||||
<DisableUserImpersonation disableImpersonation={user?.disableImpersonation ?? true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SAMLConfiguration teamsView={false} teamId={null} />
|
||||
</>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@calcom/features/ee/teams/pages/availability";
|
||||
@@ -1,181 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import SAMLConfiguration from "@calcom/features/ee/common/components/SamlConfiguration";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SkeletonAvatar, SkeletonText } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import DisableTeamImpersonation from "@components/team/DisableTeamImpersonation";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import MemberList from "@components/team/MemberList";
|
||||
import TeamSettings from "@components/team/TeamSettings";
|
||||
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
|
||||
import { UpgradeToFlexibleProModal } from "@components/team/UpgradeToFlexibleProModal";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
export function TeamSettingsPage() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const userId = useCurrentUserId();
|
||||
|
||||
const upgraded = router.query.upgraded as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (upgraded) {
|
||||
showToast(t("team_upgraded_successfully"), "success");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const query = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
loading={() => {
|
||||
return (
|
||||
<Shell
|
||||
backPath={!errorMessage ? `/settings/teams` : undefined}
|
||||
heading={
|
||||
<div className="pt-2">
|
||||
<SkeletonText width="16" height="6" />
|
||||
</div>
|
||||
}
|
||||
subtitle={<SkeletonText className="h-4 w-12" />}
|
||||
HeadingLeftIcon={<SkeletonAvatar className="mt-1 h-12 w-12" />}>
|
||||
<>
|
||||
<div className="block sm:flex md:max-w-5xl">
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
|
||||
<div className="-mx-0 h-[531px] rounded-sm border border-neutral-200 bg-white px-4 sm:px-6" />
|
||||
<div className="mb-3 mt-7 flex items-center justify-between">
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</div>
|
||||
<div className="-mx-0 h-16 rounded-sm border border-neutral-200 bg-white px-4 sm:px-6" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Shell>
|
||||
);
|
||||
}}
|
||||
success={({ data: team }) => {
|
||||
const isAdmin =
|
||||
team &&
|
||||
(team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
return (
|
||||
<Shell
|
||||
backPath={!errorMessage ? `/settings/teams` : undefined}
|
||||
heading={team?.name}
|
||||
subtitle={team && t("manage_this_team")}
|
||||
HeadingLeftIcon={
|
||||
team && (
|
||||
<Avatar
|
||||
size={12}
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="mt-1"
|
||||
/>
|
||||
)
|
||||
}>
|
||||
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
|
||||
{team && (
|
||||
<>
|
||||
<div className="block sm:flex md:max-w-5xl">
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
|
||||
{team.membership.role === MembershipRole.OWNER &&
|
||||
team.membership.isMissingSeat &&
|
||||
team.requiresUpgrade ? (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("hidden_team_member_title")}
|
||||
message={
|
||||
<>
|
||||
{t("hidden_team_owner_message")} <UpgradeToFlexibleProModal teamId={team.id} />
|
||||
{/* <a href={"https://cal.com/upgrade"} className="underline">
|
||||
{"https://cal.com/upgrade"}
|
||||
</a> */}
|
||||
</>
|
||||
}
|
||||
className="mb-4 "
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{team.membership.isMissingSeat && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("hidden_team_member_title")}
|
||||
message={t("hidden_team_member_message")}
|
||||
className="mb-4 "
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="-mx-0 rounded-sm border border-neutral-200 bg-white px-4 sm:px-6">
|
||||
{isAdmin ? (
|
||||
<TeamSettings team={team} />
|
||||
) : (
|
||||
<div className="py-5">
|
||||
<span className="mb-1 font-bold">{t("team_info")}</span>
|
||||
<p className="text-sm text-gray-700">{team.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-3 mt-7 flex items-center justify-between">
|
||||
<h3 className="font-cal text-xl leading-6 text-gray-900">{t("members")}</h3>
|
||||
{isAdmin && (
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => setShowMemberInvitationModal(true)}
|
||||
data-testid="new-member-button">
|
||||
{t("new_member")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MemberList team={team} members={team.members || []} />
|
||||
{isAdmin && <SAMLConfiguration teamsView={true} teamId={team.id} />}
|
||||
{userId && <DisableTeamImpersonation teamId={team.id} memberId={userId} />}
|
||||
</div>
|
||||
<div className="min-w-32 mt-8 w-full px-2 ltr:ml-2 rtl:mr-2 sm:mt-0 md:w-3/12">
|
||||
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
|
||||
</div>
|
||||
</div>
|
||||
{showMemberInvitationModal && (
|
||||
<MemberInvitationModal
|
||||
isOpen={showMemberInvitationModal}
|
||||
team={team}
|
||||
currentMember={team.membership.role}
|
||||
onExit={() => setShowMemberInvitationModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamSettingsPage;
|
||||
@@ -1,88 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
|
||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||
import TeamList from "@components/team/TeamList";
|
||||
|
||||
function Teams() {
|
||||
const { t } = useLocale();
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const me = useMeQuery();
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const teams = data?.filter((m) => m.accepted) || [];
|
||||
const invites = data?.filter((m) => !m.accepted) || [];
|
||||
const isFreePlan = me.data?.plan === "FREE";
|
||||
|
||||
return (
|
||||
<SettingsShell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||
<>
|
||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
{isFreePlan && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={<>{t("plan_upgrade_teams")}</>}
|
||||
message={
|
||||
<Trans i18nKey="plan_upgrade_instructions">
|
||||
You can
|
||||
<a href="/api/upgrade" className="underline">
|
||||
upgrade here
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
}
|
||||
className="my-4"
|
||||
/>
|
||||
)}
|
||||
{showCreateTeamModal && (
|
||||
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
|
||||
)}
|
||||
<div className={classNames("my-4 flex justify-end", isFreePlan && "opacity-50")}>
|
||||
<Button
|
||||
disabled={isFreePlan}
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => setShowCreateTeamModal(true)}>
|
||||
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-gray-700 group-hover:text-black ltr:mr-2 rtl:ml-2" />
|
||||
{t("new_team")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||
<TeamList teams={invites} />
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <SkeletonLoaderTeamList />}
|
||||
{!teams.length && !isLoading && (
|
||||
<EmptyScreen Icon={Icon.FiUsers} headline={t("no_teams")} description={t("no_teams_description")} />
|
||||
)}
|
||||
{teams.length > 0 && <TeamList teams={teams} />}
|
||||
</>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
Teams.requiresLicense = false;
|
||||
|
||||
export default Teams;
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import { EmailField, PasswordField, TextField } from "@calcom/ui/v2/core/form/fields";
|
||||
import { HeadSeo } from "@calcom/web/components/seo/head-seo";
|
||||
import { asStringOrNull } from "@calcom/web/lib/asStringOrNull";
|
||||
import { WEBAPP_URL } from "@calcom/web/lib/config/constants";
|
||||
import prisma from "@calcom/web/lib/prisma";
|
||||
import { isSAMLLoginEnabled } from "@calcom/web/lib/saml";
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/web/server/lib/constants";
|
||||
import { ssrInit } from "@calcom/web/server/lib/ssr";
|
||||
|
||||
type FormValues = {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
passwordcheck: string;
|
||||
apiError: string;
|
||||
};
|
||||
|
||||
export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const methods = useForm<FormValues>({
|
||||
defaultValues: prepopulateFormValues,
|
||||
});
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
} = methods;
|
||||
|
||||
const handleErrors = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (data) => {
|
||||
await fetch("/api/auth/signup", {
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
})
|
||||
.then(handleErrors)
|
||||
.then(
|
||||
async () =>
|
||||
await signIn("Cal.com", {
|
||||
callbackUrl: (`${WEBAPP_URL}/${router.query.callbackUrl}` || "") as string,
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
methods.setError("apiError", { message: err.message });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div
|
||||
className="flex min-h-screen flex-col justify-center bg-gray-50 py-12 sm:px-6 lg:px-8"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="font-cal text-center text-3xl font-extrabold text-gray-900">
|
||||
{t("create_your_account")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="mx-2 bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(signUp)} className="space-y-6 bg-white">
|
||||
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
|
||||
<div className="space-y-2">
|
||||
<TextField
|
||||
addOnLeading={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/`}
|
||||
{...register("username")}
|
||||
required
|
||||
/>
|
||||
<EmailField {...register("email")} />
|
||||
<PasswordField
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-gray-700",
|
||||
}}
|
||||
{...register("password")}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
|
||||
/>
|
||||
<PasswordField
|
||||
label={t("confirm_password")}
|
||||
{...register("passwordcheck", {
|
||||
validate: (value) =>
|
||||
value === methods.watch("password") || (t("error_password_mismatch") as string),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button type="submit" loading={isSubmitting} className="w-7/12 justify-center">
|
||||
{t("create_account")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="w-5/12 justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: (`${WEBAPP_URL}/${router.query.callbackUrl}` || "") as string,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(ctx);
|
||||
const token = asStringOrNull(ctx.query.token);
|
||||
|
||||
const props = {
|
||||
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
|
||||
isSAMLLoginEnabled,
|
||||
trpcState: ssr.dehydrate(),
|
||||
prepopulateFormValues: undefined,
|
||||
};
|
||||
|
||||
// no token given, treat as a normal signup without verification token
|
||||
if (!token) {
|
||||
return {
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: verificationToken?.identifier,
|
||||
},
|
||||
{
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/auth/login?callbackUrl=" + `${WEBAPP_URL}/${ctx.query.callbackUrl}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...props,
|
||||
prepopulateFormValues: {
|
||||
email: verificationToken.identifier,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+340
-323
@@ -10,7 +10,9 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { RRule } from "rrule";
|
||||
import { z } from "zod";
|
||||
|
||||
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 {
|
||||
@@ -28,14 +30,13 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma from "@calcom/prisma";
|
||||
import prisma, { baseUserSelect } from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Logo from "@calcom/ui/Logo";
|
||||
import { EmailInput } from "@calcom/ui/form/fields";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import CancelBooking from "@components/booking/CancelBooking";
|
||||
@@ -172,8 +173,9 @@ export default function Success(props: SuccessProps) {
|
||||
location: location,
|
||||
t,
|
||||
};
|
||||
const metadata = props.eventType?.metadata as { giphyThankYouPage: string };
|
||||
const giphyImage = metadata?.giphyThankYouPage;
|
||||
|
||||
const giphyAppData = getEventTypeAppData(eventType, "giphy");
|
||||
const giphyImage = giphyAppData?.thankYouPage;
|
||||
|
||||
const eventName = getEventName(eventNameObject, true);
|
||||
const needsConfirmation = eventType.requiresConfirmation && reschedule != "true";
|
||||
@@ -258,7 +260,7 @@ export default function Success(props: SuccessProps) {
|
||||
);
|
||||
const customInputs = bookingInfo?.customInputs;
|
||||
|
||||
const locationToDisplay = getSuccessPageLocationMessage(location, t, props?.bookingInfo?.status);
|
||||
const locationToDisplay = getSuccessPageLocationMessage(location, t);
|
||||
|
||||
return (
|
||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||
@@ -272,6 +274,7 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
)}
|
||||
<HeadSeo title={title} description={title} />
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
||||
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
||||
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
||||
@@ -286,151 +289,158 @@ export default function Success(props: SuccessProps) {
|
||||
aria-hidden="true">
|
||||
<div
|
||||
className={classNames(
|
||||
"inline-block transform overflow-hidden rounded-md border sm:my-8 sm:max-w-lg",
|
||||
"inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 bg-white dark:border-neutral-700",
|
||||
"px-8 pt-5 pb-4 text-left align-bottom transition-all sm:w-full sm:py-6 sm:align-middle"
|
||||
"px-8 pt-5 pb-4 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mx-auto flex items-center justify-center",
|
||||
!giphyImage && !isCancelled ? "h-12 w-12 rounded-full bg-green-100" : "",
|
||||
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
|
||||
)}>
|
||||
{giphyImage && !needsConfirmation && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
||||
<Icon.FiCheck className="h-8 w-8 text-green-600" />
|
||||
)}
|
||||
{needsConfirmation && !isCancelled && <Icon.FiClock className="h-8 w-8 text-green-600" />}
|
||||
{isCancelled && <Icon.FiX className="h-8 w-8 text-red-600" />}
|
||||
<div
|
||||
className={classNames(
|
||||
"mx-auto flex items-center justify-center",
|
||||
!giphyImage && !isCancelled && !needsConfirmation
|
||||
? "h-12 w-12 rounded-full bg-green-100"
|
||||
: "",
|
||||
!giphyImage && !isCancelled && needsConfirmation
|
||||
? "h-12 w-12 rounded-full bg-gray-100"
|
||||
: "",
|
||||
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
|
||||
)}>
|
||||
{giphyImage && !needsConfirmation && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
||||
<Icon.FiCheck className="h-5 w-5 text-green-600" />
|
||||
)}
|
||||
{needsConfirmation && !isCancelled && <Icon.FiCalendar className="h-5 w-5 text-gray-900" />}
|
||||
{isCancelled && <Icon.FiX className="h-5 w-5 text-red-600" />}
|
||||
</div>
|
||||
<div className="mt-6 mb-8 text-center last:mb-0">
|
||||
<h3
|
||||
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
||||
id="modal-headline">
|
||||
{needsConfirmation && !isCancelled
|
||||
? props.recurringBookings
|
||||
? t("submitted_recurring")
|
||||
: t("submitted")
|
||||
: isCancelled
|
||||
? t("event_cancelled")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
</h3>
|
||||
<div className="mt-3">
|
||||
<p className="text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3
|
||||
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
||||
id="modal-headline">
|
||||
{needsConfirmation && !isCancelled
|
||||
? props.recurringBookings
|
||||
? t("submitted_recurring")
|
||||
: t("submitted")
|
||||
: isCancelled
|
||||
? t("event_cancelled")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
</h3>
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
||||
<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">
|
||||
<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>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
<RecurringBookings
|
||||
eventType={props.eventType}
|
||||
recurringBookings={props.recurringBookings}
|
||||
listingStatus={(listingStatus as string) || "recurring"}
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:text-gray-300">
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6">{eventName}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
<RecurringBookings
|
||||
eventType={props.eventType}
|
||||
recurringBookings={props.recurringBookings}
|
||||
listingStatus={(listingStatus as string) || "recurring"}
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
/>
|
||||
</div>
|
||||
{(bookingInfo?.user || bookingInfo?.attendees) && (
|
||||
<>
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>{bookingInfo.user.name}</p>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
{(bookingInfo?.user || bookingInfo?.attendees) && (
|
||||
<>
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>{bookingInfo.user.name}</p>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee, index) => (
|
||||
<div
|
||||
key={attendee.name}
|
||||
className={index === bookingInfo.attendees.length - 1 ? "" : "mb-3"}>
|
||||
<p>{attendee.name}</p>
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{locationToDisplay && (
|
||||
<>
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3">
|
||||
{locationToDisplay.startsWith("http") ? (
|
||||
<a title="Meeting Link" href={locationToDisplay}>
|
||||
{locationToDisplay}
|
||||
</a>
|
||||
) : (
|
||||
locationToDisplay
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{bookingInfo?.description && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("additional_notes")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p>{bookingInfo.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{customInputs &&
|
||||
Object.keys(customInputs).map((key) => {
|
||||
const customInput = customInputs[key as keyof typeof customInputs];
|
||||
return (
|
||||
<>
|
||||
{customInput !== "" && (
|
||||
<>
|
||||
<div className="mt-2 pr-3 font-medium">{key}</div>
|
||||
<div className="col-span-2 mt-2 mb-2">
|
||||
{typeof customInput === "boolean" ? (
|
||||
<p>{customInput ? "true" : "false"}</p>
|
||||
) : (
|
||||
<p>{customInput}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee, index) => (
|
||||
<div
|
||||
key={attendee.name}
|
||||
className={index === bookingInfo.attendees.length - 1 ? "" : "mb-3"}>
|
||||
<p>{attendee.name}</p>
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{locationToDisplay && (
|
||||
<>
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3">
|
||||
{locationToDisplay.startsWith("http") ? (
|
||||
<a title="Meeting Link" href={locationToDisplay}>
|
||||
{locationToDisplay}
|
||||
</a>
|
||||
) : (
|
||||
locationToDisplay
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{bookingInfo?.description && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("additional_notes")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p>{bookingInfo.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{customInputs &&
|
||||
Object.keys(customInputs).map((key) => {
|
||||
const customInput = customInputs[key as keyof typeof customInputs];
|
||||
return (
|
||||
<>
|
||||
{customInput !== "" && (
|
||||
<>
|
||||
<div className="mt-2 pr-3 font-medium">{key}</div>
|
||||
<div className="col-span-2 mt-2 mb-2">
|
||||
{typeof customInput === "boolean" ? (
|
||||
<p>{customInput ? "true" : "false"}</p>
|
||||
) : (
|
||||
<p>{customInput}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!needsConfirmation &&
|
||||
!isCancelled &&
|
||||
(!isCancellationMode ? (
|
||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-2 grid-cols-3 border-b py-4 text-left sm:grid">
|
||||
<span className="font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("need_to_make_a_change")}
|
||||
</span>
|
||||
<div
|
||||
className={classNames(
|
||||
"col-span-2 items-center dark:text-gray-50",
|
||||
!props.recurringBookings ? "flex" : ""
|
||||
)}>
|
||||
<button className="underline" onClick={() => setIsCancellationMode(true)}>
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<div className="py-8 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>
|
||||
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-bookinglight inline text-gray-700">
|
||||
<span className="underline">
|
||||
<Link href={"/reschedule/" + bookingInfo?.uid}>{t("reschedule")}</Link>
|
||||
</span>
|
||||
<span className="mx-2">{t("or_lowercase")}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={classNames(
|
||||
"text-bookinglight text-gray-700 underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
)}
|
||||
onClick={() => setIsCancellationMode(true)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
{!props.recurringBookings && (
|
||||
<>
|
||||
<div className="mx-2">{t("or_lowercase")}</div>
|
||||
<div className="underline">
|
||||
<Link href={"/reschedule/" + bookingInfo?.uid}>{t("reschedule")}</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CancelBooking
|
||||
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title }}
|
||||
@@ -442,138 +452,143 @@ export default function Success(props: SuccessProps) {
|
||||
/>
|
||||
))}
|
||||
{userIsOwner && !needsConfirmation && !isCancellationMode && !isCancelled && (
|
||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-2 grid-cols-3 border-b py-4 text-left sm:grid">
|
||||
<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>
|
||||
<div className="justify-left mt-1 flex flex-grow text-left sm:mt-0">
|
||||
<Link
|
||||
href={
|
||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||
.add(props.eventType.length, "minute")
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
props.eventType.description
|
||||
}` +
|
||||
(typeof location === "string" ? "&location=" + encodeURIComponent(location) : "") +
|
||||
(props.eventType.recurringEvent
|
||||
? "&recur=" +
|
||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||
: "")
|
||||
}>
|
||||
<a className="mr-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Google</title>
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(props.eventType.length, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}>
|
||||
<a
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Outlook</title>
|
||||
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(props.eventType.length, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}>
|
||||
<a
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Office</title>
|
||||
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={"data:text/calendar," + eventLink()}>
|
||||
<a
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
download={props.eventType.title + ".ics"}>
|
||||
<svg
|
||||
version="1.1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4">
|
||||
<title>{t("other")}</title>
|
||||
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<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>
|
||||
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
||||
<Link
|
||||
href={
|
||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||
.add(props.eventType.length, "minute")
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
props.eventType.description
|
||||
}` +
|
||||
(typeof location === "string"
|
||||
? "&location=" + encodeURIComponent(location)
|
||||
: "") +
|
||||
(props.eventType.recurringEvent
|
||||
? "&recur=" +
|
||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||
: "")
|
||||
}>
|
||||
<a className="mr-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Google</title>
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(props.eventType.length, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}>
|
||||
<a
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Outlook</title>
|
||||
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(props.eventType.length, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}>
|
||||
<a
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Office</title>
|
||||
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={"data:text/calendar," + eventLink()}>
|
||||
<a
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
download={props.eventType.title + ".ics"}>
|
||||
<svg
|
||||
version="1.1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4">
|
||||
<title>{t("other")}</title>
|
||||
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{session === null && !(userIsOwner || props.hideBranding) && (
|
||||
<div className="border-bookinglightest text-booking-lighter dark:border-darkgray-200 pt-4 text-center text-xs dark:text-white">
|
||||
<a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
|
||||
<>
|
||||
<hr className="border-bookinglightest" />
|
||||
<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>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target as typeof e.target & {
|
||||
email: { value: string };
|
||||
};
|
||||
router.push(`https://cal.com/signup?email=${target.email.value}`);
|
||||
}}
|
||||
className="mt-4 flex">
|
||||
<EmailInput
|
||||
name="email"
|
||||
id="email"
|
||||
defaultValue={router.query.email}
|
||||
className="focus:border-brand border-bookinglightest dark:border-darkgray-200 mt-0 block w-full rounded-sm 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">
|
||||
{t("try_for_free")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target as typeof e.target & {
|
||||
email: { value: string };
|
||||
};
|
||||
router.push(`https://cal.com/signup?email=${target.email.value}`);
|
||||
}}
|
||||
className="mt-4 flex">
|
||||
<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"
|
||||
placeholder="rick.astley@cal.com"
|
||||
/>
|
||||
<Button size="lg" type="submit" className="min-w-max" color="primary">
|
||||
{t("try_for_free")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-4 flex justify-center md:-mt-12">
|
||||
<Logo animated />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -605,52 +620,60 @@ export function RecurringBookings({
|
||||
? recurringBookings.sort((a, b) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1))
|
||||
: null;
|
||||
|
||||
return recurringBookingsSorted && listingStatus === "recurring" ? (
|
||||
<>
|
||||
{eventType.recurringEvent?.count && (
|
||||
<span className="font-medium">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringBookings?.length ?? undefined,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(0, 4).map((dateStr, idx) => (
|
||||
<div key={idx} className="mb-2">
|
||||
{dayjs(dateStr).format("MMMM DD, YYYY")}
|
||||
<br />
|
||||
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{recurringBookingsSorted.length > 4 && (
|
||||
<Collapsible open={moreEventsVisible} onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className={classNames("flex w-full", moreEventsVisible ? "hidden" : "")}>
|
||||
{t("plus_more", { count: recurringBookingsSorted.length - 4 })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(4).map((dateStr, idx) => (
|
||||
<div key={idx} className="mb-2">
|
||||
{dayjs(dateStr).format("MMMM DD, YYYY")}
|
||||
<br />
|
||||
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
if (recurringBookingsSorted && listingStatus === "recurring") {
|
||||
// recurring bookings should only be adjusted to the start date.
|
||||
const utcOffset = dayjs(recurringBookingsSorted[0]).utcOffset();
|
||||
return (
|
||||
<>
|
||||
{eventType.recurringEvent?.count && (
|
||||
<span className="font-medium">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringBookings?.length ?? undefined,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(0, 4).map((dateStr, idx) => (
|
||||
<div key={idx} className="mb-2">
|
||||
{dayjs(dateStr).utcOffset(utcOffset).format("MMMM DD, YYYY")}
|
||||
<br />
|
||||
{dayjs(dateStr).utcOffset(utcOffset).format("LT")} -{" "}
|
||||
{dayjs(dateStr).utcOffset(utcOffset).add(eventType.length, "m").format("LT")}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{recurringBookingsSorted.length > 4 && (
|
||||
<Collapsible open={moreEventsVisible} onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className={classNames("flex w-full", moreEventsVisible ? "hidden" : "")}>
|
||||
{t("plus_more", { count: recurringBookingsSorted.length - 4 })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(4).map((dateStr, idx) => (
|
||||
<div key={idx} className="mb-2">
|
||||
{dayjs(dateStr).utcOffset(utcOffset).format("MMMM DD, YYYY")}
|
||||
<br />
|
||||
{dayjs(dateStr).utcOffset(utcOffset).format("LT")} -{" "}
|
||||
{dayjs(dateStr).utcOffset(utcOffset).add(eventType.length, "m").format("LT")}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{date.format("MMMM DD, YYYY")}
|
||||
<br />
|
||||
@@ -678,6 +701,8 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
userId: true,
|
||||
successRedirectUrl: true,
|
||||
locations: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -707,9 +732,12 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
|
||||
return {
|
||||
isDynamic: false,
|
||||
...eventType,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -722,7 +750,7 @@ const strToNumber = z.string().transform((val, ctx) => {
|
||||
const schema = z.object({
|
||||
type: strToNumber,
|
||||
date: z.string().optional(),
|
||||
user: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
reschedule: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
@@ -743,7 +771,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventSlug: eventTypeSlug,
|
||||
eventName: dynamicEventName,
|
||||
bookingId,
|
||||
user: username,
|
||||
username,
|
||||
name,
|
||||
email,
|
||||
} = parsedQuery.data;
|
||||
@@ -761,18 +789,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
where: {
|
||||
id: eventTypeRaw.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
plan: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
},
|
||||
select: baseUserSelect,
|
||||
});
|
||||
if (user) {
|
||||
eventTypeRaw.users.push(user);
|
||||
@@ -787,6 +804,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
|
||||
@@ -822,7 +840,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
select: {
|
||||
title: true,
|
||||
uid: true,
|
||||
status: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
user: {
|
||||
@@ -855,7 +872,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useEffect } from "react";
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
@@ -17,7 +18,6 @@ import { Button } from "@calcom/ui/v2/core";
|
||||
import EventTypeDescription from "@calcom/ui/v2/modules/event-types/EventTypeDescription";
|
||||
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -56,7 +56,6 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
<div className="flex-shrink">
|
||||
<div className="flex flex-wrap items-center space-x-2">
|
||||
<h2 className="dark:text-darkgray-700 text-sm font-semibold text-gray-700">{type.title}</h2>
|
||||
<p className="dark:text-darkgray-600 hidden text-sm font-normal leading-none text-gray-600 md:block">{`/${team.slug}/${type.slug}`}</p>
|
||||
</div>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { privacyFilteredLocations, LocationObject } from "@calcom/core/location";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
@@ -116,9 +116,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
eventType.schedule = null;
|
||||
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
console.log("locations", locations);
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user