Compare commits

...
34 Commits
Author SHA1 Message Date
zomars 6415d42a1d v2.2.0
release.yml / release (push) Canceled after 0s
2022-11-16 14:22:08 -07:00
Omar Lópezandalannnc ee620c7fff Adds team upgrade banner (#5541)
* Adds team upgrade banner

* Update TeamListItem.tsx

* Update payments.ts

* Copy updates and mobile style fixes

* Update packages/trpc/server/routers/viewer/teams.tsx

Co-authored-by: alannnc <alannnc@gmail.com>

Co-authored-by: alannnc <alannnc@gmail.com>
2022-11-16 21:07:20 +00:00
Jeroen Reumkens 112efbbda2 CAL-300 Ensure we show the default calendar name based in the advanced tab for event types. (#5551) 2022-11-16 20:03:19 +00:00
fac0b0fa32 Improving cancellation flow (#5447)
* Booking succes query refactor

The query is now using the uid as its main identifier for the success page

* Minor changes to the succes.tsx and tests

* Convert eventtype dates to string, and only select eventtype slug from db to have a smaller query (we don't need more data, and this way we don't need to convert the dates in here to smaller strings either.)

* In the payment component get the bookingUid from props instead of the query

* design fixes

* fix UI for recurring bookings

* fix success page for recurring events

* fix cancelling recurring events

* fix design of successful cancellation page

* fix email redirect + design fixes

* remove old cancel page

* fix v2 design for text area

* Changed the recurringMutation to use the uid for the success booking page

* redirect form old to new cancel page

* fix success page for recurring events

* rename to allRemainingbookings

* fix old cancel redirect

* fix recurring cancel link in email

* fix redirect when cancelling one recurring booking

* remove reschedule for recurring events in email

* create queryschema for cancel success page

* scroll down to bottom if cancel

* add cancellation reason

* fix tests

* fix old /cancel component

* code clean up

* Uses URL query as cancellation state

Also fixes string to boolean inference

Co-authored-by: Mischa Rouleaux <mischa-rouleaux@live.nl>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2022-11-16 19:48:17 +00:00
Jeroen Reumkens 12d47ab949 Update UI components to be able to use them in Console (#5514)
* First steps in updating console ui components.

* Replaced remaining v1 components.

* Removed todo.

* Removed createelement from form component since we were not using it at all (we wrapped all our inputs in divs), and any input that was not wrapped, was actually broken...

* Undo update submodule.

* Reverted website submodule update.
2022-11-16 13:41:40 +00:00
4fef7ba5e2 Feat/top banner (#5443)
* wip

* wip

* chore: remove unused import

* feat: top banner component added

* feat: top banner stories added

* fix: icon bug

* Update packages/ui/components/topbanner/TopBanner.tsx

Co-authored-by: Omar López <zomars@me.com>

* Apply suggestions from code review

* Export fixes

* Feedback

* Type fixes

* Fixes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-11-15 12:33:59 -07:00
6af0428a18 Booking success query refactor (#5298)
* Booking succes query refactor

The query is now using the uid as its main identifier for the success page

* Minor changes to the succes.tsx and tests

* Convert eventtype dates to string, and only select eventtype slug from db to have a smaller query (we don't need more data, and this way we don't need to convert the dates in here to smaller strings either.)

* In the payment component get the bookingUid from props instead of the query

* Changed the recurringMutation to use the uid for the success booking page

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-11-15 12:00:02 -07:00
Jeroen Reumkensandkodiakhq[bot] d66f3d1dc9 Event row style improvements (#5533)
* Made full row of event type clickable, and fixed event type loader dimensions.

* Fixed skeletons for teams and workflows.

* Fixed skeleton for workflow detail page.

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-11-15 15:30:24 +00:00
Udit Takkar a5249b6cb5 fix: use tooltip and max-w (#5531) 2022-11-15 14:24:23 +00:00
Udit Takkar 6771c11360 fix: remove type password from fields (#5530) 2022-11-15 14:08:25 +00:00
002375ac72 fix langauge in booking submitted email for attendee (#5526)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-11-15 13:55:02 +00:00
Alex van Andel 0402e46328 Reformat /booking attendees, add guests to success page, fix i18n (#5532) 2022-11-15 13:39:06 +00:00
Jeroen ReumkensandPeer Richelsen 02e3738a17 Improved event description truncation (actually removed it mostly) (#5374)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-11-15 08:36:20 +00:00
Alex van Andel f822177788 Use Intl.DateTimeFormat logic to determine locale. (#5519) 2022-11-14 19:46:58 +00:00
Alex van Andel 1edc9c50d5 Removed an invalidate onDelete + updated test (#5521) 2022-11-14 19:34:47 +00:00
Syed Ali Shahbaz 7beb17c007 stops content pushing on windows browsers due to overflow-y (#5513) 2022-11-14 11:07:44 +00:00
Hariom Balharaandkodiakhq[bot] ad7af451ff Fix text getting changed after browsing through Reporting tab (#5510)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-11-14 10:53:35 +00:00
f513ce8687 Removed unused ui components and updated skeleton components (#5412)
* Removed unused ui components and updated skeleton components throughout the app to use new version.

* Deleted unused app component and changed destructive button variants to secondary as per design.

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-11-14 10:31:36 +00:00
d83883e7e4 Minimum booking notice will allow hours and days (#5217)
* minimum booking notice allows hours and days

* Fixed error with Prisma not updating properly and input changing value to minutes autmatically

* fix type error

* Update apps/web/components/v2/eventtype/EventLimitsTab.tsx

Co-authored-by: Peer Richelsen <peer@cal.com>

* moved minimumBookingNotice to clientside, removed migration from prisma, changed DurationField to regular InputField

* Update packages/lib/convertMinimumBookingNoticeToMinutes.ts

Co-authored-by: alannnc <alannnc@gmail.com>

* added MINUTES_IN_HOUR, MINUTES_IN_DAY, and HOURS_IN_DAY as exportable consts

* Fix some comparison, mobile styles and const usages

* minimumBookingNotice values will never be floats

* fixed issue with minbookingnotice not updating properly in prisma

* Removed console.logs, converted minimumBookingNoticeType to ref, removed unreliable accessing of array

* remove yarn lockfile

* Revert "remove yarn lockfile"

This reverts commit fefc24c80269f5445c8eb7a640e2816d7d8b0407.

* Revert "Revert "remove yarn lockfile""

This reverts commit c8cc4d3c0eddb2c91d077d2277ad5ea10315ec95.

* Replaced currentDurationType export with state passed down from parent

* Improvements for duration notice type logic

* Undo adding submodule updates

* Update apps/web/components/eventtype/EventLimitsTab.tsx

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Update packages/lib/findDurationType.ts

Co-authored-by: Omar López <zomars@me.com>

* Update apps/web/components/eventtype/EventLimitsTab.tsx

Co-authored-by: Omar López <zomars@me.com>

* Update packages/lib/convertToNewDurationType.ts

Co-authored-by: Omar López <zomars@me.com>

* Update apps/web/components/eventtype/EventLimitsTab.tsx

Co-authored-by: Omar López <zomars@me.com>

* Update apps/web/test/lib/getSchedule.test.ts

Co-authored-by: Omar López <zomars@me.com>

* remove passing in as object

* Update apps/web/test/lib/getSchedule.test.ts

Co-authored-by: Omar López <zomars@me.com>

* Update packages/lib/convertToNewDurationType.ts

Co-authored-by: Omar López <zomars@me.com>

* Delete convertMinimumBookingNoticeToMinutes.ts

* Syntax fix

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
2022-11-14 09:12:28 +00:00
9e001c53e8 add tests (#5487)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-11-14 08:50:09 +00:00
Hariom BalharaandPeer Richelsen 3937b0c4c6 Add support to open a redirect URL in new tab (#5480)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-11-13 15:51:31 +00:00
Udit TakkarandPeer Richelsen cbe358bb6b fix: booking rejection message (#5497)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-11-12 21:21:01 +00:00
Leo Giovanetti 2b91102f3f Recurring emails fixed (#5499) 2022-11-12 16:46:27 +00:00
Udit Takkar 4e7804c46d fix: change max width for mobile (#5495) 2022-11-12 13:43:04 +00:00
Udit Takkar d7226fc3d4 fix: disable button and handle submit when loading (#5496) 2022-11-12 13:31:11 +00:00
Alex van Andel 0cb278d109 Update engines to >=16.x in apps/web and monorepo (#5493) 2022-11-11 21:35:38 +00:00
alannncandOmar López a1430101d1 fix/team-billing-update-publish-form (#5476)
* Display team slug on form when saved on metadata

* remove button disabled prop

* update mutation to latest trcp style

* Update packages/trpc/server/routers/viewer/teams.tsx

Co-authored-by: Omar López <zomars@me.com>
2022-11-11 20:38:21 +00:00
9092addd9c fix: use .watch() to disable update btn (#5482)
* fix: use .watch() to disable update btn

* Apply suggestions from code review

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: alannnc <alannnc@gmail.com>
2022-11-11 20:27:09 +00:00
Masumi Kawasakiandalannnc 7f14e00119 Correction of unclear Japanese translation (#5455)
Co-authored-by: alannnc <alannnc@gmail.com>
2022-11-11 20:05:46 +00:00
Omar Lópezandkodiakhq[bot] abc5ff2d97 Deprecated API endpoints cleanup (#5478)
* Deprecated API endpoints cleanup

* Linting

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-11-11 19:54:44 +00:00
Udit TakkarandPeer Richelsen fcde1f7a9d fix: ui bugs in apps (#5465)
* fix: ui bugs in apps

* fix: recurring text

* fix: logo size and font

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-11-11 19:35:10 +00:00
Udit TakkarandOmar López 30eea089ba fix: use outline none (#5486)
* fix: use outline none

* fix: dropdown trigger ui bugs

* Update packages/ui/Dropdown.tsx

Co-authored-by: Omar López <zomars@me.com>
2022-11-11 12:21:56 -07:00
zomars 3c8cb3a9ea Upgaded Snaplet CLI 2022-11-11 10:32:35 -07:00
54f4e665a3 Add alphanumeric sender ID to SMS workflow actions (#5471)
* add sender id

* add sender to twilio from

* added missing sender

* add migration

* fix design of add action dialog

* add cal as sender when creating new workflow

* fix type errors

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

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