Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf9c881d16 | ||
|
|
6dd0f9ce29 | ||
|
|
d34db7aef6 | ||
|
|
b4b90f2d27 | ||
|
|
3f0380617a | ||
|
|
7143923ea4 | ||
|
|
dfccf4604f | ||
|
|
c12abd75a8 | ||
|
|
2e12f09f05 | ||
|
|
4258da1df5 | ||
|
|
41ef354c7b | ||
|
|
8891641953 | ||
|
|
0150041496 | ||
|
|
13f9e97015 | ||
|
|
3c5901edb8 | ||
|
|
d26bab5880 | ||
|
|
1c451a8d51 | ||
|
|
576261bf88 | ||
|
|
7d944af7f6 | ||
|
|
dfe7056c93 | ||
|
|
b27f01449a | ||
|
|
6dd77d0494 | ||
|
|
f73d38b632 | ||
|
|
a7d8f0c84e | ||
|
|
b0164990c0 | ||
|
|
bad72ba7bf | ||
|
|
1b317a98a4 | ||
|
|
6a22d75caa | ||
|
|
a030861423 | ||
|
|
bd9cd00c04 | ||
|
|
6fd34af6f6 | ||
|
|
1a85f336c5 | ||
|
|
cc5096a54a | ||
|
|
eea7059226 | ||
|
|
63f3247b1b | ||
|
|
7a769c38fb | ||
|
|
34c2565c70 | ||
|
|
a130804f18 | ||
|
|
446e851065 | ||
|
|
f06b4bfb9c | ||
|
|
1896efa3a0 | ||
|
|
62c8603863 | ||
|
|
7d8559b0b0 | ||
|
|
7a48bbe553 | ||
|
|
817e95c246 | ||
|
|
4ac6225a35 | ||
|
|
38d9b93dee | ||
|
|
376b38d4ed | ||
|
|
0e181f6d9f |
@@ -1,6 +1,6 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
@@ -37,7 +37,7 @@ Contributions are what make the open source community such an amazing place to l
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Features (Booking page, availabilty, timezone calculation)
|
||||
Core Features (Booking page, availability, timezone calculation)
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||
@@ -132,7 +132,6 @@ If you get errors, be sure to fix them before committing.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue
|
||||
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
|
||||
@@ -423,7 +423,7 @@ yarn seed-app-store
|
||||
```
|
||||
|
||||
You will need to complete a few more steps to activate Google Calendar App.
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After the do the
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After that do the
|
||||
following
|
||||
|
||||
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
||||
@@ -449,8 +449,8 @@ following
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
12. Click "Done".
|
||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: "https://cal.com/ai",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
|
||||
@@ -45,6 +45,7 @@ export const POST = async (request: NextRequest) => {
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
timeZone: true,
|
||||
credentials: {
|
||||
select: {
|
||||
appId: true,
|
||||
@@ -55,15 +56,17 @@ export const POST = async (request: NextRequest) => {
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
if (!signature || !user?.email || !user?.id) {
|
||||
// User is not a cal.com user or is using an unverified email.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, you are not authorized to use this service. Please verify your email address and try again.",
|
||||
to: user?.email || "",
|
||||
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse();
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||
@@ -93,8 +96,8 @@ export const POST = async (request: NextRequest) => {
|
||||
fetchAvailability({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
dateFrom: now,
|
||||
dateTo: now,
|
||||
dateFrom: now(user.timeZone),
|
||||
dateTo: now(user.timeZone),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -120,7 +123,7 @@ export const POST = async (request: NextRequest) => {
|
||||
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
const { timeZone, workingHours } = availability;
|
||||
const { workingHours } = availability;
|
||||
|
||||
const appHost = getHostFromHeaders(request.headers);
|
||||
|
||||
@@ -135,7 +138,7 @@ export const POST = async (request: NextRequest) => {
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
timeZone,
|
||||
timeZone: user.timeZone,
|
||||
workingHours,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -52,9 +52,8 @@ const createBooking = async ({
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
// Let GPT handle this. This will happen when wrong event type id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ const cancelBooking = async ({
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -35,9 +35,7 @@ export const fetchAvailability = async ({
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ const editBooking = async ({
|
||||
method: "PATCH",
|
||||
});
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const agent = async (input: string, user: User, apiKey: string, userId: number)
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
|
||||
|
||||
Current UTC time is: ${now}
|
||||
The current time in the user's timezone is: ${now(user.timeZone)}
|
||||
The user's time zone is: ${user.timeZone}
|
||||
The user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export default new Date().toISOString();
|
||||
export default function now(timeZone: string) {
|
||||
return new Date().toLocaleString("en-US", {
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Meta } from "@storybook/addon-docs";
|
||||
<a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">
|
||||
Figma
|
||||
</a>{" "}
|
||||
library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||
library is available for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||
the design team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
@@ -80,7 +81,13 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
src={logo}
|
||||
alt={`${title} logo`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
||||
|
||||
@@ -306,7 +306,7 @@ export const EventSetupTab = (
|
||||
"h-4 w-4",
|
||||
// invert all the icons except app icons
|
||||
eventLocationType.iconUrl &&
|
||||
!eventLocationType.iconUrl.startsWith("/app-store") &&
|
||||
eventLocationType.iconUrl.includes("-dark") &&
|
||||
"dark:invert"
|
||||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
|
||||
@@ -66,6 +66,7 @@ type Props = {
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
isUpdateMutationLoading?: boolean;
|
||||
availability?: AvailabilityOption;
|
||||
isUserOrganizationAdmin: boolean;
|
||||
};
|
||||
|
||||
function getNavigation(props: {
|
||||
@@ -133,6 +134,7 @@ function EventTypeSingleLayout({
|
||||
isUpdateMutationLoading,
|
||||
formMethods,
|
||||
availability,
|
||||
isUserOrganizationAdmin,
|
||||
}: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
@@ -142,7 +144,8 @@ function EventTypeSingleLayout({
|
||||
const hasPermsToDelete =
|
||||
currentUserMembership?.role !== "MEMBER" ||
|
||||
!currentUserMembership ||
|
||||
eventType.schedulingType === SchedulingType.MANAGED;
|
||||
eventType.schedulingType === SchedulingType.MANAGED ||
|
||||
isUserOrganizationAdmin;
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -98,14 +98,7 @@ const UserProfile = () => {
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && (
|
||||
<Avatar
|
||||
alt={user.username || "user avatar"}
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
)}
|
||||
{user && <Avatar alt={user.username || "user avatar"} size="lg" imageSrc={imageSrc} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { components } from "react-select";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
export type LocationOption = {
|
||||
@@ -28,7 +27,7 @@ const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
src={icon}
|
||||
alt="cover"
|
||||
// invert all the icons except app icons
|
||||
className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/app-store") && "dark:invert")}
|
||||
className={classNames(icon.includes("-dark") && "dark:invert", "h-3.5 w-3.5")}
|
||||
/>
|
||||
)}
|
||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||
@@ -57,7 +56,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
{e.icon && (
|
||||
<img
|
||||
src={e.icon}
|
||||
alt="app-icon"
|
||||
className={classNames(e.icon.includes("-dark") && "dark:invert", "h-5 w-5")}
|
||||
/>
|
||||
)}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ function getCspPolicy(nonce: string) {
|
||||
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
|
||||
} app.cal.com;
|
||||
font-src 'self';
|
||||
img-src 'self' ${WEBAPP_URL} https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:;
|
||||
img-src 'self' ${WEBAPP_URL} https://img.youtube.com https://eu.ui-avatars.com/api/ data:;
|
||||
connect-src 'self'
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export const defaultAvatarSrc = function ({ email, md5 }: { md5?: string; email?: string }) {
|
||||
if (!email && !md5) return "";
|
||||
|
||||
if (email && !md5) {
|
||||
md5 = crypto.createHash("md5").update(email).digest("hex");
|
||||
}
|
||||
|
||||
return `https://www.gravatar.com/avatar/${md5}?s=160&d=mp&r=PG`;
|
||||
};
|
||||
@@ -450,6 +450,13 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
source: "/support",
|
||||
missing: [
|
||||
{
|
||||
type: "header",
|
||||
key: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
destination: "/event-types?openIntercom=true",
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.2.7",
|
||||
"version": "3.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
username: z.string(),
|
||||
@@ -75,12 +73,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
res.setHeader("x-cal-org", identity.org);
|
||||
}
|
||||
res.writeHead(302, {
|
||||
Location: defaultAvatarSrc({
|
||||
md5: crypto
|
||||
.createHash("md5")
|
||||
.update(identity?.email || "guest@example.com")
|
||||
.digest("hex"),
|
||||
}),
|
||||
Location: AVATAR_FALLBACK,
|
||||
});
|
||||
|
||||
return res.end();
|
||||
|
||||
@@ -12,7 +12,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import type { HorizontalTabItemProps } from "@calcom/ui";
|
||||
import { AllApps, AppStoreCategories, HorizontalTabs, TextField, PopularAppsSlider } from "@calcom/ui";
|
||||
import {
|
||||
AllApps,
|
||||
AppStoreCategories,
|
||||
HorizontalTabs,
|
||||
TextField,
|
||||
PopularAppsSlider,
|
||||
RecentAppsSlider,
|
||||
} from "@calcom/ui";
|
||||
import { Search } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -81,6 +88,7 @@ export default function Apps({
|
||||
<>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<PopularAppsSlider items={appStore} />
|
||||
<RecentAppsSlider items={appStore} />
|
||||
</>
|
||||
)}
|
||||
<AllApps
|
||||
|
||||
@@ -52,6 +52,8 @@ export default function Login({
|
||||
totpEmail,
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const isTeamInvite = searchParams.get("teamInvite");
|
||||
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const formSchema = z
|
||||
@@ -95,7 +97,9 @@ export default function Login({
|
||||
callbackUrl = safeCallbackUrl || "";
|
||||
|
||||
const LoginFooter = (
|
||||
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||
<a
|
||||
href={callbackUrl !== "" ? `${WEBSITE_URL}/signup?callbackUrl=${callbackUrl}` : `${WEBSITE_URL}/signup`}
|
||||
className="text-brand-500 font-medium">
|
||||
{t("dont_have_an_account")}
|
||||
</a>
|
||||
);
|
||||
@@ -184,6 +188,9 @@ export default function Login({
|
||||
? LoginFooter
|
||||
: null
|
||||
}>
|
||||
{isTeamInvite && (
|
||||
<Alert severity="info" message={t("signin_or_signup_to_accept_invite")} className="mb-4 mt-4" />
|
||||
)}
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
|
||||
<div>
|
||||
|
||||
@@ -443,7 +443,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
isUpdateMutationLoading={updateMutation.isLoading}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
currentUserMembership={currentUserMembership}>
|
||||
currentUserMembership={currentUserMembership}
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||
<Form
|
||||
form={formMethods}
|
||||
id="event-type-form"
|
||||
|
||||
@@ -402,7 +402,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isManagedEventType && (
|
||||
{isManagedEventType && type.children && (
|
||||
<AvatarGroup
|
||||
className="relative right-3 top-1"
|
||||
size="sm"
|
||||
@@ -662,6 +662,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
title={t(`delete${isManagedEventPrefix()}_event_type`)}
|
||||
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||
loadingText={t(`confirm_delete_event_type`)}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteEventTypeHandler(deleteDialogTypeId);
|
||||
@@ -873,7 +874,7 @@ const Main = ({
|
||||
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||
) : (
|
||||
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
|
||||
<div className="flex flex-col" key={group.profile.slug}>
|
||||
<div className="mt-4 flex flex-col" key={group.profile.slug}>
|
||||
<EventTypeListHeading
|
||||
profile={group.profile}
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
|
||||
@@ -406,7 +406,7 @@ const ProfileForm = ({
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
|
||||
<Avatar alt="" imageSrc={value} size="lg" />
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
@@ -34,6 +35,23 @@ type FormValues = z.infer<typeof signupSchema>;
|
||||
|
||||
type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
const getSafeCallbackUrl = (url: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let callbackUrl = url;
|
||||
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (!/^https?:\/\//.test(callbackUrl)) {
|
||||
callbackUrl = `${WEBAPP_URL}/${callbackUrl}`;
|
||||
}
|
||||
|
||||
const safeCallbackUrl = getSafeRedirectUrl(callbackUrl);
|
||||
|
||||
return safeCallbackUrl;
|
||||
};
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
@@ -55,6 +73,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
throw new Error(err.message);
|
||||
}
|
||||
};
|
||||
const callbackUrl = getSafeCallbackUrl(searchParams.get("callbackUrl"));
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (data) => {
|
||||
await fetch("/api/auth/signup", {
|
||||
@@ -72,13 +91,10 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
.then(async () => {
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
|
||||
|
||||
await signIn<"credentials">("credentials", {
|
||||
...data,
|
||||
callbackUrl: `${
|
||||
searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/${verifyOrGettingStarted}`
|
||||
}?from=signup`,
|
||||
callbackUrl: `${callbackUrl ? callbackUrl : `${WEBAPP_URL}/${verifyOrGettingStarted}`}?from=signup`,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -157,9 +173,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
className="w-full justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/getting-started`,
|
||||
callbackUrl: callbackUrl ? callbackUrl : `${WEBAPP_URL}/getting-started`,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
@@ -41,6 +43,21 @@ function Teams() {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
await ssr.viewer.me.prefetch();
|
||||
const session = await getServerSession({ req: context.req, res: context.res });
|
||||
const token = context.query?.token;
|
||||
const resolvedUrl = context.resolvedUrl;
|
||||
|
||||
const callbackUrl = token ? getSafeRedirectUrl(`${WEBAPP_URL}${resolvedUrl}`) : null;
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}&teamInvite=true` : "/auth/login",
|
||||
permanent: false,
|
||||
},
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
|
||||
));
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\..*`;
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
|
||||
|
||||
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
|
||||
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
|
||||
|
||||
@@ -268,3 +268,60 @@ test.describe("prefill", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking on different layouts", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await page.goto(`/${user.username}`);
|
||||
});
|
||||
|
||||
test("Book on week layout", async ({ page }) => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="toggle-group-item-week_view"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="calendar-empty-cell"]').nth(0).click();
|
||||
|
||||
// Fill what is this meeting about? name email and notes
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
await page.locator('[name="notes"]').fill("Test notes");
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
// expect page to be booking page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Book on column layout", async ({ page }) => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="toggle-group-item-column_view"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
|
||||
// Fill what is this meeting about? name email and notes
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
await page.locator('[name="notes"]').fill("Test notes");
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
// expect page to be booking page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
type Route = {
|
||||
id: string;
|
||||
action: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
isFallback: boolean;
|
||||
queryValue: {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const createRoutingFormsFixture = () => {
|
||||
return {
|
||||
async create({
|
||||
userId,
|
||||
teamId,
|
||||
name,
|
||||
fields,
|
||||
routes = [],
|
||||
}: {
|
||||
name: string;
|
||||
userId: number;
|
||||
teamId: number | null;
|
||||
routes?: Route[];
|
||||
fields: {
|
||||
type: string;
|
||||
label: string;
|
||||
identifier?: string;
|
||||
required: boolean;
|
||||
}[];
|
||||
}) {
|
||||
return await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name,
|
||||
userId,
|
||||
teamId,
|
||||
routes: [
|
||||
...routes,
|
||||
// Add a fallback route always, this is taken care of tRPC route normally but do it manually while running the query directly.
|
||||
{
|
||||
id: "898899aa-4567-489a-bcde-f1823f708646",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: fields.map((f) => ({
|
||||
id: uuidv4(),
|
||||
...f,
|
||||
})),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
import { createServersFixture } from "../fixtures/servers";
|
||||
import { createUsersFixture } from "../fixtures/users";
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface Fixtures {
|
||||
servers: ReturnType<typeof createServersFixture>;
|
||||
prisma: typeof prisma;
|
||||
emails?: API;
|
||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -71,6 +73,9 @@ export const test = base.extend<Fixtures>({
|
||||
prisma: async ({}, use) => {
|
||||
await use(prisma);
|
||||
},
|
||||
routingForms: async ({}, use) => {
|
||||
await use(createRoutingFormsFixture());
|
||||
},
|
||||
emails: async ({}, use) => {
|
||||
if (IS_MAILHOG_ENABLED) {
|
||||
const mailhogAPI = mailhog();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
gotoRoutingLink,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("BOOKING_CREATED", async () => {
|
||||
@@ -55,8 +59,6 @@ test.describe("BOOKING_CREATED", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body: any = request.body;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
@@ -187,8 +189,6 @@ test.describe("BOOKING_REJECTED", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
@@ -311,8 +311,6 @@ test.describe("BOOKING_REQUESTED", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
@@ -391,54 +389,136 @@ test.describe("BOOKING_REQUESTED", async () => {
|
||||
});
|
||||
|
||||
test.describe("FORM_SUBMITTED", async () => {
|
||||
test("can submit a form and get a submission event", async ({ page, users }) => {
|
||||
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
const user = await users.create();
|
||||
const user = await users.create(null, {
|
||||
hasTeam: true,
|
||||
});
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/teams/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const teamName = `${user.username}'s Team`;
|
||||
// Create a new team
|
||||
await page.locator('input[name="name"]').fill(teamName);
|
||||
await page.locator('input[name="slug"]').fill(teamName);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto(`/settings/developer/webhooks/new`);
|
||||
|
||||
// Add webhook
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
await Promise.all([page.click("[type=submit]"), page.goForward()]);
|
||||
await page.click("[type=submit]");
|
||||
|
||||
// Page contains the url
|
||||
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto("/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
// Choose to create the Form for the user(which is the first option) and not the team
|
||||
await page.click('[data-testid="option-0"]');
|
||||
await page.fill("input[name]", "TEST FORM");
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
const form = await routingForms.create({
|
||||
name: "Test Form",
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await gotoRoutingLink({ page, formId: formId });
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
page.click('button[type="submit"]');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
const [request] = webhookReceiver.requestList;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
body.createdAt = dynamic;
|
||||
expect(body).toEqual({
|
||||
triggerEvent: "FORM_SUBMITTED",
|
||||
createdAt: dynamic,
|
||||
payload: {
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
teamId: null,
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
name: "John Doe",
|
||||
});
|
||||
|
||||
webhookReceiver.close();
|
||||
});
|
||||
|
||||
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
const user = await users.create(null, {
|
||||
hasTeam: true,
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto(`/settings/developer/webhooks`);
|
||||
const teamId = await clickFirstTeamWebhookCta(page);
|
||||
|
||||
// Add webhook
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
const form = await routingForms.create({
|
||||
name: "Test Form",
|
||||
userId: user.id,
|
||||
teamId: teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
page.click('button[type="submit"]');
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
const [request] = webhookReceiver.requestList;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
body.createdAt = dynamic;
|
||||
expect(body).toEqual({
|
||||
triggerEvent: "FORM_SUBMITTED",
|
||||
createdAt: dynamic,
|
||||
payload: {
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
teamId,
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
name: "John Doe",
|
||||
});
|
||||
|
||||
webhookReceiver.close();
|
||||
});
|
||||
});
|
||||
|
||||
async function clickFirstTeamWebhookCta(page: Page) {
|
||||
await page.click('[data-testid="new_webhook"]');
|
||||
await page.click('[data-testid="option-team-1"]');
|
||||
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
|
||||
const url = page.url();
|
||||
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
|
||||
return teamId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><rect width="32" height="32" rx="16" fill="#E5E7EB"/><path d="M16 21.333A6.667 6.667 0 1 0 16 8a6.667 6.667 0 0 0 0 13.333Z" fill="#1F2937"/><path d="M26.667 32a10.667 10.667 0 1 0-21.334 0" fill="#1F2937"/><path d="M16 21.333A6.667 6.667 0 1 0 16 8a6.667 6.667 0 0 0 0 13.333Zm0 0A10.667 10.667 0 0 1 26.667 32M16 21.333A10.666 10.666 0 0 0 5.333 32" stroke="#1F2937" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="a"><rect width="32" height="32" rx="16" fill="#fff"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -1074,7 +1074,7 @@
|
||||
"event_cancelled": "تم إلغاء هذا الحدث",
|
||||
"emailed_information_about_cancelled_event": "لقد أرسلنا إليك وإلى الحاضرين الآخرين بريداً إلكترونياً لإعلامهم.",
|
||||
"this_input_will_shown_booking_this_event": "سيُعرَض هذا المُدخَل عند حجز هذا الحدث",
|
||||
"meeting_url_in_conformation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية",
|
||||
"meeting_url_in_confirmation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية",
|
||||
"url_start_with_https": "يجب أن يبدأ العنوان بـ http:// أو https://",
|
||||
"number_provided": "سيتم توفير رقم الهاتف",
|
||||
"before_event_trigger": "قبل بدء الحدث",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Tato událost je zrušena",
|
||||
"emailed_information_about_cancelled_event": "Poslali jsme vám a ostatním účastníkům informační e-mail.",
|
||||
"this_input_will_shown_booking_this_event": "Toto pole se zobrazí při rezervaci této události",
|
||||
"meeting_url_in_conformation_email": "Adresa URL schůzky je v potvrzovacím e-mailu",
|
||||
"meeting_url_in_confirmation_email": "Adresa URL schůzky je v potvrzovacím e-mailu",
|
||||
"url_start_with_https": "Adresa URL musí začínat http:// nebo https://",
|
||||
"number_provided": "Bude uvedeno telefonní číslo",
|
||||
"before_event_trigger": "před zahájením události",
|
||||
|
||||
@@ -960,7 +960,7 @@
|
||||
"reschedule_placeholder": "Lad andre vide, hvorfor du har brug for at omlægge",
|
||||
"emailed_information_about_cancelled_event": "Vi har sendt en e-mail til dig og de andre deltagere for at give dem besked.",
|
||||
"this_input_will_shown_booking_this_event": "Dette input vil blive vist ved booking af denne begivenhed",
|
||||
"meeting_url_in_conformation_email": "Møde-url er i bekræftelses-mailen",
|
||||
"meeting_url_in_confirmation_email": "Møde-url er i bekræftelses-mailen",
|
||||
"url_start_with_https": "URL'en skal starte med http:// eller https://",
|
||||
"number_provided": "Telefonnummer vil blive angivet",
|
||||
"before_event_trigger": "før begivenheden starter",
|
||||
|
||||
@@ -1095,7 +1095,7 @@
|
||||
"event_cancelled": "Dieser Termin ist abgesagt",
|
||||
"emailed_information_about_cancelled_event": "Wir haben Ihnen und den anderen Teilnehmern eine E-Mail gesendet, damit alle Bescheid wissen.",
|
||||
"this_input_will_shown_booking_this_event": "Diese Eingabe wird bei der Buchung dieses Termins angezeigt",
|
||||
"meeting_url_in_conformation_email": "Termin-URL ist in der Bestätigungsmail",
|
||||
"meeting_url_in_confirmation_email": "Termin-URL ist in der Bestätigungsmail",
|
||||
"url_start_with_https": "URL muss mit http:// oder https:// beginnen",
|
||||
"number_provided": "Telefonnummer wird angegeben",
|
||||
"before_event_trigger": "vor Beginn des Termins",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"invite_team_individual_segment": "Invite individual",
|
||||
"invite_team_bulk_segment": "Bulk import",
|
||||
"invite_team_notifcation_badge": "Inv.",
|
||||
"signin_or_signup_to_accept_invite": "You need to Sign in or Sign up to see team invitation.",
|
||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
||||
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
|
||||
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
|
||||
@@ -1053,12 +1054,15 @@
|
||||
"how_you_want_add_cal_site": "How do you want to add {{appName}} to your site?",
|
||||
"choose_ways_put_cal_site": "Choose one of the following ways to put {{appName}} on your site.",
|
||||
"setting_up_zapier": "Setting up your Zapier integration",
|
||||
"setting_up_make": "Setting up your Make integration",
|
||||
"generate_api_key": "Generate API key",
|
||||
"generate_api_key_description": "Generate an API key to use with {{appName}} at",
|
||||
"your_unique_api_key": "Your unique API key",
|
||||
"copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.",
|
||||
"zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
|
||||
"make_setup_instructions": "<0>Log into your Make account and create a new Scenario.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
|
||||
"install_zapier_app": "Please first install the Zapier App in the app store.",
|
||||
"install_make_app": "Please first install the Make App in the app store.",
|
||||
"connect_apple_server": "Connect to Apple Server",
|
||||
"calendar_url": "Calendar URL",
|
||||
"apple_server_generate_password": "Generate an app specific password to use with {{appName}} at",
|
||||
@@ -1089,6 +1093,7 @@
|
||||
"or_lowercase": "or",
|
||||
"nevermind": "Nevermind",
|
||||
"go_to": "Go to: ",
|
||||
"invite_link_make": "<li>Go to<a>Make Invite Link</a> and install Cal.com app</li>",
|
||||
"zapier_invite_link": "Zapier Invite Link",
|
||||
"meeting_url_provided_after_confirmed": "A Meeting URL will be created once the event is confirmed.",
|
||||
"dynamically_display_attendee_or_organizer": "Dynamically display the name of your attendee for you, or your name if it's viewed by your attendee",
|
||||
@@ -1098,7 +1103,7 @@
|
||||
"event_cancelled": "This event is canceled",
|
||||
"emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.",
|
||||
"this_input_will_shown_booking_this_event": "This input will be shown when booking this event",
|
||||
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email",
|
||||
"meeting_url_in_confirmation_email": "Meeting url is in the confirmation email",
|
||||
"url_start_with_https": "URL needs to start with http:// or https://",
|
||||
"number_provided": "Phone number will be provided",
|
||||
"before_event_trigger": "before event starts",
|
||||
@@ -1108,6 +1113,7 @@
|
||||
"email_attendee_action": "send email to attendees",
|
||||
"sms_attendee_action": "Send SMS to attendee",
|
||||
"sms_number_action": "send SMS to a specific number",
|
||||
"send_reminder_sms": "Easily send meeting reminders via SMS to your attendees",
|
||||
"whatsapp_number_action": "send WhatsApp message to a specific number",
|
||||
"whatsapp_attendee_action": "send WhatsApp message to attendee",
|
||||
"workflows": "Workflows",
|
||||
@@ -2036,5 +2042,7 @@
|
||||
"team_no_event_types": "This team has no event types",
|
||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||
"include_calendar_event": "Include calendar event",
|
||||
"recently_added":"Recently added",
|
||||
"no_members_found": "No members found",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Este evento se canceló",
|
||||
"emailed_information_about_cancelled_event": "Le enviamos un correo electrónico a usted y a los demás asistentes para informarles.",
|
||||
"this_input_will_shown_booking_this_event": "Esta entrada se mostrará al reservar este evento.",
|
||||
"meeting_url_in_conformation_email": "La URL de la reunión está en el correo de confirmación",
|
||||
"meeting_url_in_confirmation_email": "La URL de la reunión está en el correo de confirmación",
|
||||
"url_start_with_https": "La URL debe empezar por http:// o https://",
|
||||
"number_provided": "Se proporcionará el número de teléfono",
|
||||
"before_event_trigger": "antes del inicio del evento",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"invite_team_individual_segment": "Inviter une personne",
|
||||
"invite_team_bulk_segment": "Importation multiple",
|
||||
"invite_team_notifcation_badge": "Inv.",
|
||||
"signin_or_signup_to_accept_invite": "Vous devez vous connecter ou vous inscrire pour voir l'invitation d'équipe.",
|
||||
"your_event_has_been_scheduled": "Votre événement a été planifié",
|
||||
"your_event_has_been_scheduled_recurring": "Votre événement récurrent a été planifié",
|
||||
"accept_our_license": "Acceptez notre licence en changeant la variable .env <1>NEXT_PUBLIC_LICENSE_CONSENT</1> en « {{agree}} ».",
|
||||
@@ -1053,12 +1054,15 @@
|
||||
"how_you_want_add_cal_site": "Comment souhaitez-vous ajouter {{appName}} à votre site ?",
|
||||
"choose_ways_put_cal_site": "Choisissez l'une des méthodes suivantes pour mettre {{appName}} sur votre site.",
|
||||
"setting_up_zapier": "Configuration de votre intégration Zapier",
|
||||
"setting_up_make": "Configuration de votre intégration Make",
|
||||
"generate_api_key": "Générer une clé API",
|
||||
"generate_api_key_description": "Générer une clé API à utiliser avec {{appName}} sur",
|
||||
"your_unique_api_key": "Votre clé API unique",
|
||||
"copy_safe_api_key": "Copiez cette clé API et conservez-la dans un endroit sûr. Si vous perdez cette clé, vous devrez en générer une nouvelle.",
|
||||
"zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.</0><1>Sélectionnez Cal.com comme application Trigger. Choisissez également un événement Trigger.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre Trigger.</3><4>Vous êtes prêt !</4>",
|
||||
"zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.</0><1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre déclencheur.</3><4>Vous êtes prêt !</4>",
|
||||
"make_setup_instructions": "<0>Connectez-vous à votre compte Make et créez un nouveau Scénario.</0><1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre déclencheur.</3><4>Vous êtes prêt !</4>",
|
||||
"install_zapier_app": "Veuillez d'abord installer l'application Zapier dans l'App Store.",
|
||||
"install_make_app": "Veuillez d'abord installer l'application Make dans l'App Store.",
|
||||
"connect_apple_server": "Se connecter au serveur d'Apple",
|
||||
"calendar_url": "Lien du calendrier",
|
||||
"apple_server_generate_password": "Générez un mot de passe pour application à utiliser avec {{appName}} sur",
|
||||
@@ -1089,6 +1093,7 @@
|
||||
"or_lowercase": "ou",
|
||||
"nevermind": "Annuler",
|
||||
"go_to": "Aller à : ",
|
||||
"invite_link_make": "<li>Accédez<a>au lien d'invitation Make</a> et installez l'application Cal.com</li>",
|
||||
"zapier_invite_link": "Lien d'invitation Zapier",
|
||||
"meeting_url_provided_after_confirmed": "Un lien de rendez-vous sera créé une fois l'événement confirmé.",
|
||||
"dynamically_display_attendee_or_organizer": "Affiche dynamiquement le nom du participant pour vous ou votre nom s'il est vu par le participant",
|
||||
@@ -1098,7 +1103,7 @@
|
||||
"event_cancelled": "Cet événement est annulé",
|
||||
"emailed_information_about_cancelled_event": "Nous vous avons envoyé un e-mail à vous et aux autres participants pour les en informer.",
|
||||
"this_input_will_shown_booking_this_event": "Ce champ sera affiché lors de la réservation de cet événement.",
|
||||
"meeting_url_in_conformation_email": "Le lien du rendez-vous est dans l'e-mail de confirmation",
|
||||
"meeting_url_in_confirmation_email": "Le lien du rendez-vous est dans l'e-mail de confirmation",
|
||||
"url_start_with_https": "Le lien doit commencer par http:// ou https://",
|
||||
"number_provided": "Un numéro de téléphone sera fourni",
|
||||
"before_event_trigger": "avant le début de l'événement",
|
||||
@@ -1108,6 +1113,7 @@
|
||||
"email_attendee_action": "envoyer un e-mail aux participants",
|
||||
"sms_attendee_action": "Envoyer un SMS au participant",
|
||||
"sms_number_action": "envoyer un SMS à un numéro spécifique",
|
||||
"send_reminder_sms": "Envoyez facilement des rappels de rendez-vous par SMS à vos participants",
|
||||
"whatsapp_number_action": "envoyer un message WhatsApp à un numéro spécifique",
|
||||
"whatsapp_attendee_action": "envoyer un message WhatsApp au participant",
|
||||
"workflows": "Workflows",
|
||||
@@ -1115,7 +1121,7 @@
|
||||
"add_new_workflow": "Ajouter un nouveau workflow",
|
||||
"reschedule_event_trigger": "lorsque l'événement est replanifié",
|
||||
"trigger": "Déclencheur",
|
||||
"triggers": "Déclencher",
|
||||
"triggers": "Déclencheurs",
|
||||
"action": "Action",
|
||||
"workflows_to_automate_notifications": "Créez des workflows pour automatiser les notifications et les rappels.",
|
||||
"workflow_name": "Nom du workflow",
|
||||
@@ -2036,5 +2042,7 @@
|
||||
"team_no_event_types": "Cette équipe n'a aucun type d'événement",
|
||||
"seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples",
|
||||
"include_calendar_event": "Inclure l'événement du calendrier",
|
||||
"recently_added": "Ajouté récemment",
|
||||
"no_members_found": "Aucun membre trouvé",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "האירוע בוטל",
|
||||
"emailed_information_about_cancelled_event": "שלחנו לך ולשאר המשתתפים הודעת דוא״ל כדי ליידע אותם על כך.",
|
||||
"this_input_will_shown_booking_this_event": "הקלט יוצג בעת הזמנת אירוע זה",
|
||||
"meeting_url_in_conformation_email": "כתובת ה-URL של הפגישה נמצאת בהודעת הדוא״ל שנשלחה לאישור הפגישה",
|
||||
"meeting_url_in_confirmation_email": "כתובת ה-URL של הפגישה נמצאת בהודעת הדוא״ל שנשלחה לאישור הפגישה",
|
||||
"url_start_with_https": "כתובת ה-URL צריכה להתחיל עם הקידומת http:// או https://",
|
||||
"number_provided": "מספר הטלפון יסופק",
|
||||
"before_event_trigger": "לפני שהאירוע מתחיל",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Questo evento è stato cancellato",
|
||||
"emailed_information_about_cancelled_event": "Abbiamo inviato un'e-mail di notifica a te e agli altri partecipanti.",
|
||||
"this_input_will_shown_booking_this_event": "Questo input verrà mostrato durante la prenotazione di questo evento",
|
||||
"meeting_url_in_conformation_email": "L'URL della riunione è riportato nell'e-mail di conferma",
|
||||
"meeting_url_in_confirmation_email": "L'URL della riunione è riportato nell'e-mail di conferma",
|
||||
"url_start_with_https": "L'URL deve iniziare con http:// o https://",
|
||||
"number_provided": "Verrà fornito il numero di telefono",
|
||||
"before_event_trigger": "prima dell'inizio dell'evento",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "このイベントはキャンセルされました",
|
||||
"emailed_information_about_cancelled_event": "あなたと他の出席者に電子メールでお知らせしました。",
|
||||
"this_input_will_shown_booking_this_event": "この入力は、このイベントを予約する際に表示されます",
|
||||
"meeting_url_in_conformation_email": "ミーティングURLは確認メールに記載されています",
|
||||
"meeting_url_in_confirmation_email": "ミーティングURLは確認メールに記載されています",
|
||||
"url_start_with_https": "URLは、http://またはhttps://で始まる必要があります",
|
||||
"number_provided": "電話番号が提供されます",
|
||||
"before_event_trigger": "イベント開始前に",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "이 이벤트는 취소되었습니다",
|
||||
"emailed_information_about_cancelled_event": "귀하와 다른 참석자들에게 이메일로 알려 드렸습니다.",
|
||||
"this_input_will_shown_booking_this_event": "이 입력은 이 이벤트를 예약할 때 표시됩니다.",
|
||||
"meeting_url_in_conformation_email": "회의 URL은 확인 이메일에 있습니다",
|
||||
"meeting_url_in_confirmation_email": "회의 URL은 확인 이메일에 있습니다",
|
||||
"url_start_with_https": "URL은 http:// 또는 https://로 시작해야 합니다",
|
||||
"number_provided": "전화 번호는 제공됩니다",
|
||||
"before_event_trigger": "이벤트 시작 전",
|
||||
|
||||
@@ -1079,7 +1079,7 @@
|
||||
"event_cancelled": "Deze gebeurtenis is geannuleerd",
|
||||
"emailed_information_about_cancelled_event": "We hebben u en de andere deelnemers een e-mail gestuurd om hen te informeren.",
|
||||
"this_input_will_shown_booking_this_event": "Deze invoer wordt getoond tijdens het boeken van dit evenement",
|
||||
"meeting_url_in_conformation_email": "De vergader-URL wordt vermeld in de bevestigings-e-mail",
|
||||
"meeting_url_in_confirmation_email": "De vergader-URL wordt vermeld in de bevestigings-e-mail",
|
||||
"url_start_with_https": "URL moet beginnen met http:// of https://",
|
||||
"number_provided": "Telefoonnummer wordt verstrekt",
|
||||
"before_event_trigger": "voordat de gebeurtenis begint",
|
||||
|
||||
@@ -941,7 +941,7 @@
|
||||
"reschedule_placeholder": "Fortell andre hvorfor du må endre tidspunktet",
|
||||
"emailed_information_about_cancelled_event": "Vi sendte deg og de andre deltakerne en e-post for å informere.",
|
||||
"this_input_will_shown_booking_this_event": "Dette inputfeltet vil vises når man booker denne hendelsen",
|
||||
"meeting_url_in_conformation_email": "Møte-url ligger i e-postbekreftelsen",
|
||||
"meeting_url_in_confirmation_email": "Møte-url ligger i e-postbekreftelsen",
|
||||
"url_start_with_https": "URL må begynne med http:// eller https://",
|
||||
"number_provided": "Telefonnummer vil bli oppgitt",
|
||||
"before_event_trigger": "før hendelsen starter",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "To wydarzenie zostało anulowane",
|
||||
"emailed_information_about_cancelled_event": "Wysłaliśmy wiadomość e-mail Tobie i innym uczestnikom, aby ich o tym poinformować.",
|
||||
"this_input_will_shown_booking_this_event": "To wejście zostanie wyświetlone podczas rezerwacji tego wydarzenia",
|
||||
"meeting_url_in_conformation_email": "Adres URL spotkania znajduje się w wiadomości e-mail z potwierdzeniem",
|
||||
"meeting_url_in_confirmation_email": "Adres URL spotkania znajduje się w wiadomości e-mail z potwierdzeniem",
|
||||
"url_start_with_https": "Adres URL musi zaczynać się od http:// lub https://",
|
||||
"number_provided": "Numer telefonu zostanie podany.",
|
||||
"before_event_trigger": "przed rozpoczęciem wydarzenia",
|
||||
|
||||
@@ -1091,7 +1091,7 @@
|
||||
"event_cancelled": "Este evento foi cancelado",
|
||||
"emailed_information_about_cancelled_event": "Avisamos por e-mail aos outros participantes e a você.",
|
||||
"this_input_will_shown_booking_this_event": "Este campo será mostrado na reserva deste evento",
|
||||
"meeting_url_in_conformation_email": "O URL da reunião está no e-mail de confirmação",
|
||||
"meeting_url_in_confirmation_email": "O URL da reunião está no e-mail de confirmação",
|
||||
"url_start_with_https": "O URL precisa começar com http:// ou https://",
|
||||
"number_provided": "O número de telefone será fornecido",
|
||||
"before_event_trigger": "antes do início do evento",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Este evento foi cancelado",
|
||||
"emailed_information_about_cancelled_event": "Enviámos um email com o aviso para si e para os restantes participantes.",
|
||||
"this_input_will_shown_booking_this_event": "Este campo será mostrado na reserva deste evento",
|
||||
"meeting_url_in_conformation_email": "O URL da reunião está no email de confirmação",
|
||||
"meeting_url_in_confirmation_email": "O URL da reunião está no email de confirmação",
|
||||
"url_start_with_https": "O URL tem de começar por http:// ou https://",
|
||||
"number_provided": "Será fornecido um número de telefone",
|
||||
"before_event_trigger": "antes do evento iniciar",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Acest eveniment este anulat",
|
||||
"emailed_information_about_cancelled_event": "V-am trimis un e-mail de informare dvs. și celorlalți participanți.",
|
||||
"this_input_will_shown_booking_this_event": "Datele introduse vor fi afișate la rezervarea acestui eveniment",
|
||||
"meeting_url_in_conformation_email": "Adresa URL a întâlnirii este în e-mailul de confirmare",
|
||||
"meeting_url_in_confirmation_email": "Adresa URL a întâlnirii este în e-mailul de confirmare",
|
||||
"url_start_with_https": "Adresa URL trebuie să înceapă cu http:// or https://",
|
||||
"number_provided": "Numărul de telefon va fi furnizat",
|
||||
"before_event_trigger": "înainte de începerea evenimentului",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Это событие отменено",
|
||||
"emailed_information_about_cancelled_event": "Мы сообщили об этом вам и другим участникам по электронной почте.",
|
||||
"this_input_will_shown_booking_this_event": "Этот вопрос будет показан при бронировании события по этому шаблону",
|
||||
"meeting_url_in_conformation_email": "Ссылка на встречу находится в письме с подтверждением",
|
||||
"meeting_url_in_confirmation_email": "Ссылка на встречу находится в письме с подтверждением",
|
||||
"url_start_with_https": "URL-адрес должен начинаться с http:// или https://",
|
||||
"number_provided": "Будет указан номер телефона",
|
||||
"before_event_trigger": "до начала события",
|
||||
|
||||
@@ -1076,7 +1076,7 @@
|
||||
"event_cancelled": "Ovaj događaj je otkazan",
|
||||
"emailed_information_about_cancelled_event": "Poslali smo imejl vama i drugim polaznicima kao obaveštenje.",
|
||||
"this_input_will_shown_booking_this_event": "Ovaj unos će biti prikazan kad se rezerviše ovaj događaj",
|
||||
"meeting_url_in_conformation_email": "Url za sastanak je u imejlu za potvrdu",
|
||||
"meeting_url_in_confirmation_email": "Url za sastanak je u imejlu za potvrdu",
|
||||
"url_start_with_https": "Potrebno je URL da počinje sa http:// or https://",
|
||||
"number_provided": "Broj telefona će biti dostavljen",
|
||||
"before_event_trigger": "pre početka događaja",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Den här händelsen är inställd",
|
||||
"emailed_information_about_cancelled_event": "Vi mejlade dig och de andra deltagarna för att informera om detta.",
|
||||
"this_input_will_shown_booking_this_event": "Detta visas vid bokning av denna händelse",
|
||||
"meeting_url_in_conformation_email": "Mötets webbadress finns i bekräftelsemejlet",
|
||||
"meeting_url_in_confirmation_email": "Mötets webbadress finns i bekräftelsemejlet",
|
||||
"url_start_with_https": "Webbadress behöver inledas med http:// eller https://",
|
||||
"number_provided": "Telefonnummer kommer att anges",
|
||||
"before_event_trigger": "innan händelsen startar",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Bu etkinlik iptal edildi",
|
||||
"emailed_information_about_cancelled_event": "Sizi ve diğer katılımcıları bilgilendirmek için bir e-posta gönderdik.",
|
||||
"this_input_will_shown_booking_this_event": "Bu girdi, bu etkinlik için rezervasyon yapılırken gösterilecek",
|
||||
"meeting_url_in_conformation_email": "Toplantı URL'si onay e-postasında mevcut",
|
||||
"meeting_url_in_confirmation_email": "Toplantı URL'si onay e-postasında mevcut",
|
||||
"url_start_with_https": "URL'nin http:// veya https:// ile başlaması gerekiyor",
|
||||
"number_provided": "Telefon numarası sağlanacaktır",
|
||||
"before_event_trigger": "etkinlik başlamadan önce",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Цей захід скасовано",
|
||||
"emailed_information_about_cancelled_event": "Ми сповістили вас та інших учасників електронною поштою.",
|
||||
"this_input_will_shown_booking_this_event": "Це поле введення буде показано під час бронювання часу для цього заходу",
|
||||
"meeting_url_in_conformation_email": "URL-адресу наради вказано в листі з підтвердженням",
|
||||
"meeting_url_in_confirmation_email": "URL-адресу наради вказано в листі з підтвердженням",
|
||||
"url_start_with_https": "URL-адреса має починатися з http:// або https://",
|
||||
"number_provided": "Номер телефону буде надано",
|
||||
"before_event_trigger": "до початку заходу",
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Sự kiện này bị huỷ",
|
||||
"emailed_information_about_cancelled_event": "Chúng tôi đã gửi email cho bạn và những người tham dự khác để cho họ biết.",
|
||||
"this_input_will_shown_booking_this_event": "Trường này sẽ được hiển thị khi đặt sự kiện này",
|
||||
"meeting_url_in_conformation_email": "Url cuộc họp nằm trong email xác nhận",
|
||||
"meeting_url_in_confirmation_email": "Url cuộc họp nằm trong email xác nhận",
|
||||
"url_start_with_https": "URL cần bắt đầu bằng http:// hoặc https://",
|
||||
"number_provided": "Số điện thoại sẽ được cung ứng",
|
||||
"before_event_trigger": "trước khi sự kiện bắt đầu",
|
||||
|
||||
@@ -1079,7 +1079,7 @@
|
||||
"event_cancelled": "此活动已取消",
|
||||
"emailed_information_about_cancelled_event": "我们已向您和其他参与者发送电子邮件来通知他们。",
|
||||
"this_input_will_shown_booking_this_event": "在预约此活动时将显示该输入框",
|
||||
"meeting_url_in_conformation_email": "会议链接含在确认电子邮件中",
|
||||
"meeting_url_in_confirmation_email": "会议链接含在确认电子邮件中",
|
||||
"url_start_with_https": "链接需要以 http:// 或 https:// 开头",
|
||||
"number_provided": "将提供电话号码",
|
||||
"before_event_trigger": "活动开始前",
|
||||
@@ -1945,5 +1945,6 @@
|
||||
"insights_subtitle": "查看您活动的预约 insights",
|
||||
"custom_plan": "自定义计划",
|
||||
"include_calendar_event": "包括日历活动",
|
||||
"recently_added": "最近添加",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "此活動已取消",
|
||||
"emailed_information_about_cancelled_event": "我們已給您和其他與會者發送通知電子郵件。",
|
||||
"this_input_will_shown_booking_this_event": "輸入欄會在預約此活動的時候顯示",
|
||||
"meeting_url_in_conformation_email": "會議網址請參見確認電子郵件",
|
||||
"meeting_url_in_confirmation_email": "會議網址請參見確認電子郵件",
|
||||
"url_start_with_https": "URL 需要以 http:// 或 https:// 開頭",
|
||||
"number_provided": "將提供電話號碼",
|
||||
"before_event_trigger": "活動開始前",
|
||||
|
||||
@@ -207,16 +207,14 @@ html.todesktop nav a svg{
|
||||
https://github.com/tailwindlabs/tailwindcss/pull/5732
|
||||
*/
|
||||
@layer utilities {
|
||||
@layer responsive {
|
||||
/* Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ beforeAll(async () => {
|
||||
describe("next.config.js - Org Rewrite", () => {
|
||||
const orgHostRegExp = (subdomainRegExp: string) =>
|
||||
// RegExp copied from pagesAndRewritePaths.js orgHostPath. Do make the change there as well.
|
||||
new RegExp(`^(?<orgSlug>${subdomainRegExp})\\..*`);
|
||||
new RegExp(`^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`);
|
||||
|
||||
describe("Host matching based on NEXT_PUBLIC_WEBAPP_URL", () => {
|
||||
it("https://app.cal.com", () => {
|
||||
@@ -87,6 +87,11 @@ describe("next.config.js - Org Rewrite", () => {
|
||||
?.orgSlug
|
||||
).toEqual("some-other");
|
||||
});
|
||||
it("Should ignore Vercel preview URLs", () => {
|
||||
const subdomainRegExp = getSubdomainRegExp("https://cal-xxxxxxxx-cal.vercel.app");
|
||||
expect(orgHostRegExp(subdomainRegExp).exec("https://cal-xxxxxxxx-cal.vercel.app")).toMatchInlineSnapshot('null')
|
||||
expect(orgHostRegExp(subdomainRegExp).exec("cal-xxxxxxxx-cal.vercel.app")).toMatchInlineSnapshot('null')
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rewrite", () => {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"builds": [
|
||||
{
|
||||
"src": "package.json",
|
||||
"use": "https://profile-next-cold-starts.vercel.app/builder.tgz"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export async function getAppWithMetadata(app: { dirName: string } | { slug: stri
|
||||
export async function getAppRegistry() {
|
||||
const dbApps = await prisma.app.findMany({
|
||||
where: { enabled: true },
|
||||
select: { dirName: true, slug: true, categories: true, enabled: true },
|
||||
select: { dirName: true, slug: true, categories: true, enabled: true, createdAt: true },
|
||||
});
|
||||
const apps = [] as App[];
|
||||
const installCountPerApp = await getInstallCountPerApp();
|
||||
@@ -44,7 +44,7 @@ export async function getAppRegistry() {
|
||||
// Skip if app isn't installed
|
||||
/* This is now handled from the DB */
|
||||
// if (!app.installed) return apps;
|
||||
|
||||
app.createdAt = dbapp.createdAt.toISOString();
|
||||
apps.push({
|
||||
...app,
|
||||
category: app.category || "other",
|
||||
@@ -101,6 +101,7 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea
|
||||
// Skip if app isn't installed
|
||||
/* This is now handled from the DB */
|
||||
// if (!app.installed) return apps;
|
||||
app.createdAt = dbapp.createdAt.toISOString();
|
||||
let dependencyData: {
|
||||
name?: string;
|
||||
installed?: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetStaticPropsContext } from "next";
|
||||
|
||||
export const AppSetupPageMap = {
|
||||
zapier: import("../../zapier/pages/setup/_getStaticProps"),
|
||||
make: import("../../make/pages/setup/_getStaticProps"),
|
||||
};
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const AppSetupMap = {
|
||||
"exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")),
|
||||
"caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")),
|
||||
zapier: dynamic(() => import("../../zapier/pages/setup")),
|
||||
make: dynamic(() => import("../../make/pages/setup")),
|
||||
closecom: dynamic(() => import("../../closecom/pages/setup")),
|
||||
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
|
||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { appKeysSchema as gtm_zod_ts } from "./gtm/zod";
|
||||
import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod";
|
||||
import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
|
||||
import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appKeysSchema as make_zod_ts } from "./make/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";
|
||||
@@ -43,6 +44,7 @@ export const appKeysSchemas = {
|
||||
hubspot: hubspot_zod_ts,
|
||||
jitsivideo: jitsivideo_zod_ts,
|
||||
larkcalendar: larkcalendar_zod_ts,
|
||||
make: make_zod_ts,
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
|
||||
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
|
||||
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
|
||||
import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
|
||||
import make_config_json from "./make/config.json";
|
||||
import metapixel_config_json from "./metapixel/config.json";
|
||||
import mirotalk_config_json from "./mirotalk/config.json";
|
||||
import n8n_config_json from "./n8n/config.json";
|
||||
@@ -99,6 +100,7 @@ export const appStoreMetadata = {
|
||||
huddle01video: huddle01video__metadata_ts,
|
||||
jitsivideo: jitsivideo__metadata_ts,
|
||||
larkcalendar: larkcalendar__metadata_ts,
|
||||
make: make_config_json,
|
||||
metapixel: metapixel_config_json,
|
||||
mirotalk: mirotalk_config_json,
|
||||
n8n: n8n_config_json,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { appDataSchema as gtm_zod_ts } from "./gtm/zod";
|
||||
import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod";
|
||||
import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
|
||||
import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appDataSchema as make_zod_ts } from "./make/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";
|
||||
@@ -43,6 +44,7 @@ export const appDataSchemas = {
|
||||
hubspot: hubspot_zod_ts,
|
||||
jitsivideo: jitsivideo_zod_ts,
|
||||
larkcalendar: larkcalendar_zod_ts,
|
||||
make: make_zod_ts,
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const apiHandlers = {
|
||||
huddle01video: import("./huddle01video/api"),
|
||||
jitsivideo: import("./jitsivideo/api"),
|
||||
larkcalendar: import("./larkcalendar/api"),
|
||||
make: import("./make/api"),
|
||||
metapixel: import("./metapixel/api"),
|
||||
mirotalk: import("./mirotalk/api"),
|
||||
n8n: import("./n8n/api"),
|
||||
|
||||
@@ -97,10 +97,9 @@ export default class GoogleCalendarService implements Calendar {
|
||||
responseStatus: "accepted",
|
||||
})) || [];
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const [mainHostDestinationCalendar] =
|
||||
calEventRaw?.destinationCalendar && calEventRaw?.destinationCalendar.length > 0
|
||||
? calEventRaw.destinationCalendar
|
||||
: [];
|
||||
const selectedHostDestinationCalendar = calEventRaw.destinationCalendar?.find(
|
||||
(cal) => cal.credentialId === credentialId
|
||||
);
|
||||
const myGoogleAuth = await this.auth.getToken();
|
||||
const payload: calendar_v3.Schema$Event = {
|
||||
summary: calEventRaw.title,
|
||||
@@ -119,8 +118,8 @@ export default class GoogleCalendarService implements Calendar {
|
||||
id: String(calEventRaw.organizer.id),
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
email: mainHostDestinationCalendar?.externalId
|
||||
? mainHostDestinationCalendar.externalId
|
||||
email: selectedHostDestinationCalendar?.externalId
|
||||
? selectedHostDestinationCalendar.externalId
|
||||
: calEventRaw.organizer.email,
|
||||
},
|
||||
...eventAttendees,
|
||||
@@ -144,14 +143,14 @@ export default class GoogleCalendarService implements Calendar {
|
||||
});
|
||||
// Find in calEventRaw.destinationCalendar the one with the same credentialId
|
||||
|
||||
const selectedCalendar = calEventRaw.destinationCalendar?.find(
|
||||
(cal) => cal.credentialId === credentialId
|
||||
)?.externalId;
|
||||
const selectedCalendar =
|
||||
calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId ||
|
||||
"primary";
|
||||
|
||||
calendar.events.insert(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
calendarId: selectedCalendar || "primary",
|
||||
calendarId: selectedCalendar,
|
||||
requestBody: payload,
|
||||
conferenceDataVersion: 1,
|
||||
sendUpdates: "none",
|
||||
|
||||
@@ -26,6 +26,7 @@ const appStore = {
|
||||
webexvideo: () => import("./webex"),
|
||||
giphy: () => import("./giphy"),
|
||||
zapier: () => import("./zapier"),
|
||||
make: () => import("./make"),
|
||||
exchange2013calendar: () => import("./exchange2013calendar"),
|
||||
exchange2016calendar: () => import("./exchange2016calendar"),
|
||||
exchangecalendar: () => import("./exchangecalendar"),
|
||||
|
||||
@@ -403,7 +403,7 @@ export function getSuccessPageLocationMessage(
|
||||
locationToDisplay == t("web_conference");
|
||||
} else if (isConfirmed) {
|
||||
locationToDisplay =
|
||||
getHumanReadableLocationValue(location, t) + ": " + t("meeting_url_in_conformation_email");
|
||||
getHumanReadableLocationValue(location, t) + ": " + t("meeting_url_in_confirmation_email");
|
||||
} else {
|
||||
locationToDisplay = t("web_conferencing_details_to_follow");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
items:
|
||||
- 1.jpeg
|
||||
- 2.jpeg
|
||||
- 3.jpeg
|
||||
- 4.jpeg
|
||||
- 5.jpeg
|
||||
---
|
||||
|
||||
Workflow automation for everyone. Use the Cal.com app in Make to automate your workflows when a booking is created, rescheduled, cancelled or when a meeting has ended. You can also get all your booking with the 'List Bookings' module.<br /><br />**After Installation:** Have you lost your API key? You can always generate a new key on the <a href="/apps/make/setup">**<ins>Make Setup Page</ins>**</a>
|
||||
@@ -0,0 +1,22 @@
|
||||
# Setting up Make Integration
|
||||
|
||||
|
||||
1. Install the app from the Cal app store and generate an API key. Copy the API key.
|
||||
2. Go to `/admin/apps/automation` in Cal and set the `invite_link` for Make to `https://www.make.com/en/hq/app-invitation/6cb2772b61966508dd8f414ba3b44510` to use the app.
|
||||
3. Create a [Make account](https://www.make.com/en/login), if you don't have one.
|
||||
4. Go to `Scenarios` in the sidebar and click on **Create a new scenario**.
|
||||
5. Search for `Cal.com` in the apps list and select from the list of triggers - Booking Created, Booking Deleted, Booking Rescheduled, Meeting Ended
|
||||
6. To create a **connection** you will need your Cal deployment url and the app API Key generated above. You only need to create a **connection** once, all webhooks can use that connection.
|
||||
7. Setup the webhook for the desired event in Make.
|
||||
8. To delete a webhook, go to `Webhooks` in the left sidebar in Make, pick the webhook you want to delete and click **delete**.
|
||||
|
||||
## Localhost or Self-hosting
|
||||
|
||||
Localhost urls can not be used as the base URL for api endpoints
|
||||
|
||||
Possible solution: using [https://ngrok.com/](https://ngrok.com/)
|
||||
|
||||
1. Create Account
|
||||
2. [Download](https://ngrok.com/download) ngrok and start a tunnel to your running localhost
|
||||
- Use forwarding url as your baseUrl for the URL endpoints
|
||||
3. Use the ngrok url as your Cal deployment url when creating the **Connection** in Make.
|
||||
@@ -0,0 +1,20 @@
|
||||
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",
|
||||
redirect: {
|
||||
newTab: true,
|
||||
url: "/apps/make/setup",
|
||||
},
|
||||
createCredential: ({ appType, user, slug, teamId }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as listBookings } from "./subscriptions/listBookings";
|
||||
export { default as deleteSubscription } from "./subscriptions/deleteSubscription";
|
||||
export { default as addSubscription } from "./subscriptions/addSubscription";
|
||||
export { default as me } from "./subscriptions/me";
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
|
||||
import { addSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.query.apiKey as string;
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ message: "No API key provided" });
|
||||
}
|
||||
|
||||
const validKey = await findValidApiKey(apiKey, "make");
|
||||
|
||||
if (!validKey) {
|
||||
return res.status(401).json({ message: "API key not valid" });
|
||||
}
|
||||
|
||||
const { subscriberUrl, triggerEvent } = req.body;
|
||||
|
||||
const createAppSubscription = await addSubscription({
|
||||
appApiKey: validKey,
|
||||
triggerEvent: triggerEvent,
|
||||
subscriberUrl: subscriberUrl,
|
||||
appId: "make",
|
||||
});
|
||||
|
||||
if (!createAppSubscription) {
|
||||
return res.status(500).json({ message: "Could not create subscription." });
|
||||
}
|
||||
|
||||
res.status(200).json(createAppSubscription);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
POST: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
|
||||
import { deleteSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
const querySchema = z.object({
|
||||
apiKey: z.string(),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { apiKey, id } = querySchema.parse(req.query);
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ message: "No API key provided" });
|
||||
}
|
||||
|
||||
const validKey = await findValidApiKey(apiKey, "make");
|
||||
|
||||
if (!validKey) {
|
||||
return res.status(401).json({ message: "API key not valid" });
|
||||
}
|
||||
|
||||
const deleteEventSubscription = await deleteSubscription({
|
||||
appApiKey: validKey,
|
||||
webhookId: id,
|
||||
appId: "make",
|
||||
});
|
||||
|
||||
if (!deleteEventSubscription) {
|
||||
return res.status(500).json({ message: "Could not delete subscription." });
|
||||
}
|
||||
res.status(204).json({ message: "Subscription is deleted." });
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
DELETE: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
|
||||
import { listBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.query.apiKey as string;
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ message: "No API key provided" });
|
||||
}
|
||||
|
||||
const validKey = await findValidApiKey(apiKey, "make");
|
||||
|
||||
if (!validKey) {
|
||||
return res.status(401).json({ message: "API key not valid" });
|
||||
}
|
||||
const bookings = await listBookings(validKey);
|
||||
|
||||
if (!bookings) {
|
||||
return res.status(500).json({ message: "Unable to get bookings." });
|
||||
}
|
||||
if (bookings.length === 0) {
|
||||
const requested = validKey.teamId ? "teamId: " + validKey.teamId : "userId: " + validKey.userId;
|
||||
return res.status(404).json({
|
||||
message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``,
|
||||
});
|
||||
}
|
||||
res.status(201).json(bookings);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.query.apiKey as string;
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ message: "No API key provided" });
|
||||
}
|
||||
|
||||
const validKey = await findValidApiKey(apiKey, "make");
|
||||
|
||||
if (!validKey) {
|
||||
return res.status(401).json({ message: "API key not valid" });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
try {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: validKey.userId,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
res.status(201).json(user);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ message: "Unable to get User." });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Make",
|
||||
"slug": "make",
|
||||
"type": "make_automation",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://cal.com/apps/make",
|
||||
"variant": "automation",
|
||||
"categories": ["automation"],
|
||||
"publisher": "aa2dee2",
|
||||
"email": "support@cal.com",
|
||||
"description": "Automate workflows",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
"imageSrc": "icon.svg",
|
||||
"dirName": "make"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as api from "./api";
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/make",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"description": "Workflow automation for everyone. Use the Cal.com Make app to trigger your workflows when a booking is created, rescheduled, or cancelled, or after a meeting ends.",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*",
|
||||
"@types/node-schedule": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export interface IMakeSetupProps {
|
||||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let inviteLink = "";
|
||||
const appKeys = await getAppKeysFromSlug("make");
|
||||
if (typeof appKeys.invite_link === "string") inviteLink = appKeys.invite_link;
|
||||
|
||||
return {
|
||||
props: {
|
||||
inviteLink,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { InferGetStaticPropsType } from "next";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Tooltip, showToast } from "@calcom/ui";
|
||||
import { Clipboard } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { getStaticProps } from "./_getStaticProps";
|
||||
|
||||
const MAKE = "make";
|
||||
|
||||
export default function MakeSetup({ inviteLink }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const [newApiKeys, setNewApiKeys] = useState<Record<string, string>>({});
|
||||
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const integrations = trpc.viewer.integrations.useQuery({ variant: "automation" });
|
||||
const oldApiKey = trpc.viewer.apiKeys.findKeyOfType.useQuery({ appId: MAKE });
|
||||
const teamsList = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const teams = teamsList.data?.map((team) => ({ id: team.id, name: team.name }));
|
||||
const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.apiKeys.findKeyOfType.invalidate();
|
||||
},
|
||||
});
|
||||
const makeCredentials: { userCredentialIds: number[] } | undefined = integrations.data?.items.find(
|
||||
(item: { type: string }) => item.type === "make_automation"
|
||||
);
|
||||
const [credentialId] = makeCredentials?.userCredentialIds || [false];
|
||||
const showContent = integrations.data && integrations.isSuccess && credentialId;
|
||||
|
||||
async function createApiKey(teamId?: number) {
|
||||
const event = { note: "Make", expiresAt: null, appId: MAKE, teamId };
|
||||
const apiKey = await utils.client.viewer.apiKeys.create.mutate(event);
|
||||
|
||||
if (oldApiKey.data) {
|
||||
const oldKey = teamId
|
||||
? oldApiKey.data.find((key) => key.teamId === teamId)
|
||||
: oldApiKey.data.find((key) => !key.teamId);
|
||||
|
||||
if (oldKey) {
|
||||
deleteApiKey.mutate({
|
||||
id: oldKey.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function generateApiKey(teamId?: number) {
|
||||
const apiKey = await createApiKey(teamId);
|
||||
setNewApiKeys({ ...newApiKeys, [teamId || ""]: apiKey });
|
||||
}
|
||||
|
||||
if (integrations.isLoading) {
|
||||
return <div className="bg-emphasis absolute z-50 flex h-screen w-full items-center" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-emphasis flex h-screen">
|
||||
{showContent ? (
|
||||
<div className="bg-default m-auto max-w-[43em] overflow-auto rounded pb-10 md:p-10">
|
||||
<div className="md:flex md:flex-row">
|
||||
<div className="invisible md:visible">
|
||||
<img className="h-11" src="/api/app-store/make/icon.svg" alt="Make Logo" />
|
||||
</div>
|
||||
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
|
||||
<div className="text-default">{t("setting_up_make")}</div>
|
||||
|
||||
<>
|
||||
<div className="mt-1 text-xl">{t("generate_api_key")}:</div>
|
||||
{!teams ? (
|
||||
<Button color="secondary" onClick={() => createApiKey()} className="mb-4 mt-2">
|
||||
{t("generate_api_key")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-8 text-sm font-semibold">Your event types:</div>
|
||||
{!newApiKeys[""] ? (
|
||||
<Button color="secondary" onClick={() => generateApiKey()} className="mb-4 mt-2">
|
||||
{t("generate_api_key")}
|
||||
</Button>
|
||||
) : (
|
||||
<CopyApiKey apiKey={newApiKeys[""]} />
|
||||
)}
|
||||
{teams.map((team) => {
|
||||
return (
|
||||
<div key={team.name}>
|
||||
<div className="mt-2 text-sm font-semibold">{team.name}:</div>
|
||||
{!newApiKeys[team.id] ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => generateApiKey(team.id)}
|
||||
className="mb-4 mt-2">
|
||||
{t("generate_api_key")}
|
||||
</Button>
|
||||
) : (
|
||||
<CopyApiKey apiKey={newApiKeys[team.id]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
<ol className="mb-5 ml-5 mt-5 list-decimal ltr:mr-5 rtl:ml-5">
|
||||
<Trans i18nKey="invite_link_make">
|
||||
<li>
|
||||
Go to
|
||||
<a href={inviteLink} className="ml-1 mr-1 text-orange-600 underline">
|
||||
Make Invite Link
|
||||
</a>
|
||||
and install the Cal.com app.
|
||||
</li>
|
||||
</Trans>
|
||||
|
||||
<Trans i18nKey="make_setup_instructions">
|
||||
<li>Log into your Make account and create a new Scenario.</li>
|
||||
<li>Select Cal.com as your Trigger app. Also choose a Trigger event.</li>
|
||||
<li>Choose your account and then enter your Unique API Key.</li>
|
||||
<li>Test your Trigger.</li>
|
||||
<li>You're set!</li>
|
||||
</Trans>
|
||||
</ol>
|
||||
<Link href="/apps/installed/automation?hl=make" passHref={true} legacyBehavior>
|
||||
<Button color="secondary">{t("done")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 mt-5">
|
||||
<div>{t("install_make_app")}</div>
|
||||
<div className="mt-3">
|
||||
<Link href="/apps/make" passHref={true} legacyBehavior>
|
||||
<Button>{t("go_to_app_store")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CopyApiKey = ({ apiKey }: { apiKey: string }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<div className="my-2 mt-3 flex-wrap sm:flex sm:flex-nowrap">
|
||||
<code className="bg-subtle h-full w-full whitespace-pre-wrap rounded-md py-[6px] pl-2 pr-2 sm:rounded-r-none sm:pr-5">
|
||||
{apiKey}
|
||||
</code>
|
||||
<Tooltip side="top" content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
showToast(t("api_key_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="mt-4 text-base sm:mt-0 sm:rounded-l-none">
|
||||
<Clipboard className="h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-subtle mb-5 mt-2 text-sm">{t("copy_somewhere_safe")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<g id="Make-App-Icon-Circle" transform="translate(3757 -1767)">
|
||||
<circle id="Ellipse_10" data-name="Ellipse 10" cx="256" cy="256" r="256" transform="translate(-3757 1767)" fill="#6d00cc"/>
|
||||
<path id="Path_141560" data-name="Path 141560" d="M244.78,14.544a7.187,7.187,0,0,0-7.186,7.192V213.927a7.19,7.19,0,0,0,7.186,7.192h52.063a7.187,7.187,0,0,0,7.186-7.192V21.736a7.183,7.183,0,0,0-7.186-7.192ZM92.066,17.083,5.77,188.795a7.191,7.191,0,0,0,3.192,9.654l46.514,23.379a7.184,7.184,0,0,0,9.654-3.2l86.3-171.711a7.184,7.184,0,0,0-3.2-9.654L101.719,13.886a7.2,7.2,0,0,0-9.654,3.2m72.592.614L127.731,204.876a7.189,7.189,0,0,0,5.632,8.442l51.028,10.306a7.2,7.2,0,0,0,8.481-5.665L229.8,30.786a7.19,7.19,0,0,0-5.637-8.442L173.133,12.038a7.391,7.391,0,0,0-1.427-.144,7.194,7.194,0,0,0-7.048,5.8" transform="translate(-3676.356 1905.425)" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 952 B |
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const appDataSchema = z.object({});
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
invite_link: z.string().min(1),
|
||||
});
|
||||
@@ -4,7 +4,6 @@ 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) {
|
||||
@@ -78,12 +77,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ class Paypal {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -41,25 +41,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
name="Plausible URL"
|
||||
defaultValue="https://plausible.io/js/script.js"
|
||||
placeholder="https://plausible.io/js/script.js"
|
||||
value={plausibleUrl}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setAppData("PLAUSIBLE_URL", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="Tracked Domain"
|
||||
placeholder="yourdomain.com"
|
||||
value={trackingId}
|
||||
onChange={(e) => {
|
||||
setAppData("trackingId", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,13 @@ import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||
import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
|
||||
|
||||
import {
|
||||
addForm,
|
||||
saveCurrentForm,
|
||||
verifySelectOptions,
|
||||
addOneFieldAndDescriptionAndSaveForm,
|
||||
} from "./testUtils";
|
||||
|
||||
function todo(title: string) {
|
||||
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
|
||||
test.skip(title, () => {});
|
||||
@@ -407,22 +414,6 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await expect(page.locator("text=Custom Page Result")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||
await page.goto("/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
// Choose to create the Form for the user(which is the first option) and not the team
|
||||
await page.click('[data-testid="option-0"]');
|
||||
await page.fill("input[name]", name);
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
if (!formId) {
|
||||
throw new Error("Form ID couldn't be determined from url");
|
||||
}
|
||||
return formId;
|
||||
}
|
||||
|
||||
async function addAllTypesOfFieldsAndSaveForm(
|
||||
formId: string,
|
||||
page: Page,
|
||||
@@ -480,46 +471,6 @@ async function addAllTypesOfFieldsAndSaveForm(
|
||||
};
|
||||
}
|
||||
|
||||
export async function addOneFieldAndDescriptionAndSaveForm(
|
||||
formId: string,
|
||||
page: Page,
|
||||
form: { description?: string; field?: { typeIndex: number; label: string } }
|
||||
) {
|
||||
await page.goto(`apps/routing-forms/form-edit/${formId}`);
|
||||
await page.click('[data-testid="add-field"]');
|
||||
if (form.description) {
|
||||
await page.fill('[data-testid="description"]', form.description);
|
||||
}
|
||||
|
||||
// Verify all Options of SelectBox
|
||||
const { optionsInUi: types } = await verifySelectOptions(
|
||||
{ selector: ".data-testid-field-type", nth: 0 },
|
||||
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
|
||||
page
|
||||
);
|
||||
|
||||
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
|
||||
|
||||
if (form.field) {
|
||||
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator(".data-testid-field-type")
|
||||
.click();
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.nth(form.field.typeIndex)
|
||||
.click();
|
||||
}
|
||||
await saveCurrentForm(page);
|
||||
return {
|
||||
types,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectOption({
|
||||
page,
|
||||
selector,
|
||||
@@ -551,26 +502,6 @@ async function verifyFieldOptionsInRule(options: string[], page: Page) {
|
||||
);
|
||||
}
|
||||
|
||||
async function verifySelectOptions(
|
||||
selector: { selector: string; nth: number },
|
||||
expectedOptions: string[],
|
||||
page: Page
|
||||
) {
|
||||
await page.locator(selector.selector).nth(selector.nth).click();
|
||||
const selectOptions = await page
|
||||
.locator(selector.selector)
|
||||
.nth(selector.nth)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.allInnerTexts();
|
||||
|
||||
const sortedSelectOptions = [...selectOptions].sort();
|
||||
const sortedExpectedOptions = [...expectedOptions].sort();
|
||||
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
||||
return {
|
||||
optionsInUi: selectOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
||||
await selectOption({
|
||||
selector: {
|
||||
@@ -581,8 +512,3 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveCurrentForm(page: Page) {
|
||||
await page.click('[data-testid="update-form"]');
|
||||
await page.waitForSelector(".data-testid-toast-success");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||
await page.goto("/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
// Choose to create the Form for the user(which is the first option) and not the team
|
||||
await page.click('[data-testid="option-0"]');
|
||||
await page.fill("input[name]", name);
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
if (!formId) {
|
||||
throw new Error("Form ID couldn't be determined from url");
|
||||
}
|
||||
return formId;
|
||||
}
|
||||
|
||||
export async function addOneFieldAndDescriptionAndSaveForm(
|
||||
formId: string,
|
||||
page: Page,
|
||||
form: { description?: string; field?: { typeIndex: number; label: string } }
|
||||
) {
|
||||
await page.goto(`apps/routing-forms/form-edit/${formId}`);
|
||||
await page.click('[data-testid="add-field"]');
|
||||
if (form.description) {
|
||||
await page.fill('[data-testid="description"]', form.description);
|
||||
}
|
||||
|
||||
// Verify all Options of SelectBox
|
||||
const { optionsInUi: types } = await verifySelectOptions(
|
||||
{ selector: ".data-testid-field-type", nth: 0 },
|
||||
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
|
||||
page
|
||||
);
|
||||
|
||||
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
|
||||
|
||||
if (form.field) {
|
||||
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator(".data-testid-field-type")
|
||||
.click();
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.nth(form.field.typeIndex)
|
||||
.click();
|
||||
}
|
||||
await saveCurrentForm(page);
|
||||
return {
|
||||
types,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveCurrentForm(page: Page) {
|
||||
await page.click('[data-testid="update-form"]');
|
||||
await page.waitForSelector(".data-testid-toast-success");
|
||||
}
|
||||
|
||||
export async function verifySelectOptions(
|
||||
selector: { selector: string; nth: number },
|
||||
expectedOptions: string[],
|
||||
page: Page
|
||||
) {
|
||||
await page.locator(selector.selector).nth(selector.nth).click();
|
||||
const selectOptions = await page
|
||||
.locator(selector.selector)
|
||||
.nth(selector.nth)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.allInnerTexts();
|
||||
|
||||
const sortedSelectOptions = [...selectOptions].sort();
|
||||
const sortedExpectedOptions = [...expectedOptions].sort();
|
||||
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
||||
return {
|
||||
optionsInUi: selectOptions,
|
||||
};
|
||||
}
|
||||
@@ -12,32 +12,50 @@ export async function onFormSubmission(
|
||||
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
||||
response: Response
|
||||
) {
|
||||
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
|
||||
const fieldResponsesByName: Record<
|
||||
string,
|
||||
{
|
||||
value: Response[keyof Response]["value"];
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||
// Use the label lowercased as the key to identify a field.
|
||||
const key =
|
||||
form.fields.find((f) => f.id === fieldId)?.identifier ||
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
||||
fieldResponsesByName[key] = fieldResponse.value;
|
||||
fieldResponsesByName[key] = {
|
||||
value: fieldResponse.value,
|
||||
};
|
||||
}
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: form.user.id,
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
// When team routing forms are implemented, we need to make sure to add the teamId here
|
||||
teamId: null,
|
||||
...getWebhookTargetEntity(form),
|
||||
};
|
||||
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
|
||||
const promises = webhooks.map((webhook) => {
|
||||
sendGenericWebhookPayload(
|
||||
webhook.secret,
|
||||
"FORM_SUBMITTED",
|
||||
new Date().toISOString(),
|
||||
sendGenericWebhookPayload({
|
||||
secretKey: webhook.secret,
|
||||
triggerEvent: "FORM_SUBMITTED",
|
||||
createdAt: new Date().toISOString(),
|
||||
webhook,
|
||||
fieldResponsesByName
|
||||
).catch((e) => {
|
||||
data: {
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
teamId: form.teamId,
|
||||
responses: fieldResponsesByName,
|
||||
},
|
||||
rootData: {
|
||||
// Send responses unwrapped at root level for backwards compatibility
|
||||
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
|
||||
acc[key] = value.value;
|
||||
return acc;
|
||||
}, {} as Record<string, Response[keyof Response]["value"]>),
|
||||
},
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing routing form webhook`, webhook, e);
|
||||
});
|
||||
});
|
||||
@@ -66,3 +84,10 @@ export const sendResponseEmail = async (
|
||||
logger.error("Error sending response email", e);
|
||||
}
|
||||
};
|
||||
|
||||
function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: number } }) {
|
||||
// If it's a team form, the target must be team webhook
|
||||
// If it's a user form, the target must be user webhook
|
||||
const isTeamForm = form.teamId;
|
||||
return { userId: isTeamForm ? null : form.user.id, teamId: isTeamForm ? form.teamId : null };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from "@playwright/test";
|
||||
import {
|
||||
addForm as addRoutingForm,
|
||||
addOneFieldAndDescriptionAndSaveForm,
|
||||
} from "@calcom/app-store/routing-forms/playwright/tests/basic.e2e";
|
||||
} from "@calcom/app-store/routing-forms/playwright/tests/testUtils";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||
|
||||