Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3f676e57 | ||
|
|
3e17b1c5ef | ||
|
|
a466e71d4c | ||
|
|
5a7030d277 | ||
|
|
e72c25f02e | ||
|
|
06bc5facf3 | ||
|
|
9c8dc1b92c | ||
|
|
cfef92a7bc | ||
|
|
26149e2b04 | ||
|
|
3ca43ce3f3 | ||
|
|
7603ea6184 | ||
|
|
9c45da565e | ||
|
|
f84f1f5dda | ||
|
|
07f803e758 | ||
|
|
268ab882cd | ||
|
|
8052e6f6d4 | ||
|
|
99e9425257 | ||
|
|
471b2687c4 | ||
|
|
9f7152c4eb | ||
|
|
e819a31dd6 | ||
|
|
8356cb7d34 | ||
|
|
ceb18167cf | ||
|
|
4d5697f5a5 | ||
|
|
afe180a0ec | ||
|
|
851a388853 | ||
|
|
07e20144df | ||
|
|
1535e48ce5 | ||
|
|
0dce391f46 | ||
|
|
cfe7ea45ec | ||
|
|
c244f0fc8e | ||
|
|
5301af2e9f | ||
|
|
a4e7ca91b5 | ||
|
|
64e6e1a19a | ||
|
|
a6daf17909 | ||
|
|
c7dfa7bc89 |
@@ -112,7 +112,7 @@ export const InstallAppButtonChild = ({
|
||||
);
|
||||
}}>
|
||||
<p>
|
||||
{team.name} {isInstalled && `(${t("installed")})`}
|
||||
{t(team.name)} {isInstalled && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.1.8",
|
||||
"version": "3.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -4,7 +4,11 @@ import { z } from "zod";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||
import {
|
||||
getBookingForReschedule,
|
||||
getBookingForSeatedEvent,
|
||||
getMultipleDurationValue,
|
||||
} from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
@@ -28,6 +32,7 @@ export default function Type({
|
||||
isBrandingHidden,
|
||||
rescheduleUid,
|
||||
entity,
|
||||
duration,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -45,6 +50,7 @@ export default function Type({
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -55,7 +61,7 @@ Type.PageWrapper = PageWrapper;
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
@@ -111,6 +117,11 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
@@ -127,7 +138,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
@@ -177,6 +188,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
away: user?.away,
|
||||
user: username,
|
||||
slug,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router";
|
||||
|
||||
export default createNextApiHandler(kycVerificationRouter);
|
||||
@@ -74,7 +74,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
<DateOverrideInputDialog
|
||||
workingHours={workingHours}
|
||||
excludedDates={excludedDates}
|
||||
onChange={(ranges) => append({ ranges })}
|
||||
onChange={(ranges) => ranges.forEach((range) => append({ ranges: [range] }))}
|
||||
Trigger={
|
||||
<Button color="secondary" StartIcon={Plus} data-testid="add-override">
|
||||
{t("add_an_override")}
|
||||
|
||||
@@ -314,7 +314,7 @@ export default function Success(props: SuccessProps) {
|
||||
<Link
|
||||
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
className="hover:bg-subtle text-subtle hover:text-default mt-2 inline-flex px-1 py-2 text-sm dark:hover:bg-transparent">
|
||||
<ChevronLeft className="h-5 w-5" /> {t("back_to_bookings")}
|
||||
<ChevronLeft className="h-5 w-5 rtl:rotate-180" /> {t("back_to_bookings")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -394,7 +394,7 @@ export default function Success(props: SuccessProps) {
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
@@ -715,7 +715,7 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
{isGmail && (
|
||||
<Alert
|
||||
className="main -mb-20 mt-4 inline-block text-left sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
className="main -mb-20 mt-4 inline-block ltr:text-right rtl:text-right sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
severity="warning"
|
||||
message={
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
@@ -26,6 +26,7 @@ export default function Type({
|
||||
isBrandingHidden,
|
||||
isTeamEvent,
|
||||
entity,
|
||||
duration,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -44,6 +45,7 @@ export default function Type({
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent={isTeamEvent}
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -54,7 +56,7 @@ Type.isBookingPage = true;
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
@@ -133,6 +135,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
away: user?.away,
|
||||
user: username,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayout";
|
||||
|
||||
import KYCVerificationView from "./kycVerificationView";
|
||||
|
||||
const KYCVerificationPage = () => <KYCVerificationView />;
|
||||
|
||||
KYCVerificationPage.getLayout = getLayout;
|
||||
KYCVerificationPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default KYCVerificationPage;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Meta, Form, Button, TextField, showToast } from "@calcom/ui";
|
||||
|
||||
type FormValues = {
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export default function KYCVerificationView() {
|
||||
const teamForm = useForm<FormValues>();
|
||||
const userForm = useForm<FormValues>();
|
||||
|
||||
const mutation = trpc.viewer.kycVerification.verify.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(`Successfully verified ${data.isTeam ? "team" : "user"} ${data.name}`, "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Verification failed: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Meta
|
||||
title="KYC Verification"
|
||||
description="Here you can verify users and teams. This verification is needed for sending sms/whatsapp messages to attendees."
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 font-medium">Verify Team</div>
|
||||
|
||||
<Form
|
||||
form={teamForm}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
name: values.name || "",
|
||||
isTeam: true,
|
||||
});
|
||||
}}>
|
||||
<div className="flex space-x-2">
|
||||
<TextField
|
||||
{...teamForm.register("name")}
|
||||
label=""
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="team slug"
|
||||
className="-mt-2 "
|
||||
required
|
||||
/>
|
||||
<Button type="submit">Verify</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 mt-6 font-medium">Verify User</div>
|
||||
<Form
|
||||
form={userForm}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
name: values.name || "",
|
||||
isTeam: false,
|
||||
});
|
||||
}}>
|
||||
<div className="flex space-x-2">
|
||||
<TextField
|
||||
{...userForm.register("name")}
|
||||
label=""
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="user name"
|
||||
className="-mt-2"
|
||||
required
|
||||
/>
|
||||
<Button type="submit">Verify</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
@@ -18,7 +18,16 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type({ slug, user, booking, away, isEmbed, isBrandingHidden, entity }: PageProps) {
|
||||
export default function Type({
|
||||
slug,
|
||||
user,
|
||||
booking,
|
||||
away,
|
||||
isEmbed,
|
||||
isBrandingHidden,
|
||||
entity,
|
||||
duration,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
<BookerSeo
|
||||
@@ -37,6 +46,7 @@ export default function Type({ slug, user, booking, away, isEmbed, isBrandingHid
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -55,7 +65,7 @@ const paramsSchema = z.object({
|
||||
// 2. If rescheduling, get the booking details
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
@@ -104,6 +114,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Messages } from "mailhog";
|
||||
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
@@ -10,30 +13,47 @@ import {
|
||||
testName,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
const freeUserObj = { name: `Free-user-${randomString(3)}` };
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(async ({ users }) => users.deleteAll());
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("free user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const free = await users.create();
|
||||
const free = await users.create(freeUserObj);
|
||||
await page.goto(`/${free.username}`);
|
||||
});
|
||||
|
||||
test("cannot book same slot multiple times", async ({ page }) => {
|
||||
test("cannot book same slot multiple times", async ({ page, users, emails }) => {
|
||||
const [user] = users.get();
|
||||
const bookerObj = { email: `testEmail-${randomString(4)}@example.com`, name: "testBooker" };
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await bookTimeSlot(page);
|
||||
await bookTimeSlot(page, bookerObj);
|
||||
|
||||
// save booking url
|
||||
const bookingUrl: string = page.url();
|
||||
|
||||
// Make sure we're navigated to the success page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
const { title: eventTitle } = await user.getFirstEventAsOwner();
|
||||
// TODO: follow DRY
|
||||
const emailsOrganiserReceived = await emails.search(user.email, "to");
|
||||
const emailsBookerReceived = await emails.search(bookerObj.email, "to");
|
||||
expect(emailsOrganiserReceived?.total).toBe(1);
|
||||
expect(emailsBookerReceived?.total).toBe(1);
|
||||
|
||||
const [organizerFirstEmail] = (emailsOrganiserReceived as Messages).items;
|
||||
const [bookerFirstEmail] = (emailsBookerReceived as Messages).items;
|
||||
const emailSubject = `${eventTitle} between ${user.name} and ${bookerObj.name}`;
|
||||
|
||||
expect(organizerFirstEmail.subject).toBe(emailSubject);
|
||||
expect(bookerFirstEmail.subject).toBe(emailSubject);
|
||||
|
||||
// return to same time spot booking page
|
||||
await page.goto(bookingUrl);
|
||||
|
||||
// book same time spot again
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
// Due to some reason for Dynamic booking cancellation, daily video api_key is not set which causes cancellation to fail.
|
||||
// This test is skipped until the issue is resolved in GH actions.
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip("dynamic booking", async ({ page, users }) => {
|
||||
test("dynamic booking", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
@@ -20,10 +17,13 @@ test.skip("dynamic booking", async ({ page, users }) => {
|
||||
await page.goto(`/${pro.username}+${free.username}`);
|
||||
|
||||
await test.step("book an event first day in next month", async () => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Fill what is this meeting about? title
|
||||
await page.locator('[name="title"]').fill("Test meeting");
|
||||
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -37,7 +37,8 @@ test.skip("dynamic booking", async ({ page, users }) => {
|
||||
return !!bookingId;
|
||||
});
|
||||
await selectSecondAvailableTimeSlotNextMonth(page);
|
||||
// --- fill form
|
||||
|
||||
// No need to fill fields since they should be already filled
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page, WorkerInfo } from "@playwright/test";
|
||||
import type Prisma from "@prisma/client";
|
||||
import { Prisma as PrismaType } from "@prisma/client";
|
||||
import { hashSync as hash } from "bcryptjs";
|
||||
import type { API } from "mailhog";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
@@ -116,7 +117,7 @@ const createTeamAndAddUser = async (
|
||||
};
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
export const createUsersFixture = (page: Page, emails: API, workerInfo: WorkerInfo) => {
|
||||
const store = { users: [], page } as { users: UserFixture[]; page: typeof page };
|
||||
return {
|
||||
create: async (
|
||||
@@ -330,7 +331,19 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
deleteAll: async () => {
|
||||
const emailMessageIds: string[] = [];
|
||||
const ids = store.users.map((u) => u.id);
|
||||
for (const user of store.users) {
|
||||
const emailMessages = await emails.search(user.email);
|
||||
if (emailMessages && emailMessages.count > 0) {
|
||||
emailMessages.items.forEach((item) => {
|
||||
emailMessageIds.push(item.ID);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const id of emailMessageIds) {
|
||||
await emails.deleteMessage(id);
|
||||
}
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
store.users = [];
|
||||
},
|
||||
@@ -387,6 +400,12 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
include: { team: { select: { children: true, metadata: true, name: true } } },
|
||||
});
|
||||
},
|
||||
getFirstEventAsOwner: async () =>
|
||||
prisma.eventType.findFirstOrThrow({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
}),
|
||||
getFirstTeamEvent: async (teamId: number) => {
|
||||
return prisma.eventType.findFirstOrThrow({
|
||||
where: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base } from "@playwright/test";
|
||||
import type { API } from "mailhog";
|
||||
import mailhog from "mailhog";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -19,6 +21,7 @@ export interface Fixtures {
|
||||
getActionFiredDetails: ReturnType<typeof createGetActionFiredDetails>;
|
||||
servers: ReturnType<typeof createServersFixture>;
|
||||
prisma: typeof prisma;
|
||||
emails: API;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -40,8 +43,8 @@ declare global {
|
||||
* @see https://playwright.dev/docs/test-fixtures
|
||||
*/
|
||||
export const test = base.extend<Fixtures>({
|
||||
users: async ({ page, context }, use, workerInfo) => {
|
||||
const usersFixture = createUsersFixture(page, workerInfo);
|
||||
users: async ({ page, context, emails }, use, workerInfo) => {
|
||||
const usersFixture = createUsersFixture(page, emails, workerInfo);
|
||||
await use(usersFixture);
|
||||
},
|
||||
bookings: async ({ page }, use) => {
|
||||
@@ -67,4 +70,8 @@ export const test = base.extend<Fixtures>({
|
||||
prisma: async ({}, use) => {
|
||||
await use(prisma);
|
||||
},
|
||||
emails: async ({}, use) => {
|
||||
const mailhogAPI = mailhog();
|
||||
await use(mailhogAPI);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -239,4 +240,33 @@ test.describe("Reschedule Tests", async () => {
|
||||
expect(newBooking).not.toBeNull();
|
||||
expect(newBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
test("Should be able to book slot that overlaps with original rescheduled booking", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes[0];
|
||||
|
||||
let firstOfNextMonth = dayjs().add(1, "month").startOf("month");
|
||||
|
||||
// find first available slot of next month (available monday-friday)
|
||||
while (firstOfNextMonth.day() < 1 || firstOfNextMonth.day() > 5) {
|
||||
firstOfNextMonth = firstOfNextMonth.add(1, "day");
|
||||
}
|
||||
|
||||
// set startTime to first available slot
|
||||
const startTime = firstOfNextMonth.set("hour", 9).set("minute", 0).toDate();
|
||||
const endTime = firstOfNextMonth.set("hour", 9).set("minute", 30).toDate();
|
||||
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {}, startTime, endTime);
|
||||
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1720,7 +1720,7 @@
|
||||
"identifier": "Identifier",
|
||||
"duplicate_email": "Email is duplicate",
|
||||
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment is on its way.",
|
||||
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
|
||||
"booking_confirmation_failed": "Booking confirmation failed",
|
||||
"not_enough_seats": "Not enough seats",
|
||||
@@ -1903,6 +1903,7 @@
|
||||
"first_event_type_webhook_description": "Create your first webhook for this event type",
|
||||
"install_app_on": "Install app on",
|
||||
"create_for": "Create for",
|
||||
"currency": "Currency",
|
||||
"setup_organization": "Setup an Organization",
|
||||
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
|
||||
"organization_banner_title": "Manage organizations with multiple teams",
|
||||
@@ -1981,6 +1982,13 @@
|
||||
"what_is_this_meeting_about": "What is this meeting about?",
|
||||
"requires_booker_email_verification": "Requires booker email verification",
|
||||
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
|
||||
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
|
||||
"kyc_verification_information": "To ensure security, you have to verify your {{teamOrAccount}} before sending text messages to attendees. Please contact us at <a>{{supportEmail}}</a> and provide the following information:",
|
||||
"kyc_verification_documents": "<ul><li>Your {{teamOrUser}}</li><li>For businesses: Attach your Business Verification Documentation</li><li>For individuals: Attach a government-issued ID</li></ul>",
|
||||
"verify_team_or_account": "Verify {{teamOrAccount}}",
|
||||
"verify_account": "Verify Account",
|
||||
"kyc_verification": "KYC Verification",
|
||||
"organizations": "Organizations",
|
||||
"org_admin_other_teams": "Other teams",
|
||||
"org_admin_other_teams_description": "Here you can see teams inside your organization but that you are not part of. You can add yourself to them if needed.",
|
||||
"no_other_teams_found": "No other teams found",
|
||||
@@ -1989,5 +1997,7 @@
|
||||
"attendee_last_name_variable": "Attendee last name",
|
||||
"attendee_first_name_info": "The person booking's first name",
|
||||
"attendee_last_name_info": "The person booking's last name",
|
||||
"me": "Me",
|
||||
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"create_account": "Créer un compte",
|
||||
"confirm_password": "Confirmer le mot de passe",
|
||||
"confirm_auth_change": "Cela changera la façon dont vous vous connectez",
|
||||
"confirm_auth_email_change": "La modification de l'adresse e-mail déconnectera votre méthode d'authentification actuelle pour vous connecter à Cal.com. Nous vous demanderons de vérifier votre nouvelle adresse e-mail. Ensuite, vous serez déconnecté et devrez utiliser votre nouvelle adresse e-mail pour vous connecter après avoir défini votre mot de passe en suivant les instructions qui seront envoyées à votre adresse e-mail.",
|
||||
"reset_your_password": "Définissez votre nouveau mot de passe avec les instructions envoyées à votre adresse e-mail.",
|
||||
"email_change": "Reconnectez-vous avec votre nouvelle adresse e-mail et votre nouveau mot de passe.",
|
||||
"create_your_account": "Créez votre compte",
|
||||
@@ -255,6 +256,8 @@
|
||||
"available_apps": "Applications disponibles",
|
||||
"available_apps_lower_case": "Applications disponibles",
|
||||
"available_apps_desc": "Aucune application installée. Consultez les applications populaires ci-dessous et découvrez-en plus dans notre <1>App Store</1>.",
|
||||
"fixed_host_helper": "Ajouter toute personne qui doit assister à l'événement. <1>En savoir plus</1>",
|
||||
"round_robin_helper": "Les membres du groupe se relaient et une seule personne se présentera à l'événement.",
|
||||
"check_email_reset_password": "Vérifiez vos e-mails. Nous vous avons envoyé un lien pour réinitialiser votre mot de passe.",
|
||||
"finish": "Terminer",
|
||||
"organization_general_description": "Gérez les paramètres de langue et de fuseau horaire de votre équipe.",
|
||||
@@ -1847,6 +1850,8 @@
|
||||
"insights_no_data_found_for_filter": "Aucune donnée trouvée pour le filtre ou les dates sélectionnés.",
|
||||
"acknowledge_booking_no_show_fee": "Je reconnais que si je ne participe pas à cet événement, des frais d'absence de {{amount, currency}} seront appliqués à ma carte.",
|
||||
"card_details": "Détails de la carte",
|
||||
"something_went_wrong_on_our_end": "Quelque chose s'est mal passé de notre côté. Contactez notre équipe de support, et nous le réparerons immédiatement.",
|
||||
"please_provide_following_text_to_suppport": "Veuillez fournir le texte suivant lorsque vous contactez le support pour mieux vous aider",
|
||||
"seats_and_no_show_fee_error": "Il n'est pas possible d'activer les places et de facturer des frais d'absence pour le moment",
|
||||
"complete_your_booking": "Terminez votre réservation",
|
||||
"complete_your_booking_subject": "Terminez votre réservation : {{title}} le {{date}}",
|
||||
@@ -1896,6 +1901,7 @@
|
||||
"first_event_type_webhook_description": "Créez votre premier webhook pour ce type d'événement",
|
||||
"install_app_on": "Installer l’application sur",
|
||||
"create_for": "Créer pour",
|
||||
"currency": "Devise",
|
||||
"setup_organization": "Configurer une organisation",
|
||||
"organization_banner_description": "Créez un environnement où vos équipes peuvent créer des applications partagées, des workflows et des types d'événements avec une planification round-robin et collective.",
|
||||
"organization_banner_title": "Gérer les organisations avec plusieurs équipes",
|
||||
@@ -1972,11 +1978,20 @@
|
||||
"org_team_names_example_5": "p. ex. Équipe d'analyse de données",
|
||||
"org_max_team_warnings": "Vous pourrez ajouter plus d'équipes plus tard.",
|
||||
"what_is_this_meeting_about": "En quoi consiste ce rendez-vous ?",
|
||||
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
|
||||
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
|
||||
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
|
||||
"verify_team_or_account": "Vérifier {{teamOrAccount}}",
|
||||
"verify_account": "Vérifier le compte",
|
||||
"kyc_verification": "Vérification KYC",
|
||||
"organizations": "Organisations",
|
||||
"org_admin_other_teams": "Autres équipes",
|
||||
"org_admin_other_teams_description": "Ici, vous pouvez voir les équipes au sein de votre organisation dont vous ne faites pas partie. Vous pouvez vous y ajouter si nécessaire.",
|
||||
"no_other_teams_found": "Aucune autre équipe trouvée",
|
||||
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
|
||||
"attendee_first_name_variable": "Prénom du participant",
|
||||
"attendee_last_name_variable": "Nom du participant",
|
||||
"me": "Moi",
|
||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"verify_email_banner_body": "Verifique seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário",
|
||||
"verify_email_email_header": "Verifique seu endereço de e-mail",
|
||||
"verify_email_email_button": "Verificar e-mail",
|
||||
"copy_somewhere_safe": "Salve esta chave de API em um lugar seguro. Você não será capaz de vê-la novamente.",
|
||||
"verify_email_email_body": "Por favor, verifique seu endereço de e-mail clicando no botão abaixo.",
|
||||
"verify_email_by_code_email_body": "Por favor, verifique seu endereço de e-mail usando o código abaixo.",
|
||||
"verify_email_email_link_text": "Aqui está o link caso você não goste de usar botões:",
|
||||
"email_verification_code": "Insira o código de verificação",
|
||||
"email_verification_code_placeholder": "Insira o código de verificação enviado para seu e-mail",
|
||||
"incorrect_email_verification_code": "O código de verificação está incorreto.",
|
||||
"email_sent": "E-mail enviado com sucesso",
|
||||
"email_not_sent": "Ocorreu um erro ao enviar o e-mail",
|
||||
"event_declined_subject": "Recusado: {{title}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
|
||||
"event_request_declined": "Sua solicitação de reunião foi recusada",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "Um evento recorrente aguarda a sua aprovação",
|
||||
"someone_requested_an_event": "Alguém solicitou o agendamento de um evento em sua agenda.",
|
||||
"someone_requested_password_reset": "Alguém solicitou um link para alterar sua senha.",
|
||||
"password_reset_email_sent": "Se estee-mail existir em nosso sistema, você receberá um e-mail de redefinição.",
|
||||
"password_reset_instructions": "Se você não fez essa solicitação, você pode ignorar este e-mail e sua senha não será alterada.",
|
||||
"event_awaiting_approval_subject": "Aguardando aprovação: {{title}} em {{date}}",
|
||||
"event_still_awaiting_approval": "Um evento está aguardando a sua aprovação",
|
||||
@@ -217,6 +224,9 @@
|
||||
"already_have_an_account": "Já tem uma conta?",
|
||||
"create_account": "Criar Conta",
|
||||
"confirm_password": "Confirmar senha",
|
||||
"confirm_auth_change": "Isso alterará a forma como você faz login",
|
||||
"reset_your_password": "Defina sua nova senha com as instruções enviadas para seu endereço de e-mail.",
|
||||
"email_change": "Volte a entrar com seu novo endereço de e-mail e senha.",
|
||||
"create_your_account": "Criar sua conta",
|
||||
"sign_up": "Cadastro",
|
||||
"youve_been_logged_out": "A sessão foi encerrada",
|
||||
@@ -243,6 +253,9 @@
|
||||
"all": "Todos os apps",
|
||||
"yours": "Sua conta",
|
||||
"available_apps": "Aplicativos disponíveis",
|
||||
"available_apps_lower_case": "Aplicativos disponíveis",
|
||||
"available_apps_desc": "Você não tem nenhum aplicativo instalado. Veja apps populares abaixo e explore mais em nossa <1>App Store</1>",
|
||||
"fixed_host_helper": "Adicione qualquer pessoa que precise participar do evento. <1>Saiba mais</1>",
|
||||
"check_email_reset_password": "Verifique o seu email. Enviamos um link para redefinir a sua senha.",
|
||||
"finish": "Finalizar",
|
||||
"organization_general_description": "Gerencie as configurações para o idioma e o fuso horário da sua equipe",
|
||||
@@ -406,6 +419,7 @@
|
||||
"booking_requested": "Reserva solicitada",
|
||||
"meeting_ended": "Reunião encerrada",
|
||||
"form_submitted": "Formulário enviado",
|
||||
"booking_paid": "Reserva paga",
|
||||
"event_triggers": "Gatilhos de eventos",
|
||||
"subscriber_url": "URL do assinante",
|
||||
"create_new_webhook": "Criar um novo webhook",
|
||||
@@ -506,6 +520,7 @@
|
||||
"your_name": "Seu nome",
|
||||
"your_full_name": "Seu nome completo",
|
||||
"no_name": "Sem nome",
|
||||
"enter_number_between_range": "Por favor, insira um número entre 1 e {{maxOccurences}}",
|
||||
"email_address": "Endereço de e-mail",
|
||||
"enter_valid_email": "Insira um e-mail válido",
|
||||
"location": "Local",
|
||||
@@ -842,6 +857,7 @@
|
||||
"team_view_user_availability_disabled": "O usuário precisa aceitar o convite para ver a disponibilidade",
|
||||
"set_as_away": "Definir-se como indisponível",
|
||||
"set_as_free": "Desativar status indisponível",
|
||||
"toggle_away_error": "Erro ao atualizar status de ausência",
|
||||
"user_away": "Este usuário está indisponível no momento.",
|
||||
"user_away_description": "A pessoa que você está tentando realizar uma reserva está indisponível no momento e, portanto, não está aceitando novas reservas.",
|
||||
"meet_people_with_the_same_tokens": "Conheça pessoas com os mesmos tokens",
|
||||
@@ -1865,6 +1881,9 @@
|
||||
"open_dialog_with_element_click": "Abra uma caixa de diálogo quando alguém clicar em um elemento.",
|
||||
"need_help_embedding": "Precisa de ajuda? Confira nosso guia para incorporar o Cal no Wix, Squarespace ou WordPress. Consulte as perguntas frequentes ou explore opções de incorporação avançadas.",
|
||||
"book_my_cal": "Reservar meu Cal",
|
||||
"first_name": "Primeiro nome",
|
||||
"last_name": "Sobrenome",
|
||||
"first_last_name": "Primeiro nome, Sobrenome",
|
||||
"invite_as": "Convidar como",
|
||||
"form_updated_successfully": "Formulário atualizado com êxito.",
|
||||
"disable_attendees_confirmation_emails": "Desative os e-mails de confirmação padrão para participantes",
|
||||
@@ -1878,6 +1897,7 @@
|
||||
"first_event_type_webhook_description": "Crie seu primeiro webhook para este tipo de evento",
|
||||
"install_app_on": "Instalar aplicativo em",
|
||||
"create_for": "Criar para",
|
||||
"currency": "Moeda",
|
||||
"setup_organization": "Definir uma Organização",
|
||||
"organization_banner_description": "Crie um ambiente onde suas equipes podem criar tipos de evento, fluxos de trabalho e aplicativos compartilhados com agendamento coletivo e round robin.",
|
||||
"organization_banner_title": "Gerenciar organizações com múltiplas equipes",
|
||||
@@ -1940,5 +1960,18 @@
|
||||
"insights_user_filter": "Usuário: {{userName}}",
|
||||
"insights_subtitle": "Veja insights de reserva em seus eventos",
|
||||
"custom_plan": "Plano personalizado",
|
||||
"select_time": "Selecione o horário",
|
||||
"select_date": "Selecione a data",
|
||||
"see_all_available_times": "Veja todos os horários disponíveis",
|
||||
"org_team_names_example": "ex.: Time de Marketing",
|
||||
"org_team_names_example_1": "ex.: Time de Marketing",
|
||||
"org_team_names_example_2": "ex: Time de Vendas",
|
||||
"org_team_names_example_3": "ex: Time de Design",
|
||||
"org_team_names_example_4": "ex: Time de Engenharia",
|
||||
"org_team_names_example_5": "ex: Time de Análise de Dados",
|
||||
"what_is_this_meeting_about": "De que se trata esta reunião?",
|
||||
"requires_booker_email_verification": "Requer verificação de e-mail do reservante",
|
||||
"verify_team_or_account": "Verificar {{teamOrAccount}}",
|
||||
"verify_account": "Verificar Conta",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"team_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Team. Для работы с командой «{{teamName}}» необходимо изменить тариф.",
|
||||
"upgrade_banner_action": "Изменить",
|
||||
"team_upgraded_successfully": "Вы успешно перешли на новый тариф для своей команды!",
|
||||
"org_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Organization. Для работы с «{{teamName}}» нужно перейти на тариф Organization.",
|
||||
"org_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Organization. Для работы «{{teamName}}» нужно перейти на тариф Organization.",
|
||||
"org_upgraded_successfully": "Вы успешно перешли на тариф Organization!",
|
||||
"use_link_to_reset_password": "Используйте ссылку ниже, чтобы сбросить пароль",
|
||||
"hey_there": "Привет,",
|
||||
@@ -307,15 +307,15 @@
|
||||
"layout": "Макет",
|
||||
"bookerlayout_default_title": "Представление по умолчанию",
|
||||
"bookerlayout_description": "Вы можете выбрать несколько представлений; пользователи, оформляющие бронирование, могут переключать представления.",
|
||||
"bookerlayout_user_settings_title": "Макет забронированного события",
|
||||
"bookerlayout_user_settings_title": "Представление забронированного события",
|
||||
"bookerlayout_user_settings_description": "Вы можете выбрать несколько представлений; пользователи, оформляющие бронирование, могут переключать представления. Эту настройку можно переопределять для каждого события.",
|
||||
"bookerlayout_month_view": "Месяц",
|
||||
"bookerlayout_week_view": "Еженедельно",
|
||||
"bookerlayout_week_view": "Неделя",
|
||||
"bookerlayout_column_view": "Столбец",
|
||||
"bookerlayout_error_min_one_enabled": "Нужно выбрать по крайней мере один макет.",
|
||||
"bookerlayout_error_default_not_enabled": "Макет, который вы выбрали как представление по умолчанию, не входит в число доступных макетов.",
|
||||
"bookerlayout_error_unknown_layout": "Вы выбрали недопустимый макет.",
|
||||
"bookerlayout_override_global_settings": "Эту настройку можно указать для всех типов событий в <2>настройках / оформление</2> или <6>переопределить только для этого события</6>.",
|
||||
"bookerlayout_override_global_settings": "Эту настройку можно указать для всех типов событий в <2>настройках / оформление</2> или <6>указать только для этого события</6>.",
|
||||
"unexpected_error_try_again": "Произошла непредвиденная ошибка. Попробуйте еще раз.",
|
||||
"sunday_time_error": "Недопустимое время в воскресенье",
|
||||
"monday_time_error": "Недопустимое время в понедельник",
|
||||
@@ -551,11 +551,11 @@
|
||||
"team_description": "Краткое описание вашей команды. Этот текст будет показываться на странице вашей команды.",
|
||||
"org_description": "Краткое описание вашей организации. Этот текст будет виден на странице вашей организации.",
|
||||
"members": "Участники",
|
||||
"organization_members": "Сотрудники организации",
|
||||
"organization_members": "Участники, подключенные к тарифному плану Organization",
|
||||
"member": "Участник",
|
||||
"number_member_one": "Участники: {{count}}",
|
||||
"number_member_other": "Участники: {{count}}",
|
||||
"number_selected": "Выбрано: {{count}}",
|
||||
"number_selected": "выбрано: {{count}}",
|
||||
"owner": "Владелец",
|
||||
"admin": "Админ",
|
||||
"administrator_user": "Администратор",
|
||||
@@ -1151,7 +1151,7 @@
|
||||
"custom": "Пользовательский",
|
||||
"reminder": "Напоминание",
|
||||
"rescheduled": "Перенесено",
|
||||
"completed": "Выполнено",
|
||||
"completed": "Завершено",
|
||||
"reminder_email": "Напоминание: {{eventType}} с {{name}} {{date}}",
|
||||
"not_triggering_existing_bookings": "Не работает для уже созданных бронирований, так как при бронировании события пользователь должен будет ввести номер телефона.",
|
||||
"minute_one": "{{count}} минута",
|
||||
@@ -1327,7 +1327,7 @@
|
||||
"download_responses_description": "Скачать все ответы пользователей, заполнивших форму, в формате CSV.",
|
||||
"download": "Скачать",
|
||||
"download_recording": "Скачать запись",
|
||||
"recording_from_your_recent_call": "Запись вашего недавнего созвана в {{appName}} готова для скачивания",
|
||||
"recording_from_your_recent_call": "Запись вашего недавнего созвона в {{appName}} готова для скачивания",
|
||||
"create_your_first_form": "Создайте первую форму",
|
||||
"create_your_first_form_description": "Формы маршрутизации позволяют задавать пользователям уточняющие вопросы, чтобы перенаправлять их к соответствующему лицу или типу события.",
|
||||
"create_your_first_webhook": "Создайте свой первый вебхук",
|
||||
@@ -1659,9 +1659,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Удалить конфигурацию {{connectionType}}? Участники команды, входящие в Cal.com через {{connectionType}}, больше не смогут получить к нему доступ.",
|
||||
"organizer_timezone": "Часовой пояс организатора",
|
||||
"email_user_cta": "Посмотреть приглашение",
|
||||
"email_no_user_invite_heading": "Вас пригласили в {{appName}} {{entity}}",
|
||||
"email_no_user_invite_heading": "Вас пригласили в {{entity}} {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} пригласил(-а) вас в команду в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого пользователи и целые команды могут планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} пригласил(а) вас в команду {{entity}} `{{teamName}}` в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого вы и ваша компания {{entity}} можете планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} пригласил(а) вас в команду {{entity}} `{{teamName}}` в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого вы и ваша {{entity}} можете планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_no_user_invite_steps_intro": "Всего несколько шагов — и вы сможете оперативно и без стресса планировать встречи с {{entity}}.",
|
||||
"email_no_user_step_one": "Выберите имя пользователя",
|
||||
"email_no_user_step_two": "Подключите аккаунт календаря",
|
||||
@@ -1687,7 +1687,7 @@
|
||||
"attendee_no_longer_attending": "Участник отказался от посещения вашего мероприятия",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} отказался(-ась) от посещения мероприятия. Этот промежуток времени снова свободен",
|
||||
"create_event_on": "Создать событие в календаре",
|
||||
"create_routing_form_on": "Создать форму маршрутизации",
|
||||
"create_routing_form_on": "Создать форму маршрутизации в",
|
||||
"default_app_link_title": "Установить ссылку на приложение по умолчанию",
|
||||
"default_app_link_description": "Если установить ссылку на приложение по умолчанию, она будет присваиваться всем вновь создаваемым событиям.",
|
||||
"organizer_default_conferencing_app": "Приложение организатора по умолчанию",
|
||||
@@ -1876,16 +1876,16 @@
|
||||
"connect_google_workspace": "Подключить Google Workspace",
|
||||
"google_workspace_admin_tooltip": "Эту функцию может использовать только администратор Workspace",
|
||||
"first_event_type_webhook_description": "Создайте первый вебхук для этого типа событий",
|
||||
"install_app_on": "Установить приложение на",
|
||||
"install_app_on": "Установить приложение в",
|
||||
"create_for": "Создать для",
|
||||
"setup_organization": "Настроить организацию",
|
||||
"organization_banner_description": "Создайте среду, в которой ваши команды смогут открывать совместный доступ к приложениям, рабочим процессам и типам событий, составляя расписание совместно или по очереди.",
|
||||
"organization_banner_description": "Создавайте рабочие среды, в которых ваши команды смогут открывать совместный доступ к приложениям, рабочим процессам и типам событий, составляя расписание совместно или по очереди.",
|
||||
"organization_banner_title": "Управляйте организациями с несколькими командами",
|
||||
"set_up_your_organization": "Настройте организацию",
|
||||
"organizations_description": "Организация – это общая рабочая среда, в которой команды могут создавать общие типы событий, приложения, рабочие процессы и многое другое.",
|
||||
"must_enter_organization_name": "Необходимо ввести название организации",
|
||||
"must_enter_organization_admin_email": "Необходимо ввести адрес электронной почты вашей организации",
|
||||
"admin_email": "Адрес электронной почты вашей организации",
|
||||
"admin_email": "Адрес электронной почты в организации",
|
||||
"admin_username": "Имя пользователя администратора",
|
||||
"organization_name": "Название организации",
|
||||
"organization_url": "URL-адрес организации",
|
||||
@@ -1912,7 +1912,7 @@
|
||||
"organization_admin_invited_body": "Станьте частью команды {{orgName}} – и вы сможете уделять внимание встречам, а не их планированию!",
|
||||
"duplicated_slugs_warning": "Не удалось создать следующие команды из-за дублирующихся слагов: {{slugs}}",
|
||||
"team_names_empty": "Имена команд не могут быть пустыми",
|
||||
"team_names_repeated": "Имена команд не могут быть повторяться",
|
||||
"team_names_repeated": "Имена команд должны быть уникальными",
|
||||
"user_belongs_organization": "Пользователь относится к организации",
|
||||
"org_no_teams_yet": "У этой организации еще нет команд",
|
||||
"org_no_teams_yet_description": "Если вы администратор, не забудьте создать команды, которые будут отображаться здесь.",
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "團隊資訊",
|
||||
"request_another_invitation_email": "如果不想要使用 {{toEmail}} 來當作用在 {{appName}} 的電子郵件,或已有 {{appName}} 帳號,請另外索取該電信箱的邀請。",
|
||||
"you_have_been_invited": "收到加入 {{teamName}} 團隊的邀請",
|
||||
"user_invited_you": "{{user}} 已邀請您加入 {{appName}} 上的 {{entity}}{{team}} 團隊",
|
||||
"user_invited_you": "{{user}} 已邀請您加入 {{appName}} 上的 {{entity}}{{team}}",
|
||||
"hidden_team_member_title": "在團隊裡是隱藏的",
|
||||
"hidden_team_member_message": "此座位尚未付費。請升級至 Pro,或通知團隊主持人協助付費。",
|
||||
"hidden_team_owner_message": "要有 Pro 帳號才可以使用團隊,在升級之前都是隱藏的。",
|
||||
@@ -129,7 +129,7 @@
|
||||
"team_upgrade_banner_description": "謝謝您試用我們新的團隊方案。我們注意到您的團隊「{{teamName}}」需要升級。",
|
||||
"upgrade_banner_action": "在此處升級",
|
||||
"team_upgraded_successfully": "團隊成功升級!",
|
||||
"org_upgrade_banner_description": "謝謝您試用我們的團隊方案。我們注意到您的組織「{{teamName}}」需要升級。",
|
||||
"org_upgrade_banner_description": "謝謝您試用我們的 Organization 方案。我們注意到您的組織「{{teamName}}」需要升級。",
|
||||
"org_upgraded_successfully": "組織成功升級!",
|
||||
"use_link_to_reset_password": "使用以下連結重置密碼",
|
||||
"hey_there": "哈囉,",
|
||||
@@ -1661,7 +1661,7 @@
|
||||
"email_user_cta": "檢視邀請",
|
||||
"email_no_user_invite_heading": "您已獲邀加入 {{appName}} {{entity}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的團隊。{{appName}} 是活動多工排程工具,可讓您和您的團隊無須透過繁複的電子郵件往來就能輕鬆預定會議。",
|
||||
"email_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的 {{entity}}「{{teamName}}」團隊。{{appName}} 為一款活動多功能排程工具,可讓您和 {{entity}} 無須透過繁複的電子郵件往來,就能輕鬆預定會議。",
|
||||
"email_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的「{{teamName}}」{{entity}}。{{appName}} 為一款活動多功能排程工具,可讓您和 {{entity}} 無須透過繁複的電子郵件往來,就能輕鬆預定會議。",
|
||||
"email_no_user_invite_steps_intro": "我們會逐步引導您完成幾個簡短的步驟,然後您就能馬上與 {{entity}} 享受沒有壓力的排程預定功能。",
|
||||
"email_no_user_step_one": "選擇使用者名稱",
|
||||
"email_no_user_step_two": "連結行事曆帳號",
|
||||
@@ -1687,7 +1687,7 @@
|
||||
"attendee_no_longer_attending": "一位與會者不再參加您的活動",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} 已取消,這代表此時段空出了一個座位",
|
||||
"create_event_on": "活動建立日期",
|
||||
"create_routing_form_on": "建立引導表單於",
|
||||
"create_routing_form_on": "引導表單建立位置",
|
||||
"default_app_link_title": "設定預設應用程式連結",
|
||||
"default_app_link_description": "設定預設應用程式連結,即可讓所有全新建立的活動類型使用您設定的應用程式連結。",
|
||||
"organizer_default_conferencing_app": "主辦者的預設應用程式",
|
||||
@@ -1893,7 +1893,7 @@
|
||||
"organization_verify_email_body": "請使用以下驗證碼驗證您的電子郵件地址,以便繼續設定您的組織。",
|
||||
"additional_url_parameters": "其他網址參數",
|
||||
"about_your_organization": "關於您的組織",
|
||||
"about_your_organization_description": "您可在組織這個共用環境中建立多個團隊,包括共用成員、活動類型、應用程式、工作流程等。",
|
||||
"about_your_organization_description": "您可在組織這個共用環境中建立多個團隊,包括共享成員、活動類型、應用程式、工作流程等。",
|
||||
"create_your_teams": "建立您的團隊",
|
||||
"create_your_teams_description": "將團隊成員新增到組織,開始一起排程",
|
||||
"invite_organization_admins": "邀請組織管理員",
|
||||
@@ -1910,7 +1910,7 @@
|
||||
"email_already_used": "電子郵件已有人使用",
|
||||
"organization_admin_invited_heading": "您已獲邀加入 {{orgName}}",
|
||||
"organization_admin_invited_body": "加入您在 {{orgName}} 的團隊,開始專注於會議本身,不必費心於會議的前置作業!",
|
||||
"duplicated_slugs_warning": "由於重複的 slugs,下列團隊無法建立:{{slugs}}",
|
||||
"duplicated_slugs_warning": "由於 slug 重複,無法建立下列團隊:{{slugs}}",
|
||||
"team_names_empty": "團隊名稱不得留空",
|
||||
"team_names_repeated": "團隊名稱不得重複",
|
||||
"user_belongs_organization": "使用者屬於組織",
|
||||
|
||||
@@ -274,6 +274,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
|
||||
@@ -358,6 +359,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
// getSchedule returns timeslots in GMT
|
||||
@@ -387,6 +389,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus2DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus3DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
|
||||
@@ -450,6 +453,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventWith30Length).toHaveTimeSlots(
|
||||
@@ -483,6 +487,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
// `slotInterval` takes precedence over `length`
|
||||
// 4:30 is utc so it is 10:00 in IST
|
||||
@@ -545,6 +550,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${minus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${todayDateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots(
|
||||
[
|
||||
@@ -563,6 +569,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${minus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${todayDateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots(
|
||||
[
|
||||
@@ -625,6 +632,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus2DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus3DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventOnADayWithNonCalBooking).toHaveTimeSlots(
|
||||
@@ -697,6 +705,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventOnADayWithCalBooking).toHaveTimeSlots(
|
||||
@@ -753,6 +762,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(schedule).toHaveTimeSlots(
|
||||
@@ -815,6 +825,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventOnADayWithDateOverride).toHaveTimeSlots(
|
||||
@@ -899,6 +910,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(thisUserAvailability).toHaveTimeSlots(
|
||||
@@ -995,6 +1007,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${todayDateString}T18:30:00.000Z`,
|
||||
endTime: `${plus1DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
expect(scheduleForTeamEventOnADayWithNoBooking).toHaveTimeSlots(
|
||||
@@ -1022,6 +1035,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
// A user with blocked time in another event, still affects Team Event availability
|
||||
@@ -1128,6 +1142,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
// A user with blocked time in another event, still affects Team Event availability
|
||||
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots).toHaveTimeSlots(
|
||||
@@ -1153,6 +1168,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus2DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus3DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
// A user with blocked time in another event, still affects Team Event availability
|
||||
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"jest-diff": "^29.5.0",
|
||||
"jsdom": "^22.0.0",
|
||||
"lint-staged": "^12.5.0",
|
||||
"mailhog": "^4.16.0",
|
||||
"prettier": "^2.8.6",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^4.9.4",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const AppSetupMap = {
|
||||
zapier: dynamic(() => import("../../zapier/pages/setup")),
|
||||
closecom: dynamic(() => import("../../closecom/pages/setup")),
|
||||
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
|
||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||
};
|
||||
|
||||
export const AppSetupPage = (props: { slug: string }) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export const EventTypeAddonMap = {
|
||||
giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
|
||||
gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
|
||||
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
|
||||
paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
|
||||
plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
|
||||
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
|
||||
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
|
||||
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
|
||||
import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
|
||||
import { appKeysSchema as plausible_zod_ts } from "./plausible/zod";
|
||||
import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod";
|
||||
import { appKeysSchema as routing_forms_zod_ts } from "./routing-forms/zod";
|
||||
@@ -43,6 +44,7 @@ export const appKeysSchemas = {
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
paypal: paypal_zod_ts,
|
||||
plausible: plausible_zod_ts,
|
||||
qr_code: qr_code_zod_ts,
|
||||
"routing-forms": routing_forms_zod_ts,
|
||||
|
||||
@@ -12,6 +12,7 @@ import cron_config_json from "./cron/config.json";
|
||||
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
|
||||
import discord_config_json from "./discord/config.json";
|
||||
import eightxeight_config_json from "./eightxeight/config.json";
|
||||
import element_call_config_json from "./element-call/config.json";
|
||||
import { metadata as exchange2013calendar__metadata_ts } from "./exchange2013calendar/_metadata";
|
||||
import { metadata as exchange2016calendar__metadata_ts } from "./exchange2016calendar/_metadata";
|
||||
import exchangecalendar_config_json from "./exchangecalendar/config.json";
|
||||
@@ -31,6 +32,7 @@ import mirotalk_config_json from "./mirotalk/config.json";
|
||||
import n8n_config_json from "./n8n/config.json";
|
||||
import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
|
||||
import office365video_config_json from "./office365video/config.json";
|
||||
import paypal_config_json from "./paypal/config.json";
|
||||
import ping_config_json from "./ping/config.json";
|
||||
import pipedream_config_json from "./pipedream/config.json";
|
||||
import plausible_config_json from "./plausible/config.json";
|
||||
@@ -78,6 +80,7 @@ export const appStoreMetadata = {
|
||||
dailyvideo: dailyvideo__metadata_ts,
|
||||
discord: discord_config_json,
|
||||
eightxeight: eightxeight_config_json,
|
||||
"element-call": element_call_config_json,
|
||||
exchange2013calendar: exchange2013calendar__metadata_ts,
|
||||
exchange2016calendar: exchange2016calendar__metadata_ts,
|
||||
exchangecalendar: exchangecalendar_config_json,
|
||||
@@ -97,6 +100,7 @@ export const appStoreMetadata = {
|
||||
n8n: n8n_config_json,
|
||||
office365calendar: office365calendar__metadata_ts,
|
||||
office365video: office365video_config_json,
|
||||
paypal: paypal_config_json,
|
||||
ping: ping_config_json,
|
||||
pipedream: pipedream_config_json,
|
||||
plausible: plausible_config_json,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
|
||||
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
|
||||
import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
|
||||
import { appDataSchema as plausible_zod_ts } from "./plausible/zod";
|
||||
import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod";
|
||||
import { appDataSchema as routing_forms_zod_ts } from "./routing-forms/zod";
|
||||
@@ -43,6 +44,7 @@ export const appDataSchemas = {
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
paypal: paypal_zod_ts,
|
||||
plausible: plausible_zod_ts,
|
||||
qr_code: qr_code_zod_ts,
|
||||
"routing-forms": routing_forms_zod_ts,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const apiHandlers = {
|
||||
cron: import("./cron/api"),
|
||||
discord: import("./discord/api"),
|
||||
eightxeight: import("./eightxeight/api"),
|
||||
"element-call": import("./element-call/api"),
|
||||
exchange2013calendar: import("./exchange2013calendar/api"),
|
||||
exchange2016calendar: import("./exchange2016calendar/api"),
|
||||
exchangecalendar: import("./exchangecalendar/api"),
|
||||
@@ -31,6 +32,7 @@ export const apiHandlers = {
|
||||
n8n: import("./n8n/api"),
|
||||
office365calendar: import("./office365calendar/api"),
|
||||
office365video: import("./office365video/api"),
|
||||
paypal: import("./paypal/api"),
|
||||
ping: import("./ping/api"),
|
||||
pipedream: import("./pipedream/api"),
|
||||
plausible: import("./plausible/api"),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
items:
|
||||
- 1.png
|
||||
- 2.png
|
||||
- 3.png
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
|
||||
- **Voice and Video Calls:** Users can make high-quality voice and video calls with their contacts or within specified rooms or groups.
|
||||
|
||||
- **End-to-End Encryption:** All communication is secured with end-to-end encryption, ensuring that messages and calls remain private and inaccessible to unauthorized parties.
|
||||
|
||||
- **Cross-Platform Compatibility:** Element supports various platforms, including web browsers, desktop applications, and mobile devices, allowing users to stay connected from anywhere.
|
||||
|
||||
- **Room-Based Communication:** Element utilizes "rooms" for organizing conversations, where users can join or create rooms to engage in group discussions or conferencing.
|
||||
|
||||
- **Message History and Synchronization:** Chat history is synchronized across devices, enabling users to seamlessly switch between platforms without losing past conversations.
|
||||
|
||||
- **Customizable Interface:** The interface can often be customized to suit individual preferences, offering a personalized user experience.
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
|
||||
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
|
||||
|
||||
import appConfig from "../config.json";
|
||||
|
||||
const handler: AppDeclarativeHandler = {
|
||||
appType: appConfig.type,
|
||||
variant: appConfig.variant,
|
||||
slug: appConfig.slug,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
createCredential: ({ appType, user, slug, teamId }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as add } from "./add";
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Element Call",
|
||||
"slug": "element-call",
|
||||
"type": "element-call_conferencing",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://github.com/suyash5053/",
|
||||
"variant": "conferencing",
|
||||
"categories": ["conferencing"],
|
||||
"publisher": "Suyash Srivastava",
|
||||
"email": "suyashsrivastava50532gmail.com",
|
||||
"appData": {
|
||||
"location": {
|
||||
"type": "integrations:{SLUG}_video",
|
||||
"label": "{TITLE}",
|
||||
"linkType": "static",
|
||||
"organizerInputPlaceholder": "https://call.element.io/",
|
||||
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?call.element.io/[a-zA-Z0-9]*"
|
||||
}
|
||||
},
|
||||
"description": "Element is an open-source communication platform that provides messaging, voice calling, and video conferencing capabilities. It is based on the Matrix protocol, which is a decentralized and federated messaging protocol designed to enable secure and interoperable communication across different platforms and services.\"",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-location-video-static",
|
||||
"dirName": "element-call"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as api from "./api";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/element-call",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Element is an open-source communication platform that provides messaging, voice calling, and video conferencing capabilities. It is based on the Matrix protocol, which is a decentralized and federated messaging protocol designed to enable secure and interoperable communication across different platforms and services.\""
|
||||
}
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_117_4)">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
<circle cx="64" cy="64" r="64" fill="#04BF8A"/>
|
||||
<mask id="path-2-inside-1_117_4" fill="white">
|
||||
<path d="M32.7099 74.7566C29.6119 75.9781 26.0314 74.4473 25.5846 71.1473C25.1747 68.1192 25.3114 65.0002 26.0042 61.9169C27.1525 56.8057 29.7838 51.9924 33.6065 48.0106C37.4291 44.0289 42.2937 41.0341 47.6612 39.3584C50.9977 38.3167 54.4475 37.8099 57.8599 37.8459C61.1841 37.8809 63.1528 41.2619 62.1562 44.4334V44.4334C61.2866 47.2009 58.433 48.7691 55.5372 48.9409C53.9259 49.0365 52.3131 49.3307 50.7418 49.8213C47.4989 50.8338 44.5597 52.6432 42.2501 55.0489C39.9405 57.4547 38.3507 60.3628 37.6569 63.4509C37.418 64.5141 37.2885 65.5844 37.2675 66.6474C37.2014 69.9769 35.8078 73.5351 32.7099 74.7566V74.7566Z"/>
|
||||
</mask>
|
||||
<path d="M32.7099 74.7566C29.6119 75.9781 26.0314 74.4473 25.5846 71.1473C25.1747 68.1192 25.3114 65.0002 26.0042 61.9169C27.1525 56.8057 29.7838 51.9924 33.6065 48.0106C37.4291 44.0289 42.2937 41.0341 47.6612 39.3584C50.9977 38.3167 54.4475 37.8099 57.8599 37.8459C61.1841 37.8809 63.1528 41.2619 62.1562 44.4334V44.4334C61.2866 47.2009 58.433 48.7691 55.5372 48.9409C53.9259 49.0365 52.3131 49.3307 50.7418 49.8213C47.4989 50.8338 44.5597 52.6432 42.2501 55.0489C39.9405 57.4547 38.3507 60.3628 37.6569 63.4509C37.418 64.5141 37.2885 65.5844 37.2675 66.6474C37.2014 69.9769 35.8078 73.5351 32.7099 74.7566V74.7566Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-2-inside-1_117_4)"/>
|
||||
<mask id="path-3-inside-2_117_4" fill="white">
|
||||
<path d="M52.9036 31.2935C51.7371 28.1744 53.331 24.6215 56.6383 24.2332C59.6732 23.8768 62.7894 24.0688 65.8599 24.8159C70.95 26.0545 75.716 28.7705 79.6295 32.663C83.543 36.5554 86.4513 41.4723 88.0318 46.8685C89.0144 50.2229 89.46 53.6812 89.3637 57.0924C89.2699 60.4155 85.8546 62.3241 82.7012 61.2715V61.2715C79.9496 60.353 78.4321 57.4722 78.3115 54.5738C78.2444 52.9611 77.9788 51.3433 77.5161 49.7636C76.5611 46.5033 74.804 43.5326 72.4395 41.1808C70.0749 38.829 67.1954 37.188 64.12 36.4396C63.0612 36.182 61.9934 36.0336 60.9309 35.9938C57.6031 35.8689 54.0701 34.4126 52.9036 31.2935V31.2935Z"/>
|
||||
</mask>
|
||||
<path d="M52.9036 31.2935C51.7371 28.1744 53.331 24.6215 56.6383 24.2332C59.6732 23.8768 62.7894 24.0688 65.8599 24.8159C70.95 26.0545 75.716 28.7705 79.6295 32.663C83.543 36.5554 86.4513 41.4723 88.0318 46.8685C89.0144 50.2229 89.46 53.6812 89.3637 57.0924C89.2699 60.4155 85.8546 62.3241 82.7012 61.2715V61.2715C79.9496 60.353 78.4321 57.4722 78.3115 54.5738C78.2444 52.9611 77.9788 51.3433 77.5161 49.7636C76.5611 46.5033 74.804 43.5326 72.4395 41.1808C70.0749 38.829 67.1954 37.188 64.12 36.4396C63.0612 36.182 61.9934 36.0336 60.9309 35.9938C57.6031 35.8689 54.0701 34.4126 52.9036 31.2935V31.2935Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-3-inside-2_117_4)"/>
|
||||
<mask id="path-4-inside-3_117_4" fill="white">
|
||||
<path d="M97.7468 52.0457C100.912 51.0099 104.395 52.7501 104.646 56.0708C104.876 59.1178 104.554 62.2233 103.68 65.2601C102.231 70.2944 99.3192 74.9433 95.2674 78.6916C91.2156 82.4399 86.1821 85.1412 80.7248 86.4961C77.3324 87.3383 73.8587 87.6398 70.4544 87.4017C67.1381 87.1698 65.3731 83.6782 66.5559 80.5713V80.5713C67.5879 77.8602 70.5294 76.4638 73.4303 76.4638C75.0444 76.4638 76.6718 76.2656 78.2694 75.869C81.5666 75.0504 84.6078 73.4183 87.0559 71.1536C89.5039 68.8889 91.2632 66.0801 92.1388 63.0385C92.4402 61.9913 92.6328 60.9306 92.7168 59.8706C92.98 56.551 94.5819 53.0816 97.7468 52.0457V52.0457Z"/>
|
||||
</mask>
|
||||
<path d="M97.7468 52.0457C100.912 51.0099 104.395 52.7501 104.646 56.0708C104.876 59.1178 104.554 62.2233 103.68 65.2601C102.231 70.2944 99.3192 74.9433 95.2674 78.6916C91.2156 82.4399 86.1821 85.1412 80.7248 86.4961C77.3324 87.3383 73.8587 87.6398 70.4544 87.4017C67.1381 87.1698 65.3731 83.6782 66.5559 80.5713V80.5713C67.5879 77.8602 70.5294 76.4638 73.4303 76.4638C75.0444 76.4638 76.6718 76.2656 78.2694 75.869C81.5666 75.0504 84.6078 73.4183 87.0559 71.1536C89.5039 68.8889 91.2632 66.0801 92.1388 63.0385C92.4402 61.9913 92.6328 60.9306 92.7168 59.8706C92.98 56.551 94.5819 53.0816 97.7468 52.0457V52.0457Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-4-inside-3_117_4)"/>
|
||||
<mask id="path-5-inside-4_117_4" fill="white">
|
||||
<path d="M75.895 95.1969C77.127 98.2907 75.6083 101.876 72.3099 102.334C69.2832 102.755 66.1637 102.628 63.0781 101.946C57.963 100.815 53.1408 98.2 49.1462 94.3909C45.1515 90.5817 42.1403 85.7273 40.4464 80.3655C39.3934 77.0326 38.8749 73.5845 38.8994 70.172C38.9232 66.8477 42.2974 64.8675 45.4723 65.8534V65.8534C48.2427 66.7137 49.8205 69.5619 50.0022 72.4571C50.1033 74.0681 50.4029 75.6799 50.8988 77.2495C51.9223 80.489 53.7416 83.422 56.1551 85.7234C58.5687 88.0249 61.4822 89.6048 64.5727 90.2882C65.6367 90.5235 66.7073 90.6493 67.7705 90.6668C71.1001 90.7215 74.663 92.1031 75.895 95.1969V95.1969Z"/>
|
||||
</mask>
|
||||
<path d="M75.895 95.1969C77.127 98.2907 75.6083 101.876 72.3099 102.334C69.2832 102.755 66.1637 102.628 63.0781 101.946C57.963 100.815 53.1408 98.2 49.1462 94.3909C45.1515 90.5817 42.1403 85.7273 40.4464 80.3655C39.3934 77.0326 38.8749 73.5845 38.8994 70.172C38.9232 66.8477 42.2974 64.8675 45.4723 65.8534V65.8534C48.2427 66.7137 49.8205 69.5619 50.0022 72.4571C50.1033 74.0681 50.4029 75.6799 50.8988 77.2495C51.9223 80.489 53.7416 83.422 56.1551 85.7234C58.5687 88.0249 61.4822 89.6048 64.5727 90.2882C65.6367 90.5235 66.7073 90.6493 67.7705 90.6668C71.1001 90.7215 74.663 92.1031 75.895 95.1969V95.1969Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-5-inside-4_117_4)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_117_4">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -14,6 +14,7 @@ const appStore = {
|
||||
office365calendar: () => import("./office365calendar"),
|
||||
office365video: () => import("./office365video"),
|
||||
plausible: () => import("./plausible"),
|
||||
paypal: () => import("./paypal"),
|
||||
salesforce: () => import("./salesforce"),
|
||||
zohocrm: () => import("./zohocrm"),
|
||||
sendgrid: () => import("./sendgrid"),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
items:
|
||||
- 1.jpeg
|
||||
- 2.jpeg
|
||||
- 3.jpeg
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import config from "../config.json";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
return res.status(401).json({ message: "You must be logged in to do this" });
|
||||
}
|
||||
const appType = config.type;
|
||||
try {
|
||||
const alreadyInstalled = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: appType,
|
||||
userId: req.session.user.id,
|
||||
},
|
||||
});
|
||||
if (alreadyInstalled) {
|
||||
throw new Error("Already installed");
|
||||
}
|
||||
const installation = await prisma.credential.create({
|
||||
data: {
|
||||
type: appType,
|
||||
key: {},
|
||||
userId: req.session.user.id,
|
||||
appId: "paypal",
|
||||
},
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
throw new Error("Unable to create user credential for Paypal");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500);
|
||||
}
|
||||
|
||||
return res.status(200).json({ url: "/apps/paypal/setup" });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
// Look if it's get
|
||||
if (req.method !== "GET") {
|
||||
throw new Error("Invalid method");
|
||||
}
|
||||
|
||||
// Look if bookingUid it's provided in query params using zod
|
||||
const parseRequest = captureRequestSchema.safeParse(req.query);
|
||||
if (!parseRequest.success) {
|
||||
throw new Error("Request is malformed");
|
||||
}
|
||||
|
||||
// Get bookingUid and token from query params
|
||||
const { bookingUid, token } = parseRequest.data;
|
||||
|
||||
// Get booking credentials
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
const credentials = await findPaymentCredentials(booking?.id);
|
||||
|
||||
if (!credentials) {
|
||||
throw new Error("Credentials not found");
|
||||
}
|
||||
|
||||
// Get paypal instance
|
||||
const paypalClient = new Paypal(credentials);
|
||||
|
||||
// capture payment
|
||||
const capture = await paypalClient.captureOrder(token);
|
||||
|
||||
if (!capture) {
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=failed`);
|
||||
}
|
||||
if (IS_PRODUCTION) {
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`);
|
||||
} else {
|
||||
// For cal.dev, paypal sandbox doesn't send webhooks
|
||||
const updateBooking = prisma.booking.update({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
data: {
|
||||
paid: true,
|
||||
},
|
||||
});
|
||||
const updatePayment = prisma.payment.update({
|
||||
where: {
|
||||
id: booking?.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
await Promise.all([updateBooking, updatePayment]);
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`);
|
||||
}
|
||||
return;
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
|
||||
res.status(200).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
res.redirect(`/booking/${req.query.bookingUid}?paypalPaymentStatus=failed`);
|
||||
}
|
||||
}
|
||||
|
||||
const captureRequestSchema = z.object({
|
||||
bookingUid: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "@calcom/web/pages/api/integrations/paypal/webhook";
|
||||
export { default as capture } from "./capture";
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import { currencyOptions } from "@calcom/app-store/paypal/pages/setup";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Select, TextField } from "@calcom/ui";
|
||||
|
||||
import type { appDataSchema } from "../zod";
|
||||
import { PaypalPaymentOptions as paymentOptions } from "../zod";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const { asPath } = useRouter();
|
||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) || {
|
||||
label: currencyOptions[0].label,
|
||||
value: currencyOptions[0].value,
|
||||
}
|
||||
);
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
|
||||
label: paymentOptions[0].label,
|
||||
value: paymentOptions[0].value,
|
||||
};
|
||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const { t } = useLocale();
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={WEBAPP_URL + asPath}
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(enabled) => {
|
||||
setRequirePayment(enabled);
|
||||
}}
|
||||
description={<>Add Paypal payment to your events</>}>
|
||||
<>
|
||||
{recurringEventDefined ? (
|
||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<>
|
||||
<div className="mt-2 block items-center sm:flex">
|
||||
<TextField
|
||||
label="Price"
|
||||
labelSrOnly
|
||||
addOnLeading="$"
|
||||
addOnSuffix={currency || "No selected currency"}
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
type="number"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value) * 100);
|
||||
if (currency) {
|
||||
setAppData("currency", currency);
|
||||
}
|
||||
}}
|
||||
value={price > 0 ? price / 100 : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
{t("currency")}
|
||||
</label>
|
||||
<Select
|
||||
variant="default"
|
||||
options={currencyOptions}
|
||||
value={selectedCurrency}
|
||||
className="text-black"
|
||||
defaultValue={selectedCurrency}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setSelectedCurrency(e);
|
||||
setAppData("currency", e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
Payment option
|
||||
</label>
|
||||
<Select<Option>
|
||||
defaultValue={
|
||||
paymentOptionSelectValue
|
||||
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
|
||||
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
|
||||
}
|
||||
options={paymentOptions.map((option) => {
|
||||
return { ...option, label: t(option.label) || option.label };
|
||||
})}
|
||||
onChange={(input) => {
|
||||
if (input) setAppData("paymentOption", input.value);
|
||||
}}
|
||||
className="mb-1 h-[38px] w-full"
|
||||
isDisabled={seatsEnabled}
|
||||
/>
|
||||
</div>
|
||||
{seatsEnabled && paymentOption === "HOLD" && (
|
||||
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
||||
@@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import z from "zod";
|
||||
|
||||
interface IPaypalPaymentComponentProps {
|
||||
payment: {
|
||||
// Will be parsed on render
|
||||
data: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// Create zod schema for data
|
||||
const PaymentPaypalDataSchema = z.object({
|
||||
order: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
links: z.array(
|
||||
z.object({
|
||||
href: z.string(),
|
||||
rel: z.string(),
|
||||
method: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
capture: z.object({}).optional(),
|
||||
});
|
||||
|
||||
export const PaypalPaymentComponent = (props: IPaypalPaymentComponentProps) => {
|
||||
const { payment } = props;
|
||||
const { data } = payment;
|
||||
const wrongUrl = (
|
||||
<>
|
||||
<p className="mt-3 text-center">Couldn't obtain payment URL</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const parsedData = PaymentPaypalDataSchema.safeParse(data);
|
||||
if (!parsedData.success || !parsedData.data?.order?.links) {
|
||||
return wrongUrl;
|
||||
}
|
||||
const paymentUrl = parsedData.data.order.links.find(
|
||||
(link) => link.rel === "approve" || link.rel === "payer-action"
|
||||
)?.href;
|
||||
if (!paymentUrl) {
|
||||
return wrongUrl;
|
||||
}
|
||||
return (
|
||||
<div className="mt-4 flex h-full w-full flex-col items-center justify-center">
|
||||
<Link
|
||||
href={`${paymentUrl}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-[#ffc439] px-12 py-2 text-base
|
||||
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
Pay with
|
||||
<img src="/api/app-store/paypal/paypal-logo.svg" alt="Paypal" className="mx-2 mb-1 mt-2 w-16" />
|
||||
<span />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Paypal",
|
||||
"slug": "paypal",
|
||||
"type": "paypal_payment",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://example.com/link",
|
||||
"variant": "payment",
|
||||
"categories": ["payment"],
|
||||
"publisher": "Cal.com",
|
||||
"email": "support@cal.com",
|
||||
"description": "Paypal payment app by Cal.com",
|
||||
"extendsFeature": "EventType",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"imageSrc": "icon.svg",
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "paypal"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
|
||||
import { paymentOptionEnum } from "../zod";
|
||||
|
||||
export const paypalCredentialKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
secret_key: z.string(),
|
||||
webhook_id: z.string(),
|
||||
});
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
|
||||
}
|
||||
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const paypalClient = new Paypal({
|
||||
clientId: this.credentials.client_id,
|
||||
secretKey: this.credentials.secret_key,
|
||||
});
|
||||
const orderResult = await paypalClient.createOrder({
|
||||
referenceId: uid,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
returnUrl: `${WEBAPP_URL}/api/integrations/paypal/capture?bookingUid=${booking.uid}`,
|
||||
cancelUrl: `${WEBAPP_URL}/payment/${uid}`,
|
||||
});
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "paypal",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
externalId: orderResult.id,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, { order: orderResult }) as unknown as Prisma.InputJsonValue,
|
||||
fee: 0,
|
||||
refunded: false,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
async update(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async refund(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async collectCard(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: number,
|
||||
_bookerEmail: string,
|
||||
paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
// Ensure that the payment service can support the passed payment option
|
||||
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
|
||||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const paypalClient = new Paypal({
|
||||
clientId: this.credentials.client_id,
|
||||
secretKey: this.credentials.secret_key,
|
||||
});
|
||||
const preference = await paypalClient.createOrder({
|
||||
referenceId: uid,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
returnUrl: `${WEBAPP_URL}/booking/${booking.uid}`,
|
||||
cancelUrl: `${WEBAPP_URL}/payment/${uid}`,
|
||||
intent: "AUTHORIZE",
|
||||
});
|
||||
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "paypal",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, preference) as unknown as Prisma.InputJsonValue,
|
||||
fee: 0,
|
||||
refunded: false,
|
||||
success: false,
|
||||
paymentOption: paymentOption || "ON_BOOKING",
|
||||
externalId: preference?.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
chargeCard(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: number
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentPaidStatus(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
afterPayment(
|
||||
event: CalendarEvent,
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
paymentData: Payment
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
deletePayment(paymentId: number): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
class Paypal {
|
||||
url: string;
|
||||
clientId: string;
|
||||
secretKey: string;
|
||||
accessToken: string | null = null;
|
||||
expiresAt: number | null = null;
|
||||
|
||||
constructor({ clientId, secretKey }: { clientId: string; secretKey: string }) {
|
||||
this.url = IS_PRODUCTION ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
this.clientId = clientId;
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<void> {
|
||||
if (this.accessToken && this.expiresAt && this.expiresAt > Date.now()) {
|
||||
return;
|
||||
}
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.secretKey}`).toString("base64")}`,
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.append("grant_type", "client_credentials");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (response.ok) {
|
||||
const { access_token, expires_in } = await response.json();
|
||||
this.accessToken = access_token;
|
||||
this.expiresAt = Date.now() + expires_in;
|
||||
} else {
|
||||
console.error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Orders
|
||||
async createOrder({
|
||||
referenceId,
|
||||
amount,
|
||||
currency,
|
||||
returnUrl,
|
||||
cancelUrl,
|
||||
intent = "CAPTURE",
|
||||
}: {
|
||||
referenceId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
returnUrl: string;
|
||||
cancelUrl: string;
|
||||
intent?: "CAPTURE" | "AUTHORIZE";
|
||||
}): Promise<CreateOrderResponse> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"PayPal-Request-Id": uuidv4(),
|
||||
};
|
||||
|
||||
const createOrderRequestBody: CreateOrderRequestBody = {
|
||||
intent,
|
||||
purchase_units: [
|
||||
{
|
||||
reference_id: referenceId,
|
||||
amount: {
|
||||
currency_code: currency,
|
||||
value: (amount / 100).toString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
payment_source: {
|
||||
paypal: {
|
||||
experience_context: {
|
||||
user_action: "PAY_NOW",
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(createOrderRequestBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const createOrderResponse: CreateOrderResponse = await response.json();
|
||||
return createOrderResponse;
|
||||
} else {
|
||||
console.error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return {} as CreateOrderResponse;
|
||||
}
|
||||
|
||||
async captureOrder(orderId: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
const captureResult = await fetch(`${this.url}/v2/checkout/orders/${orderId}/capture`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (captureResult.ok) {
|
||||
const result = await captureResult.json();
|
||||
if (result.body.status === "COMPLETED") {
|
||||
// Get payment reference id
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: orderId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingId: true,
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment?.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
data: Object.assign(
|
||||
{},
|
||||
{ ...(payment?.data as Record<string, string | number>), capture: result.body.id }
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Update booking as paid
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
},
|
||||
data: {
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async createWebhook(): Promise<boolean | string> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
const body = {
|
||||
url: `${WEBAPP_URL}/api/integrations/paypal/webhook`,
|
||||
event_types: [
|
||||
{
|
||||
name: "CHECKOUT.ORDER.APPROVED",
|
||||
},
|
||||
{
|
||||
name: "CHECKOUT.ORDER.COMPLETED",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return result.id as string;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async listWebhooks(): Promise<string[]> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { webhooks } = await response.json();
|
||||
|
||||
return webhooks
|
||||
.filter((webhook: { id: string; url: string }) => {
|
||||
return webhook.url.includes("api/integrations/paypal/webhook");
|
||||
})
|
||||
.map((webhook: { id: string }) => webhook.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async deleteWebhook(webhookId: string): Promise<boolean> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks/${webhookId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async test(): Promise<boolean> {
|
||||
// Always get a new access token
|
||||
try {
|
||||
await this.getAccessToken();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyWebhook(options: WebhookEventVerifyRequest): Promise<void> {
|
||||
const url = `${this.url}/v1/notifications/verify-webhook-signature`;
|
||||
const parseRequest = webhookEventVerifyRequestSchema.safeParse(options);
|
||||
|
||||
// Webhook event should be parsable
|
||||
if (!parseRequest.success) {
|
||||
console.error(parseRequest.error);
|
||||
throw new Error("Request is malformed");
|
||||
}
|
||||
|
||||
const stringy = JSON.stringify({
|
||||
auth_algo: options.body.auth_algo,
|
||||
cert_url: options.body.cert_url,
|
||||
transmission_id: options.body.transmission_id,
|
||||
transmission_sig: options.body.transmission_sig,
|
||||
transmission_time: options.body.transmission_time,
|
||||
webhook_id: options.body.webhook_id,
|
||||
});
|
||||
|
||||
const bodyToString = stringy.slice(0, -1) + `,"webhook_event":${options.body.webhook_event}` + "}";
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
},
|
||||
body: bodyToString,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.verification_status !== "SUCCESS") {
|
||||
throw data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Paypal;
|
||||
|
||||
interface PurchaseUnit {
|
||||
amount: {
|
||||
currency_code: string;
|
||||
value: string;
|
||||
};
|
||||
reference_id: string;
|
||||
}
|
||||
|
||||
interface ExperienceContext {
|
||||
payment_method_preference?: string;
|
||||
payment_method_selected?: string;
|
||||
brand_name?: string;
|
||||
locale?: string;
|
||||
landing_page?: string;
|
||||
shipping_preference?: string;
|
||||
user_action: string;
|
||||
return_url: string;
|
||||
cancel_url: string;
|
||||
}
|
||||
|
||||
interface PaymentSource {
|
||||
paypal: {
|
||||
experience_context: ExperienceContext;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateOrderRequestBody {
|
||||
purchase_units: PurchaseUnit[];
|
||||
intent: string;
|
||||
payment_source?: PaymentSource;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
href: string;
|
||||
rel: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
interface CreateOrderResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
payment_source: PaymentSource;
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
const webhookEventVerifyRequestSchema = z.object({
|
||||
body: z
|
||||
.object({
|
||||
auth_algo: z.string(),
|
||||
cert_url: z.string(),
|
||||
transmission_id: z.string(),
|
||||
transmission_sig: z.string(),
|
||||
transmission_time: z.string(),
|
||||
webhook_event: z.string(),
|
||||
webhook_id: z.string(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type WebhookEventVerifyRequest = z.infer<typeof webhookEventVerifyRequestSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./PaymentService";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/paypal",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Paypal payment app by Cal.com"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let clientId = "";
|
||||
let secretKey = "";
|
||||
const appKeys = await getAppKeysFromSlug("paypal");
|
||||
if (typeof appKeys.client_id === "string" && typeof appKeys.secret_key === "string") {
|
||||
clientId = appKeys.client_id;
|
||||
secretKey = appKeys.secret_key;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
clientId,
|
||||
secretKey,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, showToast, TextField } from "@calcom/ui";
|
||||
|
||||
export interface PayPalSetupProps {
|
||||
public_key: string;
|
||||
access_token: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const currencyOptions = [
|
||||
{ label: "United States dollar", value: "USD" },
|
||||
{ label: "Australian dollar", value: "AUD" },
|
||||
{ label: "Brazilian real 2", value: "BRL" },
|
||||
{ label: "Canadian dollar", value: "CAD" },
|
||||
{ label: "Chinese Renmenbi 3", value: "CNY" },
|
||||
{ label: "Czech koruna", value: "CZK" },
|
||||
{ label: "Danish krone", value: "DKK" },
|
||||
{ label: "Euro", value: "EUR" },
|
||||
{ label: "Hong Kong dollar", value: "HKD" },
|
||||
{ label: "Hungarian forint 1", value: "HUF" },
|
||||
{ label: "Israeli new shekel", value: "ILS" },
|
||||
{ label: "Japanese yen 1", value: "JPY" },
|
||||
{ label: "Malaysian ringgit 3", value: "MYR" },
|
||||
{ label: "Mexican peso", value: "MXN" },
|
||||
{ label: "New Taiwan dollar 1", value: "TWD" },
|
||||
{ label: "New Zealand dollar", value: "NZD" },
|
||||
{ label: "Norwegian krone", value: "NOK" },
|
||||
{ label: "Philippine peso", value: "PHP" },
|
||||
{ label: "Polish złoty", value: "PLN" },
|
||||
{ label: "Pound sterling", value: "GBP" },
|
||||
{ label: "Russian ruble", value: "RUB" },
|
||||
{ label: "Singapore dollar", value: "SGD" },
|
||||
{ label: "Swedish krona", value: "SEK" },
|
||||
{ label: "Swiss franc", value: "CHF" },
|
||||
{ label: "Thai baht", value: "THB" },
|
||||
];
|
||||
|
||||
export default function PayPalSetup(props: PayPalSetupProps) {
|
||||
const [newClientId, setNewClientId] = useState("");
|
||||
const [newSecretKey, setNewSecretKey] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment" });
|
||||
const paypalPaymentAppCredentials = integrations.data?.items.find((item) => item.type === "paypal_payment");
|
||||
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [false];
|
||||
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
|
||||
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("keys_have_been_saved"), "success");
|
||||
router.push("/event-types");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const saveKeys = async (key: { clientId: string; secretKey: string }) => {
|
||||
if (!key.clientId || !key.secretKey || key.clientId === key.secretKey) {
|
||||
return false;
|
||||
}
|
||||
if (typeof credentialId !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Test credentials before saving
|
||||
const paypalClient = new Paypal({ clientId: key.clientId, secretKey: key.secretKey });
|
||||
const test = await paypalClient.test();
|
||||
if (!test) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete all existing webhooks
|
||||
const webhooksToDelete = await paypalClient.listWebhooks();
|
||||
if (webhooksToDelete) {
|
||||
for (const webhook of webhooksToDelete) {
|
||||
await paypalClient.deleteWebhook(webhook);
|
||||
}
|
||||
}
|
||||
|
||||
// Create webhook for this installation
|
||||
const webhookId = await paypalClient.createWebhook();
|
||||
if (!webhookId) {
|
||||
// @TODO: make a button that tries to create the webhook again
|
||||
console.log("Failed to create webhook", webhookId);
|
||||
return false;
|
||||
}
|
||||
saveKeysMutation.mutate({
|
||||
credentialId,
|
||||
key: {
|
||||
client_id: key.clientId,
|
||||
secret_key: key.secretKey,
|
||||
webhook_id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (integrations.isLoading) {
|
||||
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default flex h-screen">
|
||||
{showContent ? (
|
||||
<div className="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
|
||||
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
|
||||
<div className="invisible md:visible">
|
||||
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt="Paypal Payment Logo" />
|
||||
<p className="text-default mt-5 text-lg">Paypal</p>
|
||||
</div>
|
||||
<form autoComplete="off" className="mt-5">
|
||||
<TextField
|
||||
label="Client Id"
|
||||
type="text"
|
||||
name="client_id"
|
||||
id="client_id"
|
||||
value={newClientId}
|
||||
onChange={(e) => setNewClientId(e.target.value)}
|
||||
role="presentation"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Secret Key"
|
||||
type="password"
|
||||
name="access_token"
|
||||
id="access_token"
|
||||
value={newSecretKey}
|
||||
autoComplete="new-password"
|
||||
role="presentation"
|
||||
onChange={(e) => setNewSecretKey(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Button to submit */}
|
||||
<div className="mt-5 flex flex-row justify-end">
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
saveKeys({
|
||||
clientId: newClientId,
|
||||
secretKey: newSecretKey,
|
||||
})
|
||||
}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
|
||||
|
||||
<ol className="text-default ml-1 list-decimal pl-2">
|
||||
{/* @TODO: translate */}
|
||||
<li>
|
||||
Log into your Paypal Developer account and create a new app{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://developer.paypal.com/dashboard/applications/"
|
||||
className="text-orange-600 underline">
|
||||
{t("here")}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Choose a name for your application.</li>
|
||||
<li>Select Online payments solution.</li>
|
||||
<li>Choose "No" for "Using online platform".</li>
|
||||
<li>CheckoutAPI as integration product.</li>
|
||||
<li>Accept terms and Create APP.</li>
|
||||
<li>Go back to dashboard, click on new app and copy the credentials.</li>
|
||||
<li>Paste them on the required field and save them.</li>
|
||||
<li>You're all setup.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 mt-5">
|
||||
<div>Paypal</div>
|
||||
<div className="mt-3">
|
||||
<Link href="/apps/paypal" passHref={true} legacyBehavior>
|
||||
<Button>{t("go_to_app_store")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
|
||||
<path fill="#001C64" d="M37.972 13.82c.107-5.565-4.485-9.837-10.799-9.837H14.115a1.278 1.278 0 0 0-1.262 1.079L7.62 37.758a1.038 1.038 0 0 0 1.025 1.2h7.737l-1.21 7.572a1.038 1.038 0 0 0 1.026 1.2H22.5c.305 0 .576-.11.807-.307.231-.198.269-.471.316-.772l1.85-10.885c.047-.3.2-.69.432-.888.231-.198.433-.306.737-.307H30.5c6.183 0 11.43-4.394 12.389-10.507.678-4.34-1.182-8.287-4.916-10.244Z"/>
|
||||
<path fill="#0070E0" d="m18.056 26.9-1.927 12.22-1.21 7.664a1.038 1.038 0 0 0 1.026 1.2h6.67a1.278 1.278 0 0 0 1.261-1.079l1.758-11.14a1.277 1.277 0 0 1 1.261-1.078h3.927c6.183 0 11.429-4.51 12.388-10.623.68-4.339-1.504-8.286-5.238-10.244-.01.462-.05.923-.121 1.38-.959 6.112-6.206 10.623-12.389 10.623h-6.145a1.277 1.277 0 0 0-1.261 1.077Z"/>
|
||||
<path fill="#003087" d="M16.128 39.12h-7.76a1.037 1.037 0 0 1-1.025-1.2l5.232-33.182a1.277 1.277 0 0 1 1.262-1.078h13.337c6.313 0 10.905 4.595 10.798 10.16-1.571-.824-3.417-1.295-5.44-1.295H21.413a1.278 1.278 0 0 0-1.261 1.078L18.057 26.9l-1.93 12.22Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="101" height="32" preserveAspectRatio="xMinYMin meet">
|
||||
<path fill="#003087" d="M12.237 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 20c-.1.4.2.7.6.7h3.7c.5 0 1-.4 1.1-.9l.8-5.4c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.1-1.3-3.1-2-5.7-2Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.7 1.1.5 2.1ZM35.437 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c.1-.2-.3-.6-.7-.6Zm-5.1 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .5.7.7 1.6.6 2.6ZM55.337 10h-3.7c-.4 0-.7.2-.9.5l-5.2 7.6-2.2-7.3c-.1-.5-.6-.8-1-.8h-3.7c-.4 0-.8.4-.6.9l4.1 12.1-3.9 5.4c-.3.4 0 1 .5 1h3.7c.4 0 .7-.2.9-.5l12.5-18c.3-.3 0-.9-.5-.9Z"/>
|
||||
<path fill="#009cde" d="M67.737 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 19.9c-.1.4.2.7.6.7h4c.4 0 .7-.3.7-.6l.9-5.7c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.2-1.2-3.1-1.9-5.7-1.9Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.6 1.1.5 2.1ZM90.937 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c0-.2-.3-.6-.7-.6Zm-5.2 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .6.7.8 1.6.6 2.6ZM95.337 3.3l-3.2 20.3c-.1.4.2.7.6.7h3.2c.5 0 1-.4 1.1-.9l3.2-19.9c.1-.4-.2-.7-.6-.7h-3.6c-.4 0-.6.2-.7.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
|
||||
|
||||
const paymentOptionSchema = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const paymentOptionsSchema = z.array(paymentOptionSchema);
|
||||
|
||||
export const PaypalPaymentOptions = [
|
||||
{
|
||||
label: "on_booking_option",
|
||||
value: "ON_BOOKING",
|
||||
},
|
||||
// @TODO: not required right now
|
||||
// {
|
||||
// label: "hold_option",
|
||||
// value: "HOLD",
|
||||
// },
|
||||
];
|
||||
|
||||
type PaymentOption = (typeof PaypalPaymentOptions)[number]["value"];
|
||||
const VALUES: [PaymentOption, ...PaymentOption[]] = [
|
||||
PaypalPaymentOptions[0].value,
|
||||
...PaypalPaymentOptions.slice(1).map((option) => option.value),
|
||||
];
|
||||
export const paymentOptionEnum = z.enum(VALUES);
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: z.string().optional(),
|
||||
})
|
||||
);
|
||||
export const appKeysSchema = z.object({});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Booking, Payment, Prisma, PaymentOption } from "@prisma/client";
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
@@ -12,7 +12,7 @@ import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
import { paymentOptionEnum } from "../zod";
|
||||
import { createPaymentLink } from "./client";
|
||||
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
|
||||
import type { StripeSetupIntentData, StripePaymentData } from "./server";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "./server";
|
||||
|
||||
const stripeCredentialKeysSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
@@ -38,6 +38,13 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getPayment(where: Prisma.PaymentWhereInput) {
|
||||
const payment = await prisma.payment.findFirst({ where });
|
||||
if (!payment) throw new Error("Payment not found");
|
||||
if (!payment.externalId) throw new Error("Payment externalId not found");
|
||||
return { ...payment, externalId: payment.externalId };
|
||||
}
|
||||
|
||||
/* This method is for creating charges at the time of booking */
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
@@ -52,7 +59,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
@@ -81,7 +88,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
const paymentData = await prisma?.payment.create({
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid: uuidv4(),
|
||||
app: {
|
||||
@@ -97,7 +104,6 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
externalId: paymentIntent.id,
|
||||
|
||||
data: Object.assign({}, paymentIntent, {
|
||||
stripe_publishable_key: this.credentials.stripe_publishable_key,
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
@@ -131,7 +137,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
@@ -161,7 +167,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
const paymentData = await prisma?.payment.create({
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid: uuidv4(),
|
||||
app: {
|
||||
@@ -177,7 +183,6 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
externalId: setupIntent.id,
|
||||
|
||||
data: Object.assign(
|
||||
{},
|
||||
{
|
||||
@@ -202,7 +207,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
|
||||
try {
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
@@ -280,17 +285,11 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
async refund(paymentId: Payment["id"]): Promise<Payment> {
|
||||
try {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
id: paymentId,
|
||||
success: true,
|
||||
refunded: false,
|
||||
},
|
||||
const payment = await this.getPayment({
|
||||
id: paymentId,
|
||||
success: true,
|
||||
refunded: false,
|
||||
});
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
const refund = await this.stripe.refunds.create(
|
||||
{
|
||||
payment_intent: payment.externalId,
|
||||
@@ -345,15 +344,9 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
async deletePayment(paymentId: Payment["id"]): Promise<boolean> {
|
||||
try {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
id: paymentId,
|
||||
},
|
||||
const payment = await this.getPayment({
|
||||
id: paymentId,
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
|
||||
|
||||
if (!stripeAccount) {
|
||||
|
||||
@@ -151,7 +151,7 @@ module.exports = {
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("tailwind-scrollbar"),
|
||||
require("tailwind-scrollbar")({ nocompatible: true }),
|
||||
require("tailwindcss-radix")(),
|
||||
require("@savvywombat/tailwindcss-grid-areas"),
|
||||
plugin(({ addVariant }) => {
|
||||
@@ -168,6 +168,6 @@ module.exports = {
|
||||
}),
|
||||
],
|
||||
variants: {
|
||||
scrollbar: ["rounded", "dark"],
|
||||
scrollbar: ["dark"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ export async function getBusyTimes(params: {
|
||||
endTime: string;
|
||||
selectedCalendars: SelectedCalendar[];
|
||||
seatedEvent?: boolean;
|
||||
rescheduleUid?: string | null;
|
||||
duration?: number | null;
|
||||
}) {
|
||||
const {
|
||||
credentials,
|
||||
@@ -33,6 +35,8 @@ export async function getBusyTimes(params: {
|
||||
afterEventBuffer,
|
||||
selectedCalendars,
|
||||
seatedEvent,
|
||||
rescheduleUid,
|
||||
duration,
|
||||
} = params;
|
||||
logger.silly(
|
||||
`Checking Busy time from Cal Bookings in range ${startTime} to ${endTime} for input ${JSON.stringify({
|
||||
@@ -67,9 +71,14 @@ export async function getBusyTimes(params: {
|
||||
*/
|
||||
performance.mark("prismaBookingGetStart");
|
||||
|
||||
const startTimeDate =
|
||||
rescheduleUid && duration ? dayjs(startTime).subtract(duration, "minute").toDate() : new Date(startTime);
|
||||
const endTimeDate =
|
||||
rescheduleUid && duration ? dayjs(endTime).add(duration, "minute").toDate() : new Date(endTime);
|
||||
|
||||
const sharedQuery = {
|
||||
startTime: { gte: new Date(startTime) },
|
||||
endTime: { lte: new Date(endTime) },
|
||||
startTime: { gte: startTimeDate },
|
||||
endTime: { lte: endTimeDate },
|
||||
status: {
|
||||
in: [BookingStatus.ACCEPTED],
|
||||
},
|
||||
@@ -96,6 +105,7 @@ export async function getBusyTimes(params: {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
title: true,
|
||||
@@ -138,6 +148,9 @@ export async function getBusyTimes(params: {
|
||||
// doing this allows using the map later to remove the ranges from calendar busy times.
|
||||
delete bookingSeatCountMap[bookedAt];
|
||||
}
|
||||
if (rest.uid === rescheduleUid) {
|
||||
return aggregate;
|
||||
}
|
||||
aggregate.push({
|
||||
start: dayjs(startTime)
|
||||
.subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute")
|
||||
@@ -180,6 +193,17 @@ export async function getBusyTimes(params: {
|
||||
};
|
||||
});
|
||||
|
||||
if (rescheduleUid) {
|
||||
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
|
||||
// calendar busy time from original rescheduled booking should not be blocked
|
||||
if (originalRescheduleBooking) {
|
||||
openSeatsDateRanges.push({
|
||||
start: dayjs(originalRescheduleBooking.startTime),
|
||||
end: dayjs(originalRescheduleBooking.endTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = subtract(
|
||||
calendarBusyTimes.map((value) => ({
|
||||
...value,
|
||||
|
||||
@@ -127,6 +127,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
||||
user?: User;
|
||||
eventType?: EventType;
|
||||
currentSeats?: CurrentSeats;
|
||||
rescheduleUid?: string | null;
|
||||
}
|
||||
) {
|
||||
const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } =
|
||||
@@ -184,6 +185,8 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
||||
afterEventBuffer,
|
||||
selectedCalendars: user.selectedCalendars,
|
||||
seatedEvent: !!eventType?.seatsPerTimeSlot,
|
||||
rescheduleUid: initialData?.rescheduleUid || null,
|
||||
duration,
|
||||
});
|
||||
|
||||
const detailedBusyTimes: EventBusyDetails[] = [
|
||||
|
||||
@@ -8,10 +8,6 @@ import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
declare let global: {
|
||||
E2E_EMAILS?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export default class BaseEmail {
|
||||
name = "";
|
||||
|
||||
@@ -37,12 +33,6 @@ export default class BaseEmail {
|
||||
console.warn("Skipped Sending Email due to active Kill Switch");
|
||||
return new Promise((r) => r("Skipped Sending Email due to active Kill Switch"));
|
||||
}
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
global.E2E_EMAILS = global.E2E_EMAILS || [];
|
||||
global.E2E_EMAILS.push(this.getNodeMailerPayload());
|
||||
console.log("Skipped Sending Email as NEXT_PUBLIC_IS_E2E==1");
|
||||
return new Promise((r) => r("Skipped sendEmail for E2E"));
|
||||
}
|
||||
|
||||
const payload = this.getNodeMailerPayload();
|
||||
const parseSubject = z.string().safeParse(payload?.subject);
|
||||
|
||||
@@ -497,6 +497,70 @@
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
<td style="direction: rtl; font-size: 0px; padding: 0px; text-align: center">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: right;
|
||||
direction: rtl;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="vertical-align: top"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
padding-top: 32px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="border-collapse: collapse; border-spacing: 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 89px">
|
||||
<a href="{{base_url}}" target="_blank">
|
||||
<img
|
||||
height="19"
|
||||
src="https://app.cal.com/emails/logo.png"
|
||||
style="
|
||||
border: 0;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: 19px;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
"
|
||||
width="89" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -80,7 +80,7 @@ export default class OrganizerScheduledEmail extends BaseEmail {
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject: `${this.newSeat ? this.t("new_attendee") + ":" : ""} ${this.calEvent.title}`,
|
||||
subject: `${this.newSeat ? this.t("new_attendee") + ": " : ""}${this.calEvent.title}`,
|
||||
html: renderEmail("OrganizerScheduledEmail", {
|
||||
calEvent: clonedCalEvent,
|
||||
attendee: this.calEvent.organizer,
|
||||
|
||||
@@ -40,6 +40,7 @@ const BookerComponent = ({
|
||||
hideBranding = false,
|
||||
isTeamEvent,
|
||||
entity,
|
||||
duration,
|
||||
}: BookerProps) => {
|
||||
/**
|
||||
* Prioritize dateSchedule load
|
||||
@@ -50,7 +51,7 @@ const BookerComponent = ({
|
||||
username,
|
||||
eventSlug,
|
||||
month,
|
||||
duration: undefined,
|
||||
duration,
|
||||
});
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isTablet = useMediaQuery("(max-width: 1024px)");
|
||||
@@ -206,7 +207,7 @@ const BookerComponent = ({
|
||||
<BookerSection
|
||||
area="header"
|
||||
className={classNames(
|
||||
layout === BookerLayouts.MONTH_VIEW && "fixed right-4 top-4 z-10",
|
||||
layout === BookerLayouts.MONTH_VIEW && "fixed top-4 z-10 ltr:right-4 rtl:left-4",
|
||||
(layout === BookerLayouts.COLUMN_VIEW || layout === BookerLayouts.WEEK_VIEW) &&
|
||||
"bg-default dark:bg-muted sticky top-0 z-10"
|
||||
)}>
|
||||
@@ -279,7 +280,7 @@ const BookerComponent = ({
|
||||
layout === BookerLayouts.COLUMN_VIEW
|
||||
}
|
||||
className={classNames(
|
||||
"border-subtle flex h-full w-full flex-col px-5 py-3 pb-0 md:border-l",
|
||||
"border-subtle rtl:border-default flex h-full w-full flex-col px-5 py-3 pb-0 rtl:border-r ltr:md:border-l",
|
||||
layout === BookerLayouts.MONTH_VIEW &&
|
||||
"scroll-bar h-full overflow-auto md:w-[var(--booker-timeslots-width)]",
|
||||
layout !== BookerLayouts.MONTH_VIEW && "sticky top-0"
|
||||
|
||||
@@ -71,7 +71,7 @@ export const EventMeta = () => {
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(event.description) }} />
|
||||
</EventMetaBlock>
|
||||
)}
|
||||
<div className="space-y-4 font-medium">
|
||||
<div className="space-y-4 font-medium rtl:-mr-2">
|
||||
{rescheduleUid && bookingData && (
|
||||
<EventMetaBlock icon={Calendar}>
|
||||
{t("former_time")}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function Header({
|
||||
isMobile: boolean;
|
||||
enabledLayouts: BookerLayouts[];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
|
||||
const selectedDateString = useBookerStore((state) => state.selectedDate);
|
||||
const setSelectedDate = useBookerStore((state) => state.setSelectedDate);
|
||||
@@ -66,13 +66,13 @@ export function Header({
|
||||
const isSameYear = () => {
|
||||
return selectedDate.format("YYYY") === endDate.format("YYYY");
|
||||
};
|
||||
|
||||
const formattedMonth = new Intl.DateTimeFormat(i18n.language, { month: "short" });
|
||||
const FormattedSelectedDateRange = () => {
|
||||
return (
|
||||
<h3 className="min-w-[150px] text-base font-semibold leading-4">
|
||||
{selectedDate.format("MMM D")}
|
||||
{formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")}
|
||||
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
|
||||
{isSameMonth() ? endDate.format("D") : endDate.format("MMM D")},{" "}
|
||||
{!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "}
|
||||
<span className="text-subtle">
|
||||
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
|
||||
</span>
|
||||
@@ -81,11 +81,12 @@ export function Header({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-default relative z-10 flex border-b border-l px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="border-default relative z-10 flex border-b px-5 py-4 ltr:border-l rtl:border-r">
|
||||
<div className="flex items-center gap-5 rtl:flex-grow">
|
||||
<FormattedSelectedDateRange />
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
className="group rtl:ml-1 rtl:rotate-180"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon={ChevronLeft}
|
||||
@@ -93,6 +94,7 @@ export function Header({
|
||||
onClick={() => addToSelectedDate(-extraDays - 1)}
|
||||
/>
|
||||
<Button
|
||||
className="group rtl:mr-1 rtl:rotate-180"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon={ChevronRight}
|
||||
@@ -101,7 +103,7 @@ export function Header({
|
||||
/>
|
||||
{selectedDateMin3DaysDifference && (
|
||||
<Button
|
||||
className="capitalize"
|
||||
className="capitalize ltr:ml-2 rtl:mr-2"
|
||||
color="secondary"
|
||||
onClick={() => setSelectedDate(today.format("YYYY-MM-DD"))}>
|
||||
{t("today")}
|
||||
@@ -111,15 +113,15 @@ export function Header({
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<TimeFormatToggle />
|
||||
<div className="fixed right-4 top-4">
|
||||
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
||||
<LayoutToggleWithData />
|
||||
</div>
|
||||
{/*
|
||||
This second layout toggle is hidden, but needed to reserve the correct spot in the DIV
|
||||
for the fixed toggle above to fit into. If we wouldn't make it fixed in this view, the transition
|
||||
would be really weird, because the element is positioned fixed in the month view, and then
|
||||
when switching layouts wouldn't anymmore, causing it to animate from the center to the top right,
|
||||
while it actuall already was on place. That's why we have this element twice.
|
||||
when switching layouts wouldn't anymore, causing it to animate from the center to the top right,
|
||||
while it actually already was on place. That's why we have this element twice.
|
||||
*/}
|
||||
<div className="pointer-events-none opacity-0" aria-hidden>
|
||||
<LayoutToggleWithData />
|
||||
|
||||
@@ -58,6 +58,12 @@ export interface BookerProps {
|
||||
* that will always result in the user event being returned.
|
||||
*/
|
||||
isTeamEvent?: boolean;
|
||||
/**
|
||||
* Refers to a multiple-duration event-type
|
||||
* It will correspond to selected time from duration query param if exists and if it is allowed as an option,
|
||||
* otherwise, the default value is selected
|
||||
*/
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useSchedule } from "@calcom/features/schedules";
|
||||
@@ -58,6 +59,10 @@ export const useScheduleForEvent = ({
|
||||
(state) => [state.username, state.eventSlug, state.month, state.selectedDuration],
|
||||
shallow
|
||||
);
|
||||
const searchParams = useSearchParams();
|
||||
const rescheduleUid = searchParams.get("rescheduleUid");
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
return useSchedule({
|
||||
username: usernameFromStore ?? username,
|
||||
@@ -65,7 +70,9 @@ export const useScheduleForEvent = ({
|
||||
eventId: event.data?.id ?? eventId,
|
||||
timezone,
|
||||
prefetchNextMonth,
|
||||
rescheduleUid,
|
||||
month: monthFromStore ?? month,
|
||||
duration: durationFromStore ?? duration,
|
||||
isTeamEvent: pathname.indexOf("/team/") !== -1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ export const AvailableTimes = ({
|
||||
</span>
|
||||
|
||||
{showTimeFormatToggle && (
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto rtl:mr-auto">
|
||||
<TimeFormatToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function AvailableEventLocations({ locations }: { locations: LocationObje
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : filteredLocations.length === 1 ? (
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm">
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm rtl:mr-2">
|
||||
{filteredLocations}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -86,7 +86,7 @@ export const EventMetaBlock = ({
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>{!!Icon && <Icon className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0" />}</>
|
||||
<>{!!Icon && <Icon className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0 rtl:ml-2" />}</>
|
||||
)}
|
||||
<div className={classNames("relative z-10 max-w-full break-words", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
@@ -128,9 +128,9 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
|
||||
case EventDetailBlocks.LOCATION:
|
||||
if (!event?.locations?.length) return null;
|
||||
return (
|
||||
<React.Fragment key={block}>
|
||||
<EventMetaBlock key={block}>
|
||||
<AvailableEventLocations locations={event.locations} />
|
||||
</React.Fragment>
|
||||
</EventMetaBlock>
|
||||
);
|
||||
|
||||
case EventDetailBlocks.REQUIRES_CONFIRMATION:
|
||||
|
||||
@@ -119,8 +119,8 @@ export const getBookingForReschedule = async (uid: string) => {
|
||||
},
|
||||
});
|
||||
|
||||
// If no booking is found via the uid, it's probably a booking seat,
|
||||
// which we query next.
|
||||
// If no booking is found via the uid, it's probably a booking seat
|
||||
// that its being rescheduled, which we query next.
|
||||
let attendeeEmail: string | null = null;
|
||||
if (!theBooking) {
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
@@ -224,3 +224,13 @@ export const getBookingForSeatedEvent = async (uid: string) => {
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getMultipleDurationValue = (
|
||||
multipleDurationConfig: number[] | undefined,
|
||||
queryDuration: string | string[] | undefined,
|
||||
defaultValue: number
|
||||
) => {
|
||||
if (!multipleDurationConfig) return null;
|
||||
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
@@ -20,7 +20,9 @@ export default function getLocationsOptionsForSelect(
|
||||
const type = eventLocation.type;
|
||||
|
||||
return {
|
||||
label: t(locationString),
|
||||
// XYZ: is considered a namespace in i18next https://www.i18next.com/principles/namespaces and thus it get's cleaned up.
|
||||
// Beacause there can be a URL in here, simply don't translate it if it starts with http: or https:. This would allow us to keep supporting namespaces if we plan to use them
|
||||
label: locationString.search(/^https?:/) !== -1 ? locationString : t(locationString),
|
||||
value: type,
|
||||
inputPlaceholder: t(eventLocation?.attendeeInputPlaceholder || ""),
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { deleteMeeting, updateMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendCancelledEmails, sendCancelledSeatEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified";
|
||||
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
@@ -65,7 +66,23 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
|
||||
eventType: {
|
||||
select: {
|
||||
slug: true,
|
||||
owner: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
metadata: true,
|
||||
teams: {
|
||||
select: {
|
||||
accepted: true,
|
||||
team: {
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teamId: true,
|
||||
recurringEvent: true,
|
||||
title: true,
|
||||
@@ -275,6 +292,8 @@ async function handler(req: CustomRequest) {
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
const isKYCVerified = isEventTypeOwnerKYCVerified(bookingToDelete.eventType);
|
||||
|
||||
//Workflows - schedule reminders
|
||||
if (bookingToDelete.eventType?.workflows) {
|
||||
await sendCancelledReminders({
|
||||
@@ -285,6 +304,7 @@ async function handler(req: CustomRequest) {
|
||||
...{ eventType: { slug: bookingToDelete.eventType.slug } },
|
||||
},
|
||||
hideBranding: !!bookingToDelete.eventType.owner?.hideBranding,
|
||||
isKYCVerified,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,7 +503,7 @@ async function handler(req: CustomRequest) {
|
||||
};
|
||||
|
||||
const successPayment = bookingToDelete.payment.find((payment) => payment.success);
|
||||
if (!successPayment) {
|
||||
if (!successPayment?.externalId) {
|
||||
throw new Error("Cannot reject a booking without a successful payment");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import type { EventManagerUser } from "@calcom/core/EventManager";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
@@ -85,8 +86,18 @@ export async function handleConfirmation(args: {
|
||||
eventType: {
|
||||
bookingFields: Prisma.JsonValue | null;
|
||||
slug: string;
|
||||
team: {
|
||||
metadata: Prisma.JsonValue;
|
||||
} | null;
|
||||
owner: {
|
||||
hideBranding?: boolean | null;
|
||||
metadata: Prisma.JsonValue;
|
||||
teams: {
|
||||
accepted: boolean;
|
||||
team: {
|
||||
metadata: Prisma.JsonValue;
|
||||
};
|
||||
}[];
|
||||
} | null;
|
||||
workflows: (WorkflowsOnEventTypes & {
|
||||
workflow: Workflow & {
|
||||
@@ -123,9 +134,25 @@ export async function handleConfirmation(args: {
|
||||
select: {
|
||||
slug: true,
|
||||
bookingFields: true,
|
||||
team: {
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
metadata: true,
|
||||
teams: {
|
||||
select: {
|
||||
accepted: true,
|
||||
team: {
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
@@ -174,9 +201,25 @@ export async function handleConfirmation(args: {
|
||||
select: {
|
||||
slug: true,
|
||||
bookingFields: true,
|
||||
team: {
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
metadata: true,
|
||||
teams: {
|
||||
select: {
|
||||
accepted: true,
|
||||
team: {
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
@@ -206,6 +249,8 @@ export async function handleConfirmation(args: {
|
||||
updatedBookings.push(updatedBooking);
|
||||
}
|
||||
|
||||
const isKYCVerified = isEventTypeOwnerKYCVerified(updatedBookings[0].eventType);
|
||||
|
||||
//Workflows - set reminders for confirmed events
|
||||
try {
|
||||
for (let index = 0; index < updatedBookings.length; index++) {
|
||||
@@ -229,6 +274,8 @@ export async function handleConfirmation(args: {
|
||||
},
|
||||
isFirstRecurringEvent: isFirstBooking,
|
||||
hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding,
|
||||
eventTypeRequiresConfirmation: true,
|
||||
isKYCVerified,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { isEventTypeOwnerKYCVerified } from "@calcom/features/ee/workflows/lib/isEventTypeOwnerKYCVerified";
|
||||
import {
|
||||
cancelWorkflowReminders,
|
||||
scheduleWorkflowReminders,
|
||||
@@ -93,6 +94,7 @@ const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
||||
|
||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||
type BufferedBusyTimes = BufferedBusyTime[];
|
||||
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
|
||||
|
||||
interface IEventTypePaymentCredentialType {
|
||||
appId: EventTypeAppsList;
|
||||
@@ -247,6 +249,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
bookingFields: true,
|
||||
@@ -277,6 +280,17 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
metadata: true,
|
||||
teams: {
|
||||
select: {
|
||||
accepted: true,
|
||||
team: {
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
@@ -344,22 +358,35 @@ async function ensureAvailableUsers(
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
|
||||
users: IsFixedAwareUser[];
|
||||
},
|
||||
input: { dateFrom: string; dateTo: string; timeZone: string },
|
||||
input: { dateFrom: string; dateTo: string; timeZone: string; originalRescheduledBooking?: BookingType },
|
||||
recurringDatesInfo?: {
|
||||
allRecurringDates: string[] | undefined;
|
||||
currentRecurringIndex: number | undefined;
|
||||
}
|
||||
) {
|
||||
const availableUsers: IsFixedAwareUser[] = [];
|
||||
|
||||
const orginalBookingDuration = input.originalRescheduledBooking
|
||||
? dayjs(input.originalRescheduledBooking.endTime).diff(
|
||||
dayjs(input.originalRescheduledBooking.startTime),
|
||||
"minutes"
|
||||
)
|
||||
: undefined;
|
||||
|
||||
/** Let's start checking for availability */
|
||||
for (const user of eventType.users) {
|
||||
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
|
||||
{
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
duration: orginalBookingDuration,
|
||||
...input,
|
||||
},
|
||||
{ user, eventType }
|
||||
{
|
||||
user,
|
||||
eventType,
|
||||
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
|
||||
}
|
||||
);
|
||||
|
||||
if (!dateRanges.length) {
|
||||
@@ -793,6 +820,34 @@ async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
let rescheduleUid = reqBody.rescheduleUid;
|
||||
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
|
||||
|
||||
let originalRescheduledBooking: BookingType = null;
|
||||
|
||||
if (rescheduleUid) {
|
||||
// rescheduleUid can be bookingUid and bookingSeatUid
|
||||
bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: rescheduleUid,
|
||||
},
|
||||
include: {
|
||||
booking: true,
|
||||
attendee: true,
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
}
|
||||
originalRescheduledBooking = await getOriginalRescheduledBooking(
|
||||
rescheduleUid,
|
||||
!!eventType.seatsPerTimeSlot
|
||||
);
|
||||
if (!originalRescheduledBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
|
||||
}
|
||||
}
|
||||
|
||||
if (!eventType.seatsPerTimeSlot) {
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
{
|
||||
@@ -809,6 +864,7 @@ async function handler(
|
||||
dateFrom: reqBody.start,
|
||||
dateTo: reqBody.end,
|
||||
timeZone: reqBody.timeZone,
|
||||
originalRescheduledBooking,
|
||||
},
|
||||
{
|
||||
allRecurringDates,
|
||||
@@ -847,34 +903,6 @@ async function handler(
|
||||
|
||||
const allCredentials = await getAllCredentials(organizerUser, eventType);
|
||||
|
||||
let rescheduleUid = reqBody.rescheduleUid;
|
||||
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
|
||||
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
|
||||
let originalRescheduledBooking: BookingType = null;
|
||||
|
||||
if (rescheduleUid) {
|
||||
// rescheduleUid can be bookingUid and bookingSeatUid
|
||||
bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: rescheduleUid,
|
||||
},
|
||||
include: {
|
||||
booking: true,
|
||||
attendee: true,
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
}
|
||||
originalRescheduledBooking = await getOriginalRescheduledBooking(
|
||||
rescheduleUid,
|
||||
!!eventType.seatsPerTimeSlot
|
||||
);
|
||||
if (!originalRescheduledBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
|
||||
}
|
||||
}
|
||||
|
||||
const isOrganizerRescheduling = organizerUser.id === userId;
|
||||
|
||||
const attendeeInfoOnReschedule =
|
||||
@@ -1119,6 +1147,8 @@ async function handler(
|
||||
|
||||
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
||||
|
||||
const isKYCVerified = isEventTypeOwnerKYCVerified(eventType);
|
||||
|
||||
const handleSeats = async () => {
|
||||
let resultBooking:
|
||||
| (Partial<Booking> & {
|
||||
@@ -1679,11 +1709,12 @@ async function handler(
|
||||
workflows: eventType.workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
|
||||
requiresConfirmation: evt.requiresConfirmation || false,
|
||||
isNotConfirmed: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
emailAttendeeSendToOverride: bookerEmail,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
isKYCVerified,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error while scheduling workflow reminders", error);
|
||||
@@ -2322,11 +2353,12 @@ async function handler(
|
||||
...evt,
|
||||
...{ metadata: metadataFromEvent, eventType: { slug: eventType.slug } },
|
||||
},
|
||||
requiresConfirmation: evt.requiresConfirmation || false,
|
||||
isNotConfirmed: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
hideBranding: !!eventType.owner?.hideBranding,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
isKYCVerified,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error while scheduling workflow reminders", error);
|
||||
|
||||
@@ -19,8 +19,8 @@ export type DatePickerProps = {
|
||||
onChange: (date: Dayjs) => void;
|
||||
/** Fires when the month is changed. */
|
||||
onMonthChange?: (date: Dayjs) => void;
|
||||
/** which date is currently selected (not tracked from here) */
|
||||
selected?: Dayjs | null;
|
||||
/** which date or dates are currently selected (not tracked from here) */
|
||||
selected?: Dayjs | Dayjs[] | null;
|
||||
/** defaults to current date. */
|
||||
minDate?: Dayjs;
|
||||
/** Furthest date selectable in the future, default = UNLIMITED */
|
||||
@@ -147,11 +147,16 @@ const Days = ({
|
||||
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
|
||||
|
||||
const isActive = (day: dayjs.Dayjs) => {
|
||||
// for selecting a range of dates
|
||||
if (Array.isArray(selected)) {
|
||||
return Array.isArray(selected) && selected?.some((e) => yyyymmdd(e) === yyyymmdd(day));
|
||||
}
|
||||
|
||||
if (selected && yyyymmdd(selected) === yyyymmdd(day)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// for multiple dates select
|
||||
// for selecting multiple dates for an event
|
||||
if (
|
||||
eventSlug &&
|
||||
selectedDatesAndTimes &&
|
||||
@@ -241,7 +246,7 @@ const DatePicker = ({
|
||||
<div className="flex">
|
||||
<Button
|
||||
className={classNames(
|
||||
"group p-1 opacity-70 hover:opacity-100",
|
||||
"group p-1 opacity-70 hover:opacity-100 rtl:rotate-180",
|
||||
!browsingDate.isAfter(dayjs()) &&
|
||||
"disabled:text-bookinglighter hover:bg-background hover:opacity-70"
|
||||
)}
|
||||
@@ -253,7 +258,7 @@ const DatePicker = ({
|
||||
StartIcon={ChevronLeft}
|
||||
/>
|
||||
<Button
|
||||
className="group p-1 opacity-70 hover:opacity-100"
|
||||
className="group p-1 opacity-70 hover:opacity-100 rtl:rotate-180"
|
||||
onClick={() => changeMonth(+1)}
|
||||
data-testid="incrementMonth"
|
||||
color="minimal"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
type Props = {
|
||||
days: dayjs.Dayjs[];
|
||||
@@ -9,10 +10,14 @@ type Props = {
|
||||
};
|
||||
|
||||
export function DateValues({ days, containerNavRef }: Props) {
|
||||
const { i18n } = useLocale();
|
||||
const formatDate = (date: dayjs.Dayjs): string => {
|
||||
return new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(date.toDate());
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={containerNavRef}
|
||||
className="bg-default dark:bg-muted border-b-subtle sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b sm:pr-8">
|
||||
className="bg-default dark:bg-muted border-b-subtle rtl:border-r-default sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b border-r sm:pr-8">
|
||||
<div className="text-subtle flex text-sm leading-6 sm:hidden" data-dayslength={days.length}>
|
||||
{days.map((day) => {
|
||||
const isToday = dayjs().isSame(day, "day");
|
||||
@@ -34,7 +39,7 @@ export function DateValues({ days, containerNavRef }: Props) {
|
||||
})}
|
||||
</div>
|
||||
<div className="text-subtle -mr-px hidden auto-cols-fr text-sm leading-6 sm:flex ">
|
||||
<div className="border-default col-end-1 w-14 border-l" />
|
||||
<div className="border-default col-end-1 w-14 ltr:border-l" />
|
||||
{days.map((day) => {
|
||||
const isToday = dayjs().isSame(day, "day");
|
||||
return (
|
||||
@@ -45,7 +50,7 @@ export function DateValues({ days, containerNavRef }: Props) {
|
||||
isToday && "font-bold"
|
||||
)}>
|
||||
<span>
|
||||
{day.format("ddd")}{" "}
|
||||
{formatDate(day)}{" "}
|
||||
<span
|
||||
className={classNames(
|
||||
"items-center justify-center p-1",
|
||||
|
||||
@@ -107,7 +107,7 @@ function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
|
||||
{!isDisabled && hoverEventDuration !== 0 && (
|
||||
<div
|
||||
className={classNames(
|
||||
"opacity-4 bg-brand-default hover:bg-brand-default text-brand dark:border-emphasis absolute hidden rounded-[4px] border-[1px] border-gray-900 px-[6px] py-1 text-xs font-semibold leading-5 group-hover:flex group-hover:cursor-pointer",
|
||||
"opacity-4 bg-brand-default hover:bg-brand-default text-brand dark:border-emphasis absolute hidden rounded-[4px] p-[6px] text-xs font-semibold leading-5 group-hover:flex group-hover:cursor-pointer",
|
||||
hoverEventDuration && hoverEventDuration > 15 && "items-start pt-3",
|
||||
hoverEventDuration && hoverEventDuration < 15 && "items-center"
|
||||
)}
|
||||
|
||||
@@ -26,14 +26,14 @@ export const HorizontalLines = ({
|
||||
<div className="row-end-1 h-[--calendar-offset-top] " ref={containerOffsetRef} />
|
||||
{hours.map((hour) => (
|
||||
<div key={`${id}-${hour.get("hour")}`}>
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5">
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5 rtl:-mr-14">
|
||||
{/* We need to force the minute to zero, because otherwise in ex GMT+5.5, it would show :30 minute times (but at the positino of :00) */}
|
||||
{hour.minute(0).format(timeFormat)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div key={`${id}-${finalHour}`}>
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5">
|
||||
<div className="text-muted sticky left-0 z-20 -ml-14 -mt-2.5 w-14 pr-2 text-right text-xs leading-5 rtl:-mr-14">
|
||||
{finalHour}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import type dayjs from "@calcom/dayjs";
|
||||
|
||||
export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
|
||||
const isRTL = () => {
|
||||
const userLocale = navigator.language;
|
||||
const userLanguage = new Intl.Locale(userLocale).language;
|
||||
return ["ar", "he", "fa", "ur"].includes(userLanguage);
|
||||
};
|
||||
|
||||
const direction = isRTL() ? "rtl" : "ltr";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="divide-default pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid
|
||||
auto-cols-auto grid-rows-1 divide-x sm:pr-8">
|
||||
auto-cols-auto grid-rows-1 divide-x sm:pr-8"
|
||||
dir={direction}
|
||||
style={{
|
||||
direction: direction,
|
||||
}}>
|
||||
{days.map((_, i) => (
|
||||
<div
|
||||
key={`Key_vertical_${i}`}
|
||||
className="row-span-full"
|
||||
style={{
|
||||
gridColumnStart: i + 1,
|
||||
gridColumnStart: isRTL() ? days.length - i : i + 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
@@ -69,7 +68,7 @@ const OrgProfileView = () => {
|
||||
onSuccess: (org) => {
|
||||
if (org) {
|
||||
form.setValue("name", org.name || "");
|
||||
form.setValue("slug", org.slug || "");
|
||||
form.setValue("slug", org.slug || org.metadata?.requestedSlug || "");
|
||||
form.setValue("logo", org.logo || "");
|
||||
form.setValue("bio", org.bio || "");
|
||||
if (org.slug === null && (org?.metadata as Prisma.JsonObject)?.requestedSlug) {
|
||||
@@ -89,20 +88,8 @@ const OrgProfileView = () => {
|
||||
!currentOrganisation.bio ||
|
||||
!currentOrganisation.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
showToast(t("your_org_disbanded_successfully"), "success");
|
||||
router.push(`${WEBAPP_URL}/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgBranding) return null;
|
||||
|
||||
function deleteTeam() {
|
||||
if (currentOrganisation?.id) deleteTeamMutation.mutate({ teamId: currentOrganisation.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta title={t("profile")} description={t("profile_org_description")} />
|
||||
@@ -168,15 +155,21 @@ const OrgProfileView = () => {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="slug"
|
||||
label={t("org_url")}
|
||||
value={currentOrganisation.slug ?? ""}
|
||||
disabled
|
||||
addOnSuffix={`.${subdomainSuffix()}`}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="slug"
|
||||
label={t("org_url")}
|
||||
value={value}
|
||||
disabled
|
||||
addOnSuffix={`.${subdomainSuffix()}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getRawBody from "raw-body";
|
||||
import * as z from "zod";
|
||||
|
||||
import { paypalCredentialKeysSchema } from "@calcom/app-store/paypal/lib";
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function getEventType(id: number) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaymentSuccess(
|
||||
payload: z.infer<typeof eventSchema>,
|
||||
rawPayload: string,
|
||||
webhookHeaders: WebHookHeadersType
|
||||
) {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: payload?.resource?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingId: true,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
eventType: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
uid: true,
|
||||
paid: true,
|
||||
destinationCalendar: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
|
||||
// Probably booking it's already paid from /capture but we need to send confirmation email
|
||||
const foundCredentials = await findPaymentCredentials(booking.id);
|
||||
if (!foundCredentials) throw new HttpCode({ statusCode: 204, message: "No credentials found" });
|
||||
const { webhookId, ...credentials } = foundCredentials;
|
||||
|
||||
const paypalClient = new Paypal(credentials);
|
||||
await paypalClient.getAccessToken();
|
||||
await paypalClient.verifyWebhook({
|
||||
body: {
|
||||
auth_algo: webhookHeaders["paypal-auth-algo"],
|
||||
cert_url: webhookHeaders["paypal-cert-url"],
|
||||
transmission_id: webhookHeaders["paypal-transmission-id"],
|
||||
transmission_sig: webhookHeaders["paypal-transmission-sig"],
|
||||
transmission_time: webhookHeaders["paypal-transmission-time"],
|
||||
webhook_id: webhookId,
|
||||
webhook_event: rawPayload,
|
||||
},
|
||||
});
|
||||
|
||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
||||
}
|
||||
|
||||
const { user } = booking;
|
||||
|
||||
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
...getCalEventResponses({
|
||||
booking: booking,
|
||||
bookingFields: booking.eventType?.bookingFields || null,
|
||||
}),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name!,
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: t, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
||||
};
|
||||
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
const bookingData: Prisma.BookingUpdateInput = {
|
||||
paid: true,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
if (isConfirmed) {
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
}
|
||||
|
||||
if (eventTypeRaw?.requiresConfirmation) {
|
||||
delete bookingData.status;
|
||||
}
|
||||
|
||||
if (!payment?.success) {
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (booking.status === "PENDING") {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: bookingData,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
||||
await handleConfirmation({ user, evt, prisma, bookingId: booking.id, booking, paid: true });
|
||||
} else {
|
||||
await sendScheduledEmails({ ...evt });
|
||||
}
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const bodyRaw = await getRawBody(req);
|
||||
const headers = req.headers;
|
||||
const bodyAsString = bodyRaw.toString();
|
||||
|
||||
const parseHeaders = webhookHeadersSchema.safeParse(headers);
|
||||
if (!parseHeaders.success) {
|
||||
console.error(parseHeaders.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
const parse = eventSchema.safeParse(JSON.parse(bodyAsString));
|
||||
if (!parse.success) {
|
||||
console.error(parse.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
|
||||
const { data: parsedPayload } = parse;
|
||||
|
||||
if (parsedPayload.event_type === "CHECKOUT.ORDER.APPROVED") {
|
||||
return await handlePaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
|
||||
}
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
res.status(200).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return a response to acknowledge receipt of the event
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
const resourceSchema = z
|
||||
.object({
|
||||
create_time: z.string(),
|
||||
id: z.string(),
|
||||
payment_source: z.object({
|
||||
paypal: z.object({}).optional(),
|
||||
}),
|
||||
intent: z.string(),
|
||||
payer: z.object({
|
||||
email_address: z.string(),
|
||||
payer_id: z.string(),
|
||||
address: z.object({
|
||||
country_code: z.string(),
|
||||
}),
|
||||
}),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const eventSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
create_time: z.string(),
|
||||
resource_type: z.string(),
|
||||
event_type: z.string(),
|
||||
summary: z.string(),
|
||||
resource: resourceSchema,
|
||||
status: z.string().optional(),
|
||||
event_version: z.string(),
|
||||
resource_version: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const webhookHeadersSchema = z
|
||||
.object({
|
||||
"paypal-auth-algo": z.string(),
|
||||
"paypal-cert-url": z.string(),
|
||||
"paypal-transmission-id": z.string(),
|
||||
"paypal-transmission-sig": z.string(),
|
||||
"paypal-transmission-time": z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type WebHookHeadersType = z.infer<typeof webhookHeadersSchema>;
|
||||
|
||||
export const findPaymentCredentials = async (
|
||||
bookingId: number
|
||||
): Promise<{ clientId: string; secretKey: string; webhookId: string }> => {
|
||||
try {
|
||||
// @TODO: what about team bookings with paypal?
|
||||
const userFromBooking = await prisma?.booking.findFirst({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userFromBooking) throw new Error("No user found");
|
||||
|
||||
const credentials = await prisma?.credential.findFirst({
|
||||
where: {
|
||||
appId: "paypal",
|
||||
userId: userFromBooking?.userId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
if (!credentials) {
|
||||
throw new Error("No credentials found");
|
||||
}
|
||||
const parsedCredentials = paypalCredentialKeysSchema.safeParse(credentials?.key);
|
||||
if (!parsedCredentials.success) {
|
||||
throw new Error("Credentials malformed");
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: parsedCredentials.data.client_id,
|
||||
secretKey: parsedCredentials.data.secret_key,
|
||||
webhookId: parsedCredentials.data.webhook_id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return {
|
||||
clientId: "",
|
||||
secretKey: "",
|
||||
webhookId: "",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import type { SyntheticEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -17,13 +16,14 @@ import type { EventType } from ".prisma/client";
|
||||
|
||||
type Props = {
|
||||
payment: Omit<Payment, "id" | "fee" | "success" | "refunded" | "externalId" | "data"> & {
|
||||
data: StripePaymentData | StripeSetupIntentData;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
eventType: { id: number; successRedirectUrl: EventType["successRedirectUrl"] };
|
||||
user: { username: string | null };
|
||||
location?: string | null;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
type States =
|
||||
@@ -162,26 +162,18 @@ const ELEMENT_STYLES_DARK: stripejs.Appearance = {
|
||||
};
|
||||
|
||||
export default function PaymentComponent(props: Props) {
|
||||
const stripePromise = getStripe((props.payment.data as StripePaymentData).stripe_publishable_key);
|
||||
const paymentOption = props.payment.paymentOption;
|
||||
const stripePromise = getStripe(props.payment.data.stripe_publishable_key as any);
|
||||
const [darkMode, setDarkMode] = useState<boolean>(false);
|
||||
let clientSecret: string | null;
|
||||
|
||||
useEffect(() => {
|
||||
setDarkMode(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
}, []);
|
||||
|
||||
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
|
||||
clientSecret = props.payment.data.setupIntent.client_secret;
|
||||
} else if (!("setupIntent" in props.payment.data)) {
|
||||
clientSecret = props.payment.data.client_secret;
|
||||
}
|
||||
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: clientSecret!,
|
||||
clientSecret: props.clientSecret,
|
||||
appearance: darkMode ? ELEMENT_STYLES_DARK : ELEMENT_STYLES,
|
||||
}}>
|
||||
<PaymentForm {...props} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -15,7 +16,20 @@ import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { CreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { PaymentPageProps } from "../pages/payment";
|
||||
import PaymentComponent from "./Payment";
|
||||
|
||||
const StripePaymentComponent = dynamic(() => import("./Payment"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const PaypalPaymentComponent = dynamic(
|
||||
() =>
|
||||
import("@calcom/app-store/paypal/components/PaypalPaymentComponent").then(
|
||||
(m) => m.PaypalPaymentComponent
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -119,7 +133,8 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300">{t("paid")}</div>
|
||||
)}
|
||||
{props.payment.appId === "stripe" && !props.payment.success && (
|
||||
<PaymentComponent
|
||||
<StripePaymentComponent
|
||||
clientSecret={props.clientSecret}
|
||||
payment={props.payment}
|
||||
eventType={props.eventType}
|
||||
user={props.user}
|
||||
@@ -128,6 +143,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
bookingUid={props.booking.uid}
|
||||
/>
|
||||
)}
|
||||
{props.payment.appId === "paypal" && !props.payment.success && (
|
||||
<PaypalPaymentComponent payment={props.payment} />
|
||||
)}
|
||||
{props.payment.refunded && (
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Payment } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
import { ssrInit } from "../../../../../apps/web/server/lib/ssr";
|
||||
|
||||
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
@@ -88,9 +88,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
if (!rawPayment) return { notFound: true };
|
||||
|
||||
const { data, booking: _booking, ...restPayment } = rawPayment;
|
||||
|
||||
const payment = {
|
||||
...restPayment,
|
||||
data: data as unknown as StripePaymentData | StripeSetupIntentData,
|
||||
data: data as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (!_booking) return { notFound: true };
|
||||
@@ -135,7 +136,28 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
booking,
|
||||
trpcState: ssr.dehydrate(),
|
||||
payment,
|
||||
clientSecret: getClientSecretFromPayment(payment),
|
||||
profile,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
|
||||
return !!x && typeof x === "object" && key in x;
|
||||
}
|
||||
|
||||
function getClientSecretFromPayment(
|
||||
payment: Omit<Partial<Payment>, "data"> & { data: Record<string, unknown> }
|
||||
) {
|
||||
if (
|
||||
payment.paymentOption === "HOLD" &&
|
||||
hasStringProp(payment.data, "setupIntent") &&
|
||||
hasStringProp(payment.data.setupIntent, "client_secret")
|
||||
) {
|
||||
return payment.data.setupIntent.client_secret;
|
||||
}
|
||||
if (hasStringProp(payment.data, "client_secret")) {
|
||||
return payment.data.client_secret;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ const getUserAdminTeams = async ({
|
||||
},
|
||||
}),
|
||||
},
|
||||
orderBy: {
|
||||
orgUsers: { _count: "desc" },
|
||||
},
|
||||
});
|
||||
|
||||
if (teams.length && getUserInfo) {
|
||||
@@ -76,7 +79,7 @@ const getUserAdminTeams = async ({
|
||||
if (user) {
|
||||
const userObject = {
|
||||
id: user.id,
|
||||
name: user.name || "Nameless",
|
||||
name: user.name || "me",
|
||||
logo: user?.avatar === "" ? null : user?.avatar,
|
||||
isUser: true,
|
||||
...(includeCredentials && { credentials: user.credentials }),
|
||||
|
||||
@@ -134,12 +134,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
message?.length &&
|
||||
message?.length > 0 &&
|
||||
sendTo &&
|
||||
reminder.workflowStep.action !== WorkflowActions.SMS_ATTENDEE
|
||||
) {
|
||||
if (message?.length && message?.length > 0 && sendTo) {
|
||||
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, senderID);
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
|
||||
@@ -90,12 +90,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
userName
|
||||
);
|
||||
|
||||
if (
|
||||
message?.length &&
|
||||
message?.length > 0 &&
|
||||
sendTo &&
|
||||
reminder.workflowStep.action !== WorkflowActions.WHATSAPP_ATTENDEE
|
||||
) {
|
||||
if (message?.length && message?.length > 0 && sendTo) {
|
||||
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, "", true);
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
|
||||
@@ -173,7 +173,8 @@ export const AddActionDialog = (props: IAddActionDialog) => {
|
||||
label: string;
|
||||
value: WorkflowActions;
|
||||
needsUpgrade: boolean;
|
||||
}) => option.needsUpgrade}
|
||||
needsVerification: boolean;
|
||||
}) => option.needsUpgrade || option.needsVerification}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { isTextMessageToAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -10,7 +11,7 @@ import { WorkflowActions } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, EmptyScreen, showToast, Switch, Tooltip, Alert } from "@calcom/ui";
|
||||
import { ExternalLink, Zap, Lock } from "@calcom/ui/components/icon";
|
||||
import { ExternalLink, Zap, Lock, Info } from "@calcom/ui/components/icon";
|
||||
|
||||
import LicenseRequired from "../../common/components/LicenseRequired";
|
||||
import { getActionIcon } from "../lib/getActionIcon";
|
||||
@@ -22,6 +23,7 @@ type ItemProps = {
|
||||
eventType: {
|
||||
id: number;
|
||||
title: string;
|
||||
requiresConfirmation: boolean;
|
||||
};
|
||||
isChildrenManagedEventType: boolean;
|
||||
};
|
||||
@@ -101,71 +103,90 @@ const WorkflowListItem = (props: ItemProps) => {
|
||||
}
|
||||
});
|
||||
|
||||
const needsRequiresConfirmationWarning =
|
||||
!eventType.requiresConfirmation &&
|
||||
workflow.steps.find((step) => {
|
||||
return isTextMessageToAttendeeAction(step.action);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-subtle flex w-full items-center overflow-hidden rounded-md border p-6 px-3 md:p-6">
|
||||
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
|
||||
{getActionIcon(
|
||||
workflow.steps,
|
||||
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
|
||||
)}
|
||||
</div>
|
||||
<div className=" grow">
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
|
||||
workflow.name && isActive ? "text-emphasis" : "text-subtle"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
|
||||
isActive ? "text-default" : "text-muted"
|
||||
)}>
|
||||
<span className="mr-1">{t("to")}:</span>
|
||||
{Array.from(sendTo).map((sendToPerson, index) => {
|
||||
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!workflow.readOnly && (
|
||||
<div className="flex-none">
|
||||
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
|
||||
<Button type="button" color="minimal" className="mr-4">
|
||||
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
|
||||
<ExternalLink className="text-default -mt-[2px] h-4 w-4 stroke-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
t(
|
||||
workflow.readOnly && props.isChildrenManagedEventType
|
||||
? "locked_by_admin"
|
||||
: isActive
|
||||
? "turn_off"
|
||||
: "turn_on"
|
||||
) as string
|
||||
}>
|
||||
<div className="flex items-center ltr:mr-2 rtl:ml-2">
|
||||
{workflow.readOnly && props.isChildrenManagedEventType && (
|
||||
<Lock className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
|
||||
<div className="flex items-center ">
|
||||
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
|
||||
{getActionIcon(
|
||||
workflow.steps,
|
||||
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
|
||||
)}
|
||||
<Switch
|
||||
checked={isActive}
|
||||
disabled={workflow.readOnly}
|
||||
onCheckedChange={() => {
|
||||
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className=" grow">
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
|
||||
workflow.name && isActive ? "text-emphasis" : "text-subtle"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
|
||||
isActive ? "text-default" : "text-muted"
|
||||
)}>
|
||||
<span className="mr-1">{t("to")}:</span>
|
||||
{Array.from(sendTo).map((sendToPerson, index) => {
|
||||
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
{!workflow.readOnly && (
|
||||
<div className="flex-none">
|
||||
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
|
||||
<Button type="button" color="minimal" className="mr-4">
|
||||
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
|
||||
<ExternalLink className="text-default -mt-[2px] h-4 w-4 stroke-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
t(
|
||||
workflow.readOnly && props.isChildrenManagedEventType
|
||||
? "locked_by_admin"
|
||||
: isActive
|
||||
? "turn_off"
|
||||
: "turn_on"
|
||||
) as string
|
||||
}>
|
||||
<div className="flex items-center ltr:mr-2 rtl:ml-2">
|
||||
{workflow.readOnly && props.isChildrenManagedEventType && (
|
||||
<Lock className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
)}
|
||||
<Switch
|
||||
checked={isActive}
|
||||
disabled={workflow.readOnly}
|
||||
onCheckedChange={() => {
|
||||
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{needsRequiresConfirmationWarning ? (
|
||||
<div className="text-attention -mb-2 mt-3 flex">
|
||||
<Info className="mr-1 mt-0.5 h-4 w-4" />
|
||||
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogContent, DialogClose, DialogFooter } from "@calcom/ui";
|
||||
|
||||
export const KYCVerificationDialog = (props: {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
isPartOfTeam: boolean;
|
||||
}) => {
|
||||
const { isOpenDialog, setIsOpenDialog, isPartOfTeam } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const isTeamString = isPartOfTeam ? "team" : "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent title={t("verify_team_or_account", { teamOrAccount: isTeamString || "account" })}>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Trans
|
||||
i18nKey="kyc_verification_information"
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href={`mailto:support@cal.com?subject=${
|
||||
isPartOfTeam ? "Team%20Verification" : "Account%20Verification"
|
||||
}`}
|
||||
style={{ color: "#3E3E3E" }}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
values={{
|
||||
supportEmail:
|
||||
SUPPORT_MAIL_ADDRESS === "help@cal.com" ? "support@cal.com" : SUPPORT_MAIL_ADDRESS,
|
||||
teamOrAccount: isTeamString || "account",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Trans
|
||||
i18nKey="kyc_verification_documents"
|
||||
components={{ li: <li />, ul: <ul className="ml-8 list-disc" /> }}
|
||||
values={{ teamOrUser: isPartOfTeam ? "team URL" : "user name" }}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import type { UseFormReturn } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
import type { WorkflowActions } from "@calcom/prisma/enums";
|
||||
@@ -18,6 +19,7 @@ import { isSMSAction, isWhatsappAction } from "../lib/actionHelperFunctions";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import { AddActionDialog } from "./AddActionDialog";
|
||||
import { DeleteDialog } from "./DeleteDialog";
|
||||
import { KYCVerificationDialog } from "./KYCVerificationDialog";
|
||||
import WorkflowStepContainer from "./WorkflowStepContainer";
|
||||
|
||||
type User = RouterOutputs["viewer"]["me"];
|
||||
@@ -39,11 +41,15 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
|
||||
const [isKYCVerificationDialogOpen, setKYCVerificationDialogOpen] = useState(false);
|
||||
|
||||
const [reload, setReload] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery();
|
||||
|
||||
const isPartOfTeam = useHasTeamPlan();
|
||||
|
||||
const eventTypeOptions = useMemo(
|
||||
() =>
|
||||
data?.eventTypeGroups.reduce((options, group) => {
|
||||
@@ -114,7 +120,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="my-8 sm:my-0 md:flex">
|
||||
<div className="z-1 my-8 sm:my-0 md:flex">
|
||||
<div className="pl-2 pr-3 md:sticky md:top-6 md:h-0 md:pl-0">
|
||||
<div className="mb-5">
|
||||
<TextField
|
||||
@@ -168,6 +174,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
user={props.user}
|
||||
teamId={teamId}
|
||||
readOnly={props.readOnly}
|
||||
setKYCVerificationDialogOpen={setKYCVerificationDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -184,6 +191,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
setReload={setReload}
|
||||
teamId={teamId}
|
||||
readOnly={props.readOnly}
|
||||
setKYCVerificationDialogOpen={setKYCVerificationDialogOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -212,6 +220,11 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
setIsOpenDialog={setIsAddActionDialogOpen}
|
||||
addAction={addAction}
|
||||
/>
|
||||
<KYCVerificationDialog
|
||||
isOpenDialog={isKYCVerificationDialogOpen}
|
||||
setIsOpenDialog={setKYCVerificationDialogOpen}
|
||||
isPartOfTeam={!!isPartOfTeam.hasTeamPlan}
|
||||
/>
|
||||
<DeleteDialog
|
||||
isOpenDialog={deleteDialogOpen}
|
||||
setIsOpenDialog={setDeleteDialogOpen}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isSMSOrWhatsappAction,
|
||||
isWhatsappAction,
|
||||
getWhatsappTemplateForAction,
|
||||
isTextMessageToAttendeeAction,
|
||||
} from "../lib/actionHelperFunctions";
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
|
||||
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
|
||||
@@ -66,6 +67,7 @@ type WorkflowStepProps = {
|
||||
setReload?: Dispatch<SetStateAction<boolean>>;
|
||||
teamId?: number;
|
||||
readOnly: boolean;
|
||||
setKYCVerificationDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
@@ -118,6 +120,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
const [showTimeSectionAfter, setShowTimeSectionAfter] = useState(
|
||||
form.getValues("trigger") === WorkflowTriggerEvents.AFTER_EVENT
|
||||
);
|
||||
|
||||
const [isRequiresConfirmationNeeded, setIsRequiresConfirmationNeeded] = useState(
|
||||
isTextMessageToAttendeeAction(step?.action)
|
||||
);
|
||||
|
||||
const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
|
||||
const triggerOptions = getWorkflowTriggerOptions(t);
|
||||
const templateOptions = getWorkflowTemplateOptions(t, step?.action);
|
||||
@@ -331,6 +338,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
label: actionString.charAt(0).toUpperCase() + actionString.slice(1),
|
||||
value: step.action,
|
||||
needsUpgrade: false,
|
||||
needsVerification: false,
|
||||
verificationAction: () => props.setKYCVerificationDialogOpen(true),
|
||||
};
|
||||
|
||||
const selectedTemplate = { label: t(`${step.template.toLowerCase()}`), value: step.template };
|
||||
@@ -448,6 +457,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
setIsEmailSubjectNeeded(true);
|
||||
}
|
||||
|
||||
if (isTextMessageToAttendeeAction(val.value)) {
|
||||
setIsRequiresConfirmationNeeded(true);
|
||||
} else {
|
||||
setIsRequiresConfirmationNeeded(false);
|
||||
}
|
||||
|
||||
if (
|
||||
form.getValues(`steps.${step.stepNumber - 1}.template`) ===
|
||||
WorkflowTemplates.REMINDER
|
||||
@@ -513,16 +528,28 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
}
|
||||
}}
|
||||
defaultValue={selectedAction}
|
||||
options={actionOptions}
|
||||
options={actionOptions?.map((option) => ({
|
||||
...option,
|
||||
verificationAction: () => props.setKYCVerificationDialogOpen(true),
|
||||
}))}
|
||||
isOptionDisabled={(option: {
|
||||
label: string;
|
||||
value: WorkflowActions;
|
||||
needsUpgrade: boolean;
|
||||
}) => option.needsUpgrade}
|
||||
needsVerification: boolean;
|
||||
}) => option.needsUpgrade || option.needsVerification}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{isRequiresConfirmationNeeded ? (
|
||||
<div className="text-attention mb-3 mt-2 flex">
|
||||
<Info className="mr-1 mt-0.5 h-4 w-4" />
|
||||
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
{isPhoneNumberNeeded && (
|
||||
<div className="bg-muted mt-2 rounded-md p-4 pt-0">
|
||||
|
||||
@@ -38,6 +38,10 @@ export function isAttendeeAction(action: WorkflowActions) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isTextMessageToAttendeeAction(action?: WorkflowActions) {
|
||||
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE;
|
||||
}
|
||||
|
||||
export function getWhatsappTemplateForTrigger(trigger: WorkflowTriggerEvents): WorkflowTemplates {
|
||||
switch (trigger) {
|
||||
case "NEW_EVENT":
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TFunction } from "next-i18next";
|
||||
|
||||
import { WorkflowActions } from "@calcom/prisma/enums";
|
||||
|
||||
import { isSMSOrWhatsappAction, isWhatsappAction } from "./actionHelperFunctions";
|
||||
import {
|
||||
isTextMessageToAttendeeAction,
|
||||
isSMSOrWhatsappAction,
|
||||
isWhatsappAction,
|
||||
} from "./actionHelperFunctions";
|
||||
import {
|
||||
TIME_UNIT,
|
||||
WHATSAPP_WORKFLOW_TEMPLATES,
|
||||
@@ -11,7 +15,7 @@ import {
|
||||
WORKFLOW_TRIGGER_EVENTS,
|
||||
} from "./constants";
|
||||
|
||||
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) {
|
||||
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isKYCVerified?: boolean) {
|
||||
return WORKFLOW_ACTIONS.filter((action) => action !== WorkflowActions.EMAIL_ADDRESS) //removing EMAIL_ADDRESS for now due to abuse episode
|
||||
.map((action) => {
|
||||
const actionString = t(`${action.toLowerCase()}_action`);
|
||||
@@ -20,6 +24,7 @@ export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) {
|
||||
label: actionString.charAt(0).toUpperCase() + actionString.slice(1),
|
||||
value: action,
|
||||
needsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan,
|
||||
needsVerification: isTextMessageToAttendeeAction(action) && !isKYCVerified,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||