Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0910f65b87 | ||
|
|
343f8ee303 | ||
|
|
de479bb2da | ||
|
|
5dc3065a47 | ||
|
|
36d315343c | ||
|
|
2171a320f5 | ||
|
|
7ee035c3a3 | ||
|
|
1de05ebf3d | ||
|
|
53d7e2ec3f | ||
|
|
f65c7e413f | ||
|
|
b762f60214 | ||
|
|
2498785c49 | ||
|
|
9a6683e01d | ||
|
|
2853288f49 | ||
|
|
d04226ab9a | ||
|
|
cb7ddc455a | ||
|
|
73aa1e8a22 | ||
|
|
a3b5263b76 | ||
|
|
af2c6c0844 | ||
|
|
113195224a | ||
|
|
828092c1d0 | ||
|
|
4b060fc2cd | ||
|
|
16c5b070b6 | ||
|
|
48dde246e9 | ||
|
|
556b382f75 | ||
|
|
85237c4985 | ||
|
|
404bc0e4d6 | ||
|
|
00553e897b | ||
|
|
ed2ce005c9 | ||
|
|
058844b6b8 | ||
|
|
77fa3702db | ||
|
|
9eb2ae470c | ||
|
|
3de7b73f5f | ||
|
|
8e7785ca64 | ||
|
|
608b891536 | ||
|
|
4f26ca1a7b | ||
|
|
c55b36f235 | ||
|
|
bdd3b132d4 | ||
|
|
9a4c20cca4 | ||
|
|
2144fcb23e | ||
|
|
f2c39fc786 | ||
|
|
e83e0a770c | ||
|
|
c818ef3188 | ||
|
|
176564f4ed | ||
|
|
27d59b6413 | ||
|
|
57a65401e7 | ||
|
|
28acbe549a | ||
|
|
3534e3c224 | ||
|
|
e48a6c3dcf | ||
|
|
7d09ccb0d7 | ||
|
|
9029c5b4c4 | ||
|
|
a4c1df3658 | ||
|
|
0b96ef5476 | ||
|
|
ca9e0f5b72 | ||
|
|
a9418b06d6 | ||
|
|
833a326c5b | ||
|
|
b7e2a62e26 | ||
|
|
f687056d60 | ||
|
|
0a39f53a4b | ||
|
|
ea0a64624c | ||
|
|
6a8726f5f8 | ||
|
|
eb97e1660b | ||
|
|
371a0f7245 | ||
|
|
270d4f6e82 | ||
|
|
48e7b616b8 | ||
|
|
8b80ea99c2 | ||
|
|
150c6e0f50 | ||
|
|
4037e87fbd | ||
|
|
a804a29516 | ||
|
|
87b514b91b | ||
|
|
693ca04626 | ||
|
|
67f83c7e7d | ||
|
|
ecfecd832d | ||
|
|
e55302cbc5 | ||
|
|
9514a68039 | ||
|
|
4e3d159401 | ||
|
|
a519941b81 | ||
|
|
f62c58532d | ||
|
|
0981c4cb23 | ||
|
|
9d06f6dd0e | ||
|
|
00df493623 | ||
|
|
2cf2b11a7b | ||
|
|
5c3fd0993e | ||
|
|
811e655720 | ||
|
|
defaafc9be | ||
|
|
e0c6feff1f | ||
|
|
d17dcbd319 | ||
|
|
90d52663ac | ||
|
|
ada74467bc | ||
|
|
7db758c2d5 | ||
|
|
2867d53442 | ||
|
|
31067fcd45 | ||
|
|
8c2ce972cc | ||
|
|
d3ab11e38e | ||
|
|
3d596fcb1a | ||
|
|
820b45a912 | ||
|
|
b945849736 | ||
|
|
6b2c59b733 | ||
|
|
b7f85d785a | ||
|
|
08e4e816c6 | ||
|
|
20430022e1 | ||
|
|
e4c71e5d2c | ||
|
|
28eda407be | ||
|
|
9dab171520 | ||
|
|
d5f359291d | ||
|
|
3c1d0a9bf1 | ||
|
|
3057edbd87 | ||
|
|
a2f859b55a | ||
|
|
b2f4eaee63 | ||
|
|
b66c3f3452 | ||
|
|
5ab0b85e52 | ||
|
|
4135c1038e | ||
|
|
fc5190a33b | ||
|
|
f29328e6a2 | ||
|
|
621f7639ff | ||
|
|
63f2f6edba | ||
|
|
199f41fb1a | ||
|
|
3ea9faa414 | ||
|
|
518ec4f605 | ||
|
|
4db0da5da4 | ||
|
|
115ded8429 | ||
|
|
71485d1b57 | ||
|
|
87071c4f19 | ||
|
|
412f15814e | ||
|
|
a57085cf64 | ||
|
|
65d1a34e39 | ||
|
|
e4abc85382 | ||
|
|
eb1eb722f2 | ||
|
|
033bc45dd1 | ||
|
|
affe85d02c | ||
|
|
d6f9a833bd | ||
|
|
e4281a852a | ||
|
|
e4b2464548 | ||
|
|
8c99a2a7af |
@@ -25,6 +25,7 @@ CALCOM_LICENSE_KEY=
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
INSIGHTS_DATABASE_URL=
|
||||
|
||||
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
|
||||
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
|
||||
@@ -106,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||
NEXT_PUBLIC_FRESHCHAT_TOKEN=
|
||||
NEXT_PUBLIC_FRESHCHAT_HOST=
|
||||
|
||||
# Google OAuth credentials
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||
GOOGLE_LOGIN_ENABLED=false
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS=
|
||||
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
@@ -236,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL=
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
||||
|
||||
# - CALCOM QA ACCOUNT
|
||||
# Used for E2E tests on Cal.com that require 3rd party integrations
|
||||
E2E_TEST_CALCOM_QA_EMAIL="qa@example.com"
|
||||
# Replace with your own password
|
||||
E2E_TEST_CALCOM_QA_PASSWORD="password"
|
||||
|
||||
# - APP CREDENTIAL SYNC ***********************************************************************************
|
||||
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
|
||||
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
|
||||
@@ -263,3 +283,9 @@ E2E_TEST_OIDC_USER_EMAIL=
|
||||
E2E_TEST_OIDC_USER_PASSWORD=
|
||||
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# provide a value between 0 and 100 to ensure the percentage of traffic
|
||||
# redirected from the legacy to the future pages
|
||||
AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=1
|
||||
|
||||
@@ -13,4 +13,4 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||
import prisma, { customPrisma } from "@calcom/prisma";
|
||||
import { customPrisma } from "@calcom/prisma";
|
||||
|
||||
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
|
||||
|
||||
@@ -12,41 +12,32 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
|
||||
} = req;
|
||||
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
|
||||
if (!key) {
|
||||
req.prisma = prisma;
|
||||
req.prisma = customPrisma();
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
|
||||
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
|
||||
.then((res) => res.json())
|
||||
.then((res) => res.databaseUrl);
|
||||
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
|
||||
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
|
||||
.then((res) => res.json())
|
||||
.then((res) => res.databaseUrl);
|
||||
|
||||
if (!databaseUrl) {
|
||||
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
|
||||
return;
|
||||
}
|
||||
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
|
||||
/* @note:
|
||||
if (!databaseUrl) {
|
||||
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
|
||||
return;
|
||||
}
|
||||
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
|
||||
/* @note:
|
||||
In order to skip verifyApiKey for customPrisma requests,
|
||||
we pass isAdmin true, and userId 0, if we detect them later,
|
||||
we skip verifyApiKey logic and pass onto next middleware instead.
|
||||
*/
|
||||
req.isAdmin = true;
|
||||
req.isCustomPrisma = true;
|
||||
// We don't need the key from here and on. Prevents unrecognized key errors.
|
||||
delete req.query.key;
|
||||
await next();
|
||||
await req.prisma.$disconnect();
|
||||
// @ts-expect-error testing
|
||||
delete req.prisma;
|
||||
} catch (err) {
|
||||
if (req.prisma) {
|
||||
await req.prisma.$disconnect();
|
||||
// @ts-expect-error testing
|
||||
delete req.prisma;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
req.isAdmin = true;
|
||||
req.isCustomPrisma = true;
|
||||
// We don't need the key from here and on. Prevents unrecognized key errors.
|
||||
delete req.query.key;
|
||||
await next();
|
||||
await req.prisma.$disconnect();
|
||||
// @ts-expect-error testing
|
||||
delete req.prisma;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ const schemaAvailabilityCreateParams = z
|
||||
startTime: z.date().or(z.string()),
|
||||
endTime: z.date().or(z.string()),
|
||||
days: z.array(z.number()).optional(),
|
||||
date: z.date().or(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -34,6 +35,7 @@ const schemaAvailabilityEditParams = z
|
||||
startTime: z.date().or(z.string()).optional(),
|
||||
endTime: z.date().or(z.string()).optional(),
|
||||
days: z.array(z.number()).optional(),
|
||||
date: z.date().or(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -21,7 +21,16 @@ export const schemaSchedulePublic = z
|
||||
.merge(
|
||||
z.object({
|
||||
availability: z
|
||||
.array(Availability.pick({ id: true, eventTypeId: true, days: true, startTime: true, endTime: true }))
|
||||
.array(
|
||||
Availability.pick({
|
||||
id: true,
|
||||
eventTypeId: true,
|
||||
date: true,
|
||||
days: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
})
|
||||
)
|
||||
.transform((v) =>
|
||||
v.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -205,8 +205,8 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { userId, isAdmin } = req;
|
||||
if (isAdmin) req.userId = req.body.userId || userId;
|
||||
const booking = await handleNewBooking(req);
|
||||
return booking;
|
||||
|
||||
return await handleNewBooking(req);
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) {
|
||||
statusCode: 401,
|
||||
message: "ADMIN required for `userId`",
|
||||
});
|
||||
if (!isAdmin && body.teamId)
|
||||
throw new HttpError({
|
||||
statusCode: 401,
|
||||
message: "ADMIN required for `teamId`",
|
||||
});
|
||||
/* Admin users are required to pass in a userId or teamId */
|
||||
if (isAdmin && (!body.userId || !body.teamId))
|
||||
if (isAdmin && !body.userId && !body.teamId)
|
||||
throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) {
|
||||
res.status(201).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
|
||||
res.status(200).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
@@ -41,10 +42,18 @@ export async function deleteHandler(req: NextApiRequest) {
|
||||
// Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user
|
||||
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: query.userId } });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: query.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
await deleteUser(user);
|
||||
|
||||
return { message: `User with id: ${user.id} deleted successfully` };
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -148,7 +149,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
||||
expect(res._getStatusCode()).toBe(500);
|
||||
expect(JSON.parse(res._getData())).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "No available users found.",
|
||||
message: ErrorCode.NoAvailableUsersFound,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"functions": {
|
||||
"pages/api/slots/*.ts": {
|
||||
"memory": 512
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
"@calcom/config": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
@@ -49,7 +49,7 @@
|
||||
"storybook-addon-designs": "^6.3.1",
|
||||
"storybook-addon-next": "^1.6.9",
|
||||
"storybook-react-i18next": "^1.1.2",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.1.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { getBucket } from "abTest/utils";
|
||||
import type { NextMiddleware, NextRequest } from "next/server";
|
||||
import { NextResponse, URLPattern } from "next/server";
|
||||
import z from "zod";
|
||||
|
||||
const ROUTES: [URLPattern, boolean][] = [
|
||||
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
|
||||
].map(([pathname, enabled]) => [
|
||||
new URLPattern({
|
||||
pathname,
|
||||
}),
|
||||
enabled,
|
||||
]);
|
||||
|
||||
const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
|
||||
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";
|
||||
|
||||
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
|
||||
|
||||
export const abTestMiddlewareFactory =
|
||||
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
|
||||
async (req: NextRequest) => {
|
||||
const response = await next(req);
|
||||
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);
|
||||
|
||||
const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null;
|
||||
|
||||
const enabled = route !== null ? route[1] || override : false;
|
||||
|
||||
if (pathname.includes("future") || !enabled) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const safeParsedBucket = override
|
||||
? { success: true as const, data: "future" as const }
|
||||
: bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value);
|
||||
|
||||
if (!safeParsedBucket.success) {
|
||||
// cookie does not exist or it has incorrect value
|
||||
const bucket = getBucket();
|
||||
|
||||
response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, {
|
||||
expires: Date.now() + 1000 * 60 * 30,
|
||||
httpOnly: true,
|
||||
}); // 30 min in ms
|
||||
|
||||
if (bucket === "legacy") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `future${pathname}/`;
|
||||
|
||||
return NextResponse.rewrite(url, response);
|
||||
}
|
||||
|
||||
if (safeParsedBucket.data === "legacy") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `future${pathname}/`;
|
||||
|
||||
return NextResponse.rewrite(url, response);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants";
|
||||
|
||||
const cryptoRandom = () => {
|
||||
return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff;
|
||||
};
|
||||
|
||||
export const getBucket = () => {
|
||||
return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy";
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { createHydrateClient } from "app/_trpc/createHydrateClient";
|
||||
import superjson from "superjson";
|
||||
|
||||
export const HydrateClient = createHydrateClient({
|
||||
transformer: superjson,
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>({});
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { type DehydratedState, Hydrate } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { DataTransformer } from "@trpc/server";
|
||||
|
||||
export function createHydrateClient(opts: { transformer?: DataTransformer }) {
|
||||
return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) {
|
||||
const { state, children } = props;
|
||||
|
||||
const transformedState: DehydratedState = useMemo(() => {
|
||||
if (opts.transformer) {
|
||||
return opts.transformer.deserialize(state);
|
||||
}
|
||||
return state;
|
||||
}, [state]);
|
||||
|
||||
return <Hydrate state={transformedState}>{children}</Hydrate>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { type DehydratedState, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HydrateClient } from "app/_trpc/HydrateClient";
|
||||
import { trpc } from "app/_trpc/client";
|
||||
import { useState } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
|
||||
import { httpLink } from "@calcom/trpc/client/links/httpLink";
|
||||
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
|
||||
import { splitLink } from "@calcom/trpc/client/links/splitLink";
|
||||
|
||||
const ENDPOINTS = [
|
||||
"admin",
|
||||
"apiKeys",
|
||||
"appRoutingForms",
|
||||
"apps",
|
||||
"auth",
|
||||
"availability",
|
||||
"appBasecamp3",
|
||||
"bookings",
|
||||
"deploymentSetup",
|
||||
"eventTypes",
|
||||
"features",
|
||||
"insights",
|
||||
"payments",
|
||||
"public",
|
||||
"saml",
|
||||
"slots",
|
||||
"teams",
|
||||
"organizations",
|
||||
"users",
|
||||
"viewer",
|
||||
"webhook",
|
||||
"workflows",
|
||||
"appsRouter",
|
||||
"googleWorkspace",
|
||||
] as const;
|
||||
export type Endpoint = (typeof ENDPOINTS)[number];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolveEndpoint = (links: any) => {
|
||||
// TODO: Update our trpc routes so they are more clear.
|
||||
// This function parses paths like the following and maps them
|
||||
// to the correct API endpoints.
|
||||
// - viewer.me - 2 segment paths like this are for logged in requests
|
||||
// - viewer.public.i18n - 3 segments paths can be public or authed
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (ctx: any) => {
|
||||
const parts = ctx.op.path.split(".");
|
||||
let endpoint;
|
||||
let path = "";
|
||||
if (parts.length == 2) {
|
||||
endpoint = parts[0] as keyof typeof links;
|
||||
path = parts[1];
|
||||
} else {
|
||||
endpoint = parts[1] as keyof typeof links;
|
||||
path = parts.splice(2, parts.length - 2).join(".");
|
||||
}
|
||||
return links[endpoint]({ ...ctx, op: { ...ctx.op, path } });
|
||||
};
|
||||
};
|
||||
|
||||
export const TrpcProvider: React.FC<{ children: React.ReactNode; dehydratedState?: DehydratedState }> = ({
|
||||
children,
|
||||
dehydratedState,
|
||||
}) => {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 5000 } },
|
||||
})
|
||||
);
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? "/api/trpc"
|
||||
: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}/api/trpc`
|
||||
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
// adds pretty logs to your console in development and logs errors in production
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
splitLink({
|
||||
// check for context property `skipBatch`
|
||||
condition: (op) => !!op.context.skipBatch,
|
||||
// when condition is true, use normal request
|
||||
true: (runtime) => {
|
||||
const links = Object.fromEntries(
|
||||
ENDPOINTS.map((endpoint) => [
|
||||
endpoint,
|
||||
httpLink({
|
||||
url: `${url}/${endpoint}`,
|
||||
})(runtime),
|
||||
])
|
||||
);
|
||||
return resolveEndpoint(links);
|
||||
},
|
||||
// when condition is false, use batch request
|
||||
false: (runtime) => {
|
||||
const links = Object.fromEntries(
|
||||
ENDPOINTS.map((endpoint) => [
|
||||
endpoint,
|
||||
httpBatchLink({
|
||||
url: `${url}/${endpoint}`,
|
||||
})(runtime),
|
||||
])
|
||||
);
|
||||
return resolveEndpoint(links);
|
||||
},
|
||||
}),
|
||||
],
|
||||
transformer: superjson,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{dehydratedState ? <HydrateClient state={dehydratedState}>{children}</HydrateClient> : children}
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { constructGenericImage } from "@calcom/lib/OgImages";
|
||||
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
|
||||
import { getFixedT } from "@calcom/lib/server/getFixedT";
|
||||
|
||||
import { preparePageMetadata } from "@lib/metadata";
|
||||
|
||||
export const _generateMetadata = async (
|
||||
getTitle: (t: TFunction<string, undefined>) => string,
|
||||
getDescription: (t: TFunction<string, undefined>) => string
|
||||
) => {
|
||||
const h = headers();
|
||||
const canonical = h.get("x-pathname") ?? "";
|
||||
const locale = h.get("x-locale") ?? "en";
|
||||
|
||||
const t = await getFixedT(locale, "common");
|
||||
|
||||
const title = getTitle(t);
|
||||
const description = getDescription(t);
|
||||
|
||||
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
|
||||
|
||||
const image =
|
||||
SEO_IMG_OGIMG +
|
||||
constructGenericImage({
|
||||
title,
|
||||
description,
|
||||
});
|
||||
|
||||
return preparePageMetadata({
|
||||
title,
|
||||
canonical,
|
||||
image,
|
||||
description,
|
||||
siteName: APP_NAME,
|
||||
metadataBase,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Typescript class based component for custom-error
|
||||
* @link https://nextjs.org/docs/advanced-features/custom-error-page
|
||||
*/
|
||||
import type { NextPage } from "next";
|
||||
import type { ErrorProps } from "next/error";
|
||||
import React from "react";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
import { ErrorPage } from "@components/error/error-page";
|
||||
|
||||
type NextError = Error & { digest?: string };
|
||||
|
||||
// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props
|
||||
export type DefaultErrorProps = {
|
||||
error: NextError;
|
||||
reset: () => void; // A function to reset the error boundary
|
||||
};
|
||||
|
||||
type AugmentedError = NextError | HttpError | null;
|
||||
|
||||
type CustomErrorProps = {
|
||||
err?: AugmentedError;
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
} & Omit<ErrorProps, "err" | "statusCode">;
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[error]"] });
|
||||
|
||||
const CustomError: NextPage<DefaultErrorProps> = (props) => {
|
||||
const { error } = props;
|
||||
let errorObject: CustomErrorProps = {
|
||||
message: error.message,
|
||||
err: error,
|
||||
};
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
const redactedError = redactError(error);
|
||||
errorObject = {
|
||||
statusCode: error.statusCode,
|
||||
title: redactedError.name,
|
||||
message: redactedError.message,
|
||||
err: {
|
||||
...redactedError,
|
||||
...error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs
|
||||
log.debug(`${error?.toString() ?? JSON.stringify(error)}`);
|
||||
log.info("errorObject: ", errorObject);
|
||||
|
||||
return (
|
||||
<ErrorPage statusCode={errorObject.statusCode} error={errorObject.err} message={errorObject.message} />
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomError;
|
||||
@@ -0,0 +1,10 @@
|
||||
import EventTypes from "@pages/event-types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("event_types_page_title"),
|
||||
(t) => t("event_types_page_subtitle")
|
||||
);
|
||||
|
||||
export default EventTypes;
|
||||
@@ -0,0 +1,22 @@
|
||||
// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder
|
||||
import { headers } from "next/headers";
|
||||
import { type ReactElement } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
type WrapperWithLayoutProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { type NextPage } from "next";
|
||||
|
||||
import CustomError, { type DefaultErrorProps } from "./error";
|
||||
|
||||
export const GlobalError: NextPage<DefaultErrorProps> = (props) => {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<CustomError {...props} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalError;
|
||||
+34
-55
@@ -1,84 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
|
||||
import { dir } from "i18next";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { prepareRootMetadata } from "@lib/metadata";
|
||||
|
||||
import "../styles/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
sizes: "32x32",
|
||||
url: "/api/logo?type=favicon-32",
|
||||
},
|
||||
{
|
||||
sizes: "16x16",
|
||||
url: "/api/logo?type=favicon-16",
|
||||
},
|
||||
],
|
||||
apple: {
|
||||
sizes: "180x180",
|
||||
url: "/api/logo?type=apple-touch-icon",
|
||||
export const generateMetadata = () =>
|
||||
prepareRootMetadata({
|
||||
twitterCreator: "@calcom",
|
||||
twitterSite: "@calcom",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
other: [
|
||||
{
|
||||
url: "/safari-pinned-tab.svg",
|
||||
rel: "mask-icon",
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
|
||||
],
|
||||
other: {
|
||||
"msapplication-TileColor": "#000000",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const getInitialProps = async (
|
||||
url: string,
|
||||
headers: ReturnType<typeof nextHeaders>,
|
||||
cookies: ReturnType<typeof nextCookies>
|
||||
) => {
|
||||
const getInitialProps = async (url: string) => {
|
||||
const { pathname, searchParams } = new URL(url);
|
||||
|
||||
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
|
||||
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
||||
|
||||
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
|
||||
const newLocale = await getLocale({ headers, cookies });
|
||||
let direction = "ltr";
|
||||
|
||||
try {
|
||||
const intlLocale = new Intl.Locale(newLocale);
|
||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||
direction = intlLocale.textInfo?.direction;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const req = { headers: headers(), cookies: cookies() };
|
||||
const newLocale = await getLocale(req);
|
||||
const direction = dir(newLocale);
|
||||
|
||||
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
||||
};
|
||||
|
||||
const getFallbackProps = () => ({
|
||||
locale: "en",
|
||||
direction: "ltr",
|
||||
isEmbed: false,
|
||||
embedColorScheme: false,
|
||||
});
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const headers = nextHeaders();
|
||||
const cookies = nextCookies();
|
||||
const h = headers();
|
||||
|
||||
const fullUrl = headers.get("x-url") ?? "";
|
||||
const nonce = headers.get("x-csp") ?? "";
|
||||
const fullUrl = h.get("x-url") ?? "";
|
||||
const nonce = h.get("x-csp") ?? "";
|
||||
|
||||
const isSSG = !fullUrl;
|
||||
|
||||
const { locale, direction, isEmbed, embedColorScheme } = isSSG
|
||||
? getFallbackProps()
|
||||
: await getInitialProps(fullUrl);
|
||||
|
||||
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={direction}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
|
||||
data-nextjs-router="app">
|
||||
<head nonce={nonce}>
|
||||
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
|
||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
@@ -6,6 +6,7 @@ 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 { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { Badge, ListItemText, Avatar } from "@calcom/ui";
|
||||
@@ -56,7 +57,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
const router = useRouter();
|
||||
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { type DehydratedState } from "@tanstack/react-query";
|
||||
import type { SSRConfig } from "next-i18next";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
@@ -10,7 +11,6 @@ import type { ReactNode } from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers-app-dir";
|
||||
import AppProviders from "@lib/app-providers-app-dir";
|
||||
@@ -29,13 +29,14 @@ const calFont = localFont({
|
||||
});
|
||||
|
||||
export type PageWrapperProps = Readonly<{
|
||||
getLayout: (page: React.ReactElement) => ReactNode;
|
||||
getLayout: ((page: React.ReactElement) => ReactNode) | null;
|
||||
children: React.ReactElement;
|
||||
requiresLicense: boolean;
|
||||
isThemeSupported: boolean;
|
||||
isBookingPage: boolean;
|
||||
nonce: string | undefined;
|
||||
themeBasis: string | null;
|
||||
dehydratedState?: DehydratedState;
|
||||
isThemeSupported?: boolean;
|
||||
isBookingPage?: boolean;
|
||||
i18n?: SSRConfig;
|
||||
}>;
|
||||
|
||||
@@ -85,4 +86,4 @@ function PageWrapper(props: PageWrapperProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default trpc.withTRPC(PageWrapper);
|
||||
export default PageWrapper;
|
||||
|
||||
@@ -42,6 +42,7 @@ export type AppPageProps = {
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
concurrentMeetings: AppType["concurrentMeetings"];
|
||||
paid?: AppType["paid"];
|
||||
};
|
||||
|
||||
export const AppPage = ({
|
||||
@@ -67,6 +68,7 @@ export const AppPage = ({
|
||||
isTemplate,
|
||||
dependencies,
|
||||
concurrentMeetings,
|
||||
paid,
|
||||
}: AppPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
@@ -163,6 +165,19 @@ export const AppPage = ({
|
||||
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
|
||||
{categories[0]}
|
||||
</Link>{" "}
|
||||
{paid && (
|
||||
<>
|
||||
<Badge className="mr-1">
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(paid.priceInUsd)}
|
||||
/{t("month")}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
•{" "}
|
||||
<a target="_blank" rel="noreferrer" href={website}>
|
||||
{t("published_by", { author })}
|
||||
@@ -206,6 +221,7 @@ export const AppPage = ({
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
multiInstall
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -244,6 +260,7 @@ export const AppPage = ({
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
credentials={appDbQuery.data?.credentials}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -263,7 +280,7 @@ export const AppPage = ({
|
||||
<SkeletonButton className="mt-6 h-20 grow" />
|
||||
))}
|
||||
|
||||
{price !== 0 && (
|
||||
{price !== 0 && !paid && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
|
||||
{feeType === "monthly" && `/${t("month")}`}
|
||||
@@ -273,23 +290,27 @@ export const AppPage = ({
|
||||
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
|
||||
{body}
|
||||
</div>
|
||||
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
||||
<span className="text-default">
|
||||
{teamsPlanRequired ? (
|
||||
t("teams_plan_required")
|
||||
) : price === 0 ? (
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && `/${t("month")}`}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{!paid && (
|
||||
<>
|
||||
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
||||
<span className="text-default">
|
||||
{teamsPlanRequired ? (
|
||||
t("teams_plan_required")
|
||||
) : price === 0 ? (
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && `/${t("month")}`}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
|
||||
@@ -26,6 +26,7 @@ export const InstallAppButtonChild = ({
|
||||
multiInstall,
|
||||
credentials,
|
||||
concurrentMeetings,
|
||||
paid,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
@@ -34,6 +35,7 @@ export const InstallAppButtonChild = ({
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
concurrentMeetings?: boolean;
|
||||
paid?: AppFrontendPayload["paid"];
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -46,8 +48,27 @@ export const InstallAppButtonChild = ({
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
||||
|
||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
||||
// Paid apps don't support team installs at the moment
|
||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||
if (paid) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
color="primary"
|
||||
size="base">
|
||||
{paid.trial ? t("start_paid_trial") : t("install_paid_app")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!userAdminTeams?.length ||
|
||||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
@@ -55,6 +76,7 @@ export const InstallAppButtonChild = ({
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
disabled={shouldDisableInstallation}
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
} from "@calcom/ui";
|
||||
import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } from "@calcom/ui/components/icon";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
@@ -45,14 +43,18 @@ type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
loggedInUser: {
|
||||
userId: number | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const bookerUrl = useBookerUrl();
|
||||
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
|
||||
const user = query.data;
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
@@ -215,7 +217,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.tz(user?.timeZone)
|
||||
.tz(userTimeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
@@ -301,7 +303,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={user?.timeFormat ?? null}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
@@ -341,11 +343,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={user?.timeFormat}
|
||||
userTimezone={user?.timeZone}
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
@@ -372,7 +374,12 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
) : null}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted mt-2 text-sm">
|
||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -385,11 +392,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={user?.timeFormat}
|
||||
userTimezone={user?.timeZone}
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
@@ -414,7 +421,12 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -446,7 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<DisplayAttendees
|
||||
attendees={booking.attendees}
|
||||
user={booking.user}
|
||||
currentEmail={user?.email}
|
||||
currentEmail={userEmail}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
@@ -460,7 +472,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||
{isPending && userId === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||
{isConfirmed && <TableActions actions={bookedActions} />}
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
</>
|
||||
@@ -486,12 +498,16 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
interface RecurringBookingsTooltipProps {
|
||||
booking: BookingItemProps;
|
||||
recurringDates: Date[];
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
}
|
||||
|
||||
const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookingsTooltipProps) => {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const RecurringBookingsTooltip = ({
|
||||
booking,
|
||||
recurringDates,
|
||||
userTimeZone,
|
||||
userTimeFormat,
|
||||
}: RecurringBookingsTooltipProps) => {
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
@@ -523,7 +539,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
||||
.includes(aDate.toDateString());
|
||||
return (
|
||||
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
||||
{formatTime(aDate, user?.timeFormat, user?.timeZone)}
|
||||
{formatTime(aDate, userTimeFormat, userTimeZone)}
|
||||
{" - "}
|
||||
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
|
||||
</p>
|
||||
@@ -536,7 +552,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
||||
/>
|
||||
<p className="mt-1 pl-5 text-xs">
|
||||
{booking.status === BookingStatus.ACCEPTED
|
||||
? `${t("event_remaining", {
|
||||
? `${t("event_remaining_other", {
|
||||
count: recurringCount,
|
||||
})}`
|
||||
: getEveryFreqFor({
|
||||
|
||||
@@ -47,7 +47,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => {
|
||||
const getAppDataSetter = (appId: EventTypeAppsList, credentialId?: number): SetAppData => {
|
||||
return function (key, value) {
|
||||
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
||||
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
|
||||
@@ -57,6 +57,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
[appId]: {
|
||||
...appData,
|
||||
[key]: value,
|
||||
credentialId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -76,7 +77,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
@@ -90,7 +91,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, team.credentialId)}
|
||||
key={app.slug + team?.credentialId}
|
||||
app={{
|
||||
...app,
|
||||
@@ -147,7 +148,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
return (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Controller, useFormContext, useFieldArray } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -247,15 +247,7 @@ export const EventSetupTab = (
|
||||
<ul ref={animationRef} className="space-y-2">
|
||||
{locationFields.map((field, index) => {
|
||||
const eventLocationType = getEventLocationType(field.type);
|
||||
const defaultLocation = formMethods
|
||||
.getValues("locations")
|
||||
?.find((location: { type: EventLocationType["type"]; address?: string }) => {
|
||||
if (location.type === LocationType.InPerson) {
|
||||
return location.type === eventLocationType?.type && location.address === field?.address;
|
||||
} else {
|
||||
return location.type === eventLocationType?.type;
|
||||
}
|
||||
});
|
||||
const defaultLocation = field;
|
||||
|
||||
const option = getLocationFromType(field.type, locationOptions);
|
||||
|
||||
@@ -337,10 +329,12 @@ export const EventSetupTab = (
|
||||
name={eventLocationType.defaultValueVariable}
|
||||
className="text-error my-1 ml-6 text-sm"
|
||||
as="div"
|
||||
id="location-error"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-6">
|
||||
<CheckboxField
|
||||
name={`locations[${index}].displayLocationPublicly`}
|
||||
data-testid="display-location"
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
|
||||
@@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
|
||||
<div>
|
||||
<Label>{t("assign_to")}</Label>
|
||||
<ChildrenEventTypeSelect
|
||||
aria-label="assignment-dropdown"
|
||||
data-testid="assignment-dropdown"
|
||||
onChange={(options) => {
|
||||
onChange &&
|
||||
onChange(
|
||||
|
||||
@@ -14,6 +14,6 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageS
|
||||
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
||||
*/
|
||||
export function UserAvatar(props: UserAvatarProps) {
|
||||
const { user, previewSrc, ...rest } = props;
|
||||
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
|
||||
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
|
||||
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider, useSession } from "next-auth/react";
|
||||
@@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<SessionProvider>
|
||||
<CustomI18nextProvider i18n={props.i18n}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.themeBasis}
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={props.isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</OrgBrandProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
</EventCollectionProvider>
|
||||
<TrpcProvider dehydratedState={props.dehydratedState}>
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<SessionProvider>
|
||||
<CustomI18nextProvider i18n={props.i18n}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.themeBasis}
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</OrgBrandProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
</EventCollectionProvider>
|
||||
</TrpcProvider>
|
||||
);
|
||||
|
||||
if (isBookingPage) {
|
||||
|
||||
+25
-12
@@ -1,10 +1,11 @@
|
||||
import type { IncomingMessage, OutgoingMessage } from "http";
|
||||
import type { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { buildNonce } from "@lib/buildNonce";
|
||||
import { buildNonce } from "./buildNonce";
|
||||
|
||||
function getCspPolicy(nonce: string) {
|
||||
//TODO: Do we need to explicitly define it in turbo.json
|
||||
@@ -46,11 +47,12 @@ const isPagePathRequest = (url: URL) => {
|
||||
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
|
||||
};
|
||||
|
||||
export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
|
||||
export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMessage | NextResponse | null) {
|
||||
if (!req) {
|
||||
return { nonce: undefined };
|
||||
}
|
||||
const existingNonce = req.headers["x-nonce"];
|
||||
const existingNonce = "cache" in req ? req.headers.get("x-nonce") : req.headers["x-nonce"];
|
||||
|
||||
if (existingNonce) {
|
||||
const existingNoneParsed = z.string().safeParse(existingNonce);
|
||||
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
|
||||
@@ -71,17 +73,28 @@ export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
|
||||
}
|
||||
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
|
||||
// It is generated for all page requests but only used by pages that need CSP
|
||||
req.headers["x-nonce"] = nonce;
|
||||
|
||||
if ("cache" in req) {
|
||||
req.headers.set("x-nonce", nonce);
|
||||
} else {
|
||||
req.headers["x-nonce"] = nonce;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
res.setHeader(
|
||||
req.headers["x-csp-enforce"] === "true"
|
||||
? "Content-Security-Policy"
|
||||
: "Content-Security-Policy-Report-Only",
|
||||
getCspPolicy(nonce)
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim()
|
||||
);
|
||||
const enforced =
|
||||
"cache" in req ? req.headers.get("x-csp-enforce") === "true" : req.headers["x-csp-enforce"] === "true";
|
||||
|
||||
const name = enforced ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only";
|
||||
|
||||
const value = getCspPolicy(nonce)
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
if ("body" in res) {
|
||||
res.headers.set(name, value);
|
||||
} else {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { nonce };
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
export default function useIsBookingPage(): boolean {
|
||||
const pathname = usePathname();
|
||||
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const userParam = Boolean(searchParams?.get("user"));
|
||||
const teamParam = Boolean(searchParams?.get("team"));
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
export default function useRouterQuery<T extends string>(name: T) {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
export function useToggleQuery(name: string) {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
return {
|
||||
isOn: searchParams?.get(name) === "1",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { truncateOnWord } from "@calcom/lib/text";
|
||||
|
||||
type RootMetadataRecipe = Readonly<{
|
||||
twitterCreator: string;
|
||||
twitterSite: string;
|
||||
robots: {
|
||||
index: boolean;
|
||||
follow: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type PageMetadataRecipe = Readonly<{
|
||||
title: string;
|
||||
canonical: string;
|
||||
image: string;
|
||||
description: string;
|
||||
siteName: string;
|
||||
metadataBase: URL;
|
||||
}>;
|
||||
|
||||
export const prepareRootMetadata = (recipe: RootMetadataRecipe): Metadata => ({
|
||||
icons: {
|
||||
icon: "/favicon.icon",
|
||||
apple: "/api/logo?type=apple-touch-icon",
|
||||
other: [
|
||||
{
|
||||
rel: "icon-mask",
|
||||
url: "/safari-pinned-tab.svg",
|
||||
color: "#000000",
|
||||
},
|
||||
{
|
||||
url: "/api/logo?type=favicon-16",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/api/logo?type=favicon-32",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
viewport: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0",
|
||||
robots: recipe.robots,
|
||||
other: {
|
||||
"application-TileColor": "#ff0000",
|
||||
},
|
||||
themeColor: [
|
||||
{
|
||||
media: "(prefers-color-scheme: light)",
|
||||
color: "#f9fafb",
|
||||
},
|
||||
{
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
color: "#1C1C1C",
|
||||
},
|
||||
],
|
||||
twitter: {
|
||||
site: recipe.twitterSite,
|
||||
creator: recipe.twitterCreator,
|
||||
card: "summary_large_image",
|
||||
},
|
||||
});
|
||||
|
||||
export const preparePageMetadata = (recipe: PageMetadataRecipe): Metadata => ({
|
||||
title: recipe.title,
|
||||
alternates: {
|
||||
canonical: recipe.canonical,
|
||||
},
|
||||
openGraph: {
|
||||
description: truncateOnWord(recipe.description, 158),
|
||||
url: recipe.canonical,
|
||||
type: "website",
|
||||
siteName: recipe.siteName,
|
||||
title: recipe.title,
|
||||
images: [recipe.image],
|
||||
},
|
||||
metadataBase: recipe.metadataBase,
|
||||
});
|
||||
+30
-3
@@ -1,14 +1,21 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import { collectEvents } from "next-collect/server";
|
||||
import type { NextMiddleware } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
const middleware: NextMiddleware = async (req) => {
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
import { abTestMiddlewareFactory } from "./abTest/middlewareFactory";
|
||||
|
||||
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const url = req.nextUrl;
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
|
||||
requestHeaders.set("x-url", req.url);
|
||||
|
||||
if (!url.pathname.startsWith("/api")) {
|
||||
//
|
||||
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||
@@ -32,6 +39,18 @@ const middleware: NextMiddleware = async (req) => {
|
||||
}
|
||||
|
||||
const res = routingForms.handle(url);
|
||||
|
||||
const { nonce } = csp(req, res ?? null);
|
||||
|
||||
if (!process.env.CSP_POLICY) {
|
||||
req.headers.set("x-csp", "not-opted-in");
|
||||
} else if (!req.headers.get("x-csp")) {
|
||||
// If x-csp not set by gSSP, then it's initialPropsOnly
|
||||
req.headers.set("x-csp", "initialPropsOnly");
|
||||
} else {
|
||||
req.headers.set("x-csp", nonce ?? "");
|
||||
}
|
||||
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
@@ -45,6 +64,12 @@ const middleware: NextMiddleware = async (req) => {
|
||||
requestHeaders.set("x-csp-enforce", "true");
|
||||
}
|
||||
|
||||
requestHeaders.set("x-pathname", url.pathname);
|
||||
|
||||
const locale = await getLocale(req);
|
||||
|
||||
requestHeaders.set("x-locale", locale);
|
||||
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
@@ -74,11 +99,13 @@ export const config = {
|
||||
* Paths required by routingForms.handle
|
||||
*/
|
||||
"/apps/routing_forms/:path*",
|
||||
"/event-types",
|
||||
"/future/event-types/",
|
||||
],
|
||||
};
|
||||
|
||||
export default collectEvents({
|
||||
middleware,
|
||||
middleware: abTestMiddlewareFactory(middleware),
|
||||
...nextCollectBasicSettings,
|
||||
cookieName: "__clnds",
|
||||
extend: extendEventData,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.4.8",
|
||||
"version": "3.5.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@hookform/resolvers": "^2.9.7",
|
||||
"@next-auth/prisma-adapter": "^1.0.4",
|
||||
"@next/bundle-analyzer": "^13.1.6",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
@@ -168,8 +168,9 @@
|
||||
"module-alias": "^2.2.2",
|
||||
"msw": "^0.42.3",
|
||||
"node-html-parser": "^6.1.10",
|
||||
"node-mocks-http": "^1.11.0",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.9.4"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Head from "next/head";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
import { Copy } from "@calcom/ui/components/icon";
|
||||
@@ -9,7 +9,7 @@ import { Copy } from "@calcom/ui/components/icon";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function Error500() {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
|
||||
@@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
description={markdownStrippedBio}
|
||||
meeting={{
|
||||
title: markdownStrippedBio,
|
||||
profile: { name: `${profile.name}`, image: null },
|
||||
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
|
||||
users: [{ username: `${user.username}`, name: `${user.name}` }],
|
||||
}}
|
||||
nextSeoProps={{
|
||||
@@ -245,7 +245,7 @@ export type UserPageProps = {
|
||||
allowSEOIndexing: boolean;
|
||||
username: string | null;
|
||||
};
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
|
||||
themeBasis: string | null;
|
||||
markdownStrippedBio: string;
|
||||
safeBio: string;
|
||||
@@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
metadata: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
avatarUrl: true,
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
@@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
image: user.avatar,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
avatarUrl: user.avatarUrl,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
username: user.username,
|
||||
@@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl,
|
||||
away: user.away,
|
||||
verified: user.verified,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const querySchema = z.object({
|
||||
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
|
||||
});
|
||||
|
||||
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
|
||||
const errors = error.errors.map((err) => ({
|
||||
path: err.path.join("."),
|
||||
errorCode: `error.validation.${err.code}`,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
message: "VALIDATION_ERROR",
|
||||
errors,
|
||||
});
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const result = querySchema.safeParse(req.query);
|
||||
if (!result.success) {
|
||||
return handleValidationError(res, result.error);
|
||||
}
|
||||
|
||||
const { uuid: objectKey } = result.data;
|
||||
|
||||
let img;
|
||||
try {
|
||||
const { data } = await prisma.avatar.findUniqueOrThrow({
|
||||
where: {
|
||||
objectKey,
|
||||
},
|
||||
select: {
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
img = data;
|
||||
} catch (e) {
|
||||
// If anything goes wrong or avatar is not found, use default avatar
|
||||
res.writeHead(302, {
|
||||
Location: AVATAR_FALLBACK,
|
||||
});
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
|
||||
const imageResp = Buffer.from(decoded, "base64");
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": imageResp.length,
|
||||
});
|
||||
|
||||
res.end(imageResp);
|
||||
}
|
||||
@@ -2,9 +2,18 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: userIp,
|
||||
});
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
|
||||
@@ -3,12 +3,20 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: userIp,
|
||||
});
|
||||
const data: RecurringBookingCreateBody[] = req.body;
|
||||
const session = await getServerSession({ req, res });
|
||||
const createdBookings: BookingResponse[] = [];
|
||||
|
||||
@@ -73,7 +73,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
||||
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
||||
}
|
||||
return res.status(200);
|
||||
if (!res.writableEnded) return res.status(200);
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { buffer } from "micro";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
// This file is a catch-all for any integration related subscription/paid app.
|
||||
|
||||
const handleSubscriptionUpdate = async (event: Stripe.Event) => {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
|
||||
|
||||
const app = await prisma.credential.findFirst({
|
||||
where: {
|
||||
subscriptionId: subscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
|
||||
}
|
||||
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: app.id,
|
||||
},
|
||||
data: {
|
||||
paymentStatus: subscription.status,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubscriptionDeleted = async (event: Stripe.Event) => {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
|
||||
|
||||
const app = await prisma.credential.findFirst({
|
||||
where: {
|
||||
subscriptionId: subscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
|
||||
}
|
||||
|
||||
// should we delete the credential here rather than marking as inactive?
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: app.id,
|
||||
},
|
||||
data: {
|
||||
paymentStatus: "inactive",
|
||||
billingCycleStart: null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
|
||||
|
||||
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
|
||||
"customer.subscription.updated": handleSubscriptionUpdate,
|
||||
"customer.subscription.deleted": handleSubscriptionDeleted,
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
const sig = req.headers["stripe-signature"];
|
||||
if (!sig) {
|
||||
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
|
||||
}
|
||||
|
||||
if (!process.env.STRIPE_WEBHOOK_SECRET) {
|
||||
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
|
||||
}
|
||||
const requestBuffer = await buffer(req);
|
||||
const payload = requestBuffer.toString();
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
|
||||
const handler = webhookHandlers[event.type];
|
||||
if (handler) {
|
||||
await handler(event);
|
||||
} else {
|
||||
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
|
||||
throw new HttpCode({
|
||||
statusCode: 202,
|
||||
message: `Unhandled Stripe Webhook event type ${event.type}`,
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
res.status(err.statusCode ?? 500).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return a response to acknowledge receipt of the event
|
||||
res.json({ received: true });
|
||||
}
|
||||
@@ -79,6 +79,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
||||
isTemplate={data.isTemplate}
|
||||
dependencies={data.dependencies}
|
||||
concurrentMeetings={data.concurrentMeetings}
|
||||
paid={data.paid}
|
||||
// tos="https://zoom.us/terms"
|
||||
// privacy="https://zoom.us/privacy"
|
||||
body={
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { InferGetServerSidePropsType } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
const slug = searchParams?.get("slug") as string;
|
||||
const { status } = useSession();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
@@ -13,7 +13,7 @@ import { AppCard, SkeletonText } from "@calcom/ui";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const category = searchParams?.get("category");
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useReducer } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -122,7 +122,7 @@ type ModalState = {
|
||||
};
|
||||
|
||||
export default function InstalledApps() {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const category = searchParams?.get("category") as querySchemaType["category"];
|
||||
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import z from "zod";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { X } from "@calcom/ui/components/icon";
|
||||
@@ -18,7 +18,7 @@ const querySchema = z.object({
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { error } = querySchema.parse(searchParams);
|
||||
const isTokenVerificationError = error?.toLowerCase() === "verification";
|
||||
const errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { jwtVerify } from "jose";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken, signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
@@ -17,6 +17,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -53,7 +54,7 @@ export default function Login({
|
||||
totpEmail,
|
||||
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const formSchema = z
|
||||
@@ -278,7 +279,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||
|
||||
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
|
||||
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { req, res } = context;
|
||||
const { req, res, query } = context;
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
const ssr = await ssrInit(context);
|
||||
@@ -318,6 +319,24 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
|
||||
}
|
||||
|
||||
if (session) {
|
||||
const { callbackUrl } = query;
|
||||
|
||||
if (callbackUrl) {
|
||||
try {
|
||||
const destination = getSafeRedirectUrl(callbackUrl as string);
|
||||
if (destination) {
|
||||
return {
|
||||
redirect: {
|
||||
destination,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Button, Select } from "@calcom/ui";
|
||||
@@ -16,7 +16,7 @@ export default function Authorize() {
|
||||
const { status } = useSession();
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const client_id = searchParams?.get("client_id") as string;
|
||||
const state = searchParams?.get("state") as string;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
// To handle the IdP initiated login flow callback
|
||||
export default function Page() {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams?.get("code");
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
@@ -21,7 +22,7 @@ import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
function useSetStep() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
const setStep = (newStep = 1) => {
|
||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
@@ -9,6 +9,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -22,7 +23,7 @@ import { ssrInit } from "@server/lib/ssr";
|
||||
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Provider(props: SSOProviderPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
@@ -54,7 +55,7 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export default function Verify() {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
|
||||
@@ -8,6 +8,7 @@ import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -83,7 +84,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
};
|
||||
|
||||
export default function Availability() {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -131,7 +132,7 @@ const WithQuery = withQuery(trpc.viewer.availability.list as any);
|
||||
|
||||
export default function AvailabilityPage() {
|
||||
const { t } = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
|
||||
@@ -1,139 +1,20 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter";
|
||||
import { getLayout } from "@calcom/features/troubleshooter/layout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
type User = RouterOutputs["viewer"]["me"];
|
||||
|
||||
export interface IBusySlot {
|
||||
start: string | Date;
|
||||
end: string | Date;
|
||||
title?: string;
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
const AvailabilityView = ({ user }: { user: User }) => {
|
||||
const { t } = useLocale();
|
||||
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
|
||||
const selectedDate = dayjs(date);
|
||||
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
|
||||
|
||||
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
|
||||
{
|
||||
username: user.username || "",
|
||||
dateFrom: selectedDate.startOf("day").utc().format(),
|
||||
dateTo: selectedDate.endOf("day").utc().format(),
|
||||
withSource: true,
|
||||
},
|
||||
{
|
||||
enabled: !!user.username,
|
||||
}
|
||||
);
|
||||
|
||||
const overrides =
|
||||
data?.dateOverrides.reduce((acc, override) => {
|
||||
if (
|
||||
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
|
||||
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
|
||||
)
|
||||
return acc;
|
||||
acc.push({ ...override, source: "Date override" });
|
||||
return acc;
|
||||
}, [] as IBusySlot[]) || [];
|
||||
|
||||
return (
|
||||
<div className="bg-default max-w-xl overflow-hidden rounded-md shadow">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
{t("overview_of_day")}{" "}
|
||||
<input
|
||||
type="date"
|
||||
className="inline h-8 border-none bg-inherit p-0"
|
||||
defaultValue={formattedSelectedDate}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setSelectedDate(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<small className="text-muted block">{t("hover_over_bold_times_tip")}</small>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
||||
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
||||
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
if (isLoading)
|
||||
return (
|
||||
<>
|
||||
<SkeletonText className="block h-16 w-full" />
|
||||
<SkeletonText className="block h-16 w-full" />
|
||||
</>
|
||||
);
|
||||
|
||||
if (data && (data.busy.length > 0 || overrides.length > 0))
|
||||
return [...data.busy, ...overrides]
|
||||
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
|
||||
.map((slot: IBusySlot) => (
|
||||
<div
|
||||
key={dayjs(slot.start).format("HH:mm")}
|
||||
className="bg-subtle overflow-hidden rounded-md"
|
||||
data-testid="troubleshooter-busy-time">
|
||||
<div className="text-emphasis px-4 py-5 sm:p-6">
|
||||
{t("calendar_shows_busy_between")}{" "}
|
||||
<span className="text-default font-medium" title={dayjs(slot.start).format("HH:mm")}>
|
||||
{dayjs(slot.start).format("HH:mm")}
|
||||
</span>{" "}
|
||||
{t("and")}{" "}
|
||||
<span className="text-default font-medium" title={dayjs(slot.end).format("HH:mm")}>
|
||||
{dayjs(slot.end).format("HH:mm")}
|
||||
</span>{" "}
|
||||
{t("on")} {dayjs(slot.start).format("D")}{" "}
|
||||
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
|
||||
{slot.title && ` - (${slot.title})`}
|
||||
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
<div className="bg-subtle overflow-hidden rounded-md">
|
||||
<div className="text-emphasis px-4 py-5 sm:p-6">{t("calendar_no_busy_slots")}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
||||
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
||||
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Troubleshoot() {
|
||||
const { data, isLoading } = trpc.viewer.me.useQuery();
|
||||
function TroubleshooterPage() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}>
|
||||
{!isLoading && data && <AvailabilityView user={data} />}
|
||||
</Shell>
|
||||
</div>
|
||||
<>
|
||||
<HeadSeo title={t("troubleshoot")} description={t("troubleshoot_availability")} />
|
||||
<Troubleshooter month={null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Troubleshoot.PageWrapper = PageWrapper;
|
||||
|
||||
function convertMinsToHrsMins(mins: number) {
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
const hs = h < 10 ? `0${h}` : h;
|
||||
const ms = m < 10 ? `0${m}` : m;
|
||||
return `${hs}:${ms}`;
|
||||
}
|
||||
TroubleshooterPage.getLayout = getLayout;
|
||||
TroubleshooterPage.PageWrapper = PageWrapper;
|
||||
export default TroubleshooterPage;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createEvent } from "ics";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RRule } from "rrule";
|
||||
import { z } from "zod";
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@calcom/lib/date-fns";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -98,7 +99,7 @@ export default function Success(props: SuccessProps) {
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const {
|
||||
allRemainingBookings,
|
||||
isSuccessBookingPage,
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
|
||||
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
@@ -20,6 +21,7 @@ import { Alert, Button, EmptyScreen } from "@calcom/ui";
|
||||
import { Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
@@ -78,6 +80,7 @@ export default function Bookings() {
|
||||
const { data: filterQuery } = useFilterQuery();
|
||||
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
|
||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||
{
|
||||
@@ -119,7 +122,10 @@ export default function Bookings() {
|
||||
}
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
} else if (status === "upcoming") {
|
||||
return new Date(booking.startTime).toDateString() !== new Date().toDateString();
|
||||
return (
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -132,7 +138,11 @@ export default function Bookings() {
|
||||
recurringInfoToday = page.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
);
|
||||
return new Date(booking.startTime).toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
);
|
||||
})
|
||||
)[0] || [];
|
||||
|
||||
@@ -166,6 +176,12 @@ export default function Bookings() {
|
||||
{bookingsToday.map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfoToday}
|
||||
{...booking}
|
||||
@@ -190,6 +206,12 @@ export default function Bookings() {
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...booking}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, ButtonGroup } from "@calcom/ui";
|
||||
import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function EnterprisePage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Globe className="h-5 w-5 text-red-500" />,
|
||||
title: t("branded_subdomain"),
|
||||
description: t("branded_subdomain_description"),
|
||||
},
|
||||
{
|
||||
icon: <BarChart className="h-5 w-5 text-blue-500" />,
|
||||
title: t("org_insights"),
|
||||
description: t("org_insights_description"),
|
||||
},
|
||||
{
|
||||
icon: <Paintbrush className="h-5 w-5 text-pink-500" />,
|
||||
title: t("extensive_whitelabeling"),
|
||||
description: t("extensive_whitelabeling_description"),
|
||||
},
|
||||
{
|
||||
icon: <Users className="h-5 w-5 text-orange-500" />,
|
||||
title: t("unlimited_teams"),
|
||||
description: t("unlimited_teams_description"),
|
||||
},
|
||||
{
|
||||
icon: <CreditCard className="h-5 w-5 text-green-500" />,
|
||||
title: t("unified_billing"),
|
||||
description: t("unified_billing_description"),
|
||||
},
|
||||
{
|
||||
icon: <Lock className="h-5 w-5 text-purple-500" />,
|
||||
title: t("advanced_managed_events"),
|
||||
description: t("advanced_managed_events_description"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<ShellMain heading="Enterprise" subtitle={t("enterprise_description")}>
|
||||
<UpgradeTip
|
||||
plan="enterprise"
|
||||
title={t("create_your_org")}
|
||||
description={t("create_your_org_description")}
|
||||
features={features}
|
||||
background="/tips/enterprise"
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href="https://i.cal.com/sales/enterprise?duration=25" target="_blank">
|
||||
{t("contact_sales")}
|
||||
</Button>
|
||||
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
}>
|
||||
<>Create Org</>
|
||||
</UpgradeTip>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnterprisePage.PageWrapper = PageWrapper;
|
||||
EnterprisePage.getLayout = getLayout;
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import type { User } from "@prisma/client";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
@@ -19,6 +21,7 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
@@ -208,7 +211,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -386,7 +389,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
||||
return (
|
||||
<li key={type.id}>
|
||||
<div className="hover:bg-muted flex w-full items-center justify-between">
|
||||
<div className="hover:bg-muted flex w-full items-center justify-between transition">
|
||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
||||
{!(firstItem && firstItem.id === type.id) && (
|
||||
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||
@@ -840,7 +843,7 @@ const Main = ({
|
||||
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
|
||||
}) => {
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
if (!data || status === "loading") {
|
||||
@@ -904,7 +907,7 @@ const Main = ({
|
||||
|
||||
const EventTypesPage = () => {
|
||||
const { t } = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { open } = useIntercom();
|
||||
const { data: user } = useMeQuery();
|
||||
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function InsightsPage() {
|
||||
<div>
|
||||
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
|
||||
<UpgradeTip
|
||||
plan="team"
|
||||
title={t("make_informed_decisions")}
|
||||
description={t("make_informed_decisions_description")}
|
||||
features={features}
|
||||
|
||||
@@ -250,10 +250,7 @@ const AppearanceView = ({
|
||||
/>
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
<Alert severity="warning" message={t("light_theme_contrast_error")} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -282,10 +279,7 @@ const AppearanceView = ({
|
||||
/>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import OrganizationMemberAvatar from "@calcom/features/ee/organizations/componen
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
@@ -72,37 +72,22 @@ interface DeleteAccountValues {
|
||||
|
||||
type FormValues = {
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
avatar: string;
|
||||
name: string;
|
||||
email: string;
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
|
||||
return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||
};
|
||||
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { update } = useSession();
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
|
||||
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
|
||||
onSuccess: async (userData) => {
|
||||
try {
|
||||
if (!userData.organization) {
|
||||
const res = await fetch(userData.avatar);
|
||||
if (res.url) setFetchedImgSrc(res.url);
|
||||
} else {
|
||||
setFetchedImgSrc("");
|
||||
}
|
||||
} catch (err) {
|
||||
setFetchedImgSrc("");
|
||||
}
|
||||
},
|
||||
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
|
||||
enabled: !isLoading && !user?.avatarUrl,
|
||||
});
|
||||
|
||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await update(res);
|
||||
@@ -234,7 +219,7 @@ const ProfileView = () => {
|
||||
|
||||
const defaultValues = {
|
||||
username: user.username || "",
|
||||
avatar: user.avatar || "",
|
||||
avatar: getUserAvatarUrl(user),
|
||||
name: user.name || "",
|
||||
email: user.email || "",
|
||||
bio: user.bio || "",
|
||||
@@ -251,8 +236,7 @@ const ProfileView = () => {
|
||||
key={JSON.stringify(defaultValues)}
|
||||
defaultValues={defaultValues}
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
|
||||
userAvatar={user.avatar}
|
||||
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
|
||||
user={user}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
@@ -398,7 +382,6 @@ const ProfileForm = ({
|
||||
extraField,
|
||||
isLoading = false,
|
||||
isFallbackImg,
|
||||
userAvatar,
|
||||
user,
|
||||
userOrganization,
|
||||
}: {
|
||||
@@ -407,7 +390,6 @@ const ProfileForm = ({
|
||||
extraField?: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
isFallbackImg: boolean;
|
||||
userAvatar: string;
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
@@ -416,7 +398,7 @@ const ProfileForm = ({
|
||||
|
||||
const profileFormSchema = z.object({
|
||||
username: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
avatar: z.string(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -438,7 +420,6 @@ const ProfileForm = ({
|
||||
} = formMethods;
|
||||
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
|
||||
return (
|
||||
<Form form={formMethods} handleSubmit={onSubmit}>
|
||||
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
|
||||
@@ -447,7 +428,7 @@ const ProfileForm = ({
|
||||
control={formMethods.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => {
|
||||
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
|
||||
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
|
||||
const organization =
|
||||
userOrganization && userOrganization.id
|
||||
? {
|
||||
@@ -474,7 +455,7 @@ const ProfileForm = ({
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||
}}
|
||||
imageSrc={value || undefined}
|
||||
imageSrc={value}
|
||||
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
|
||||
/>
|
||||
|
||||
@@ -482,7 +463,7 @@ const ProfileForm = ({
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
formMethods.setValue("avatar", null, { shouldDirty: true });
|
||||
formMethods.setValue("avatar", "", { shouldDirty: true });
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
@@ -13,6 +12,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 { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
@@ -37,7 +37,7 @@ type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
||||
|
||||
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
|
||||
const [emailUser, emailDomain] = email.split("@");
|
||||
const [emailUser, emailDomain = ""] = email.split("@");
|
||||
const username =
|
||||
emailDomain === autoAcceptEmailDomain
|
||||
? slugify(emailUser)
|
||||
@@ -53,7 +53,7 @@ function addOrUpdateQueryParam(url: string, key: string, value: string) {
|
||||
}
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
const { t, i18n } = useLocale();
|
||||
const flags = useFlagMap();
|
||||
@@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
|
||||
methods.clearErrors("apiError");
|
||||
}
|
||||
|
||||
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||
if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||
methods.setValue(
|
||||
"username",
|
||||
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
|
||||
|
||||
@@ -11,10 +11,6 @@ let pages = (exports.pages = glob
|
||||
)
|
||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")));
|
||||
|
||||
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
|
||||
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
|
||||
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
|
||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
||||
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
||||
@@ -27,11 +23,26 @@ let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
|
||||
));
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
|
||||
|
||||
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
|
||||
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
|
||||
exports.orgUserTypeRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
|
||||
"/|"
|
||||
)}|_next/|public/)[^/]+)/:type((?!avatar\.png)[^/]+)`;
|
||||
exports.orgUserTypeEmbedRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
|
||||
"/|"
|
||||
)}|_next/|public/)[^/]+)/:type/embed`;
|
||||
/**
|
||||
* Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
|
||||
*/
|
||||
function getRegExpMatchingAllReservedRoutes(suffix) {
|
||||
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
|
||||
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
|
||||
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
const nextJsSpecialPaths = ["_next", "public"];
|
||||
|
||||
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes).concat(nextJsSpecialPaths);
|
||||
return beforeRewriteExcludePages.join(`${suffix}|`) + suffix;
|
||||
}
|
||||
|
||||
// To handle /something
|
||||
exports.orgUserRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/?$")})[a-zA-Z0-9\-_]+)`;
|
||||
|
||||
// To handle /something/somethingelse
|
||||
exports.orgUserTypeRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes(
|
||||
"/"
|
||||
)})[^/]+)/:type((?!avatar\.png)[^/]+)`;
|
||||
|
||||
// To handle /something/somethingelse/embed
|
||||
exports.orgUserTypeEmbedRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/")})[^/]+)/:type/embed`;
|
||||
|
||||
@@ -40,6 +40,7 @@ test.describe("Availablity tests", () => {
|
||||
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
|
||||
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
|
||||
await page.goto(troubleshooterURL);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
import type { Schedule, TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
@@ -342,3 +344,92 @@ test.describe("Booking on different layouts", () => {
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking round robin event", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const teamMatesObj = [{ name: "teammate-1" }];
|
||||
|
||||
const dateRanges: TimeRange = {
|
||||
start: new Date(new Date().setUTCHours(10, 0, 0, 0)), //one hour after default schedule (teammate-1's schedule)
|
||||
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
|
||||
};
|
||||
|
||||
const schedule: Schedule = [[], [dateRanges], [dateRanges], [dateRanges], [dateRanges], [dateRanges], []];
|
||||
|
||||
const testUser = await users.create(
|
||||
{ username: "test-user", name: "Test User", email: "testuser@example.com", schedule },
|
||||
{
|
||||
hasTeam: true,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
teamEventLength: 120,
|
||||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
const team = await testUser.getFirstTeam();
|
||||
await page.goto(`/team/${team.team.slug}`);
|
||||
});
|
||||
|
||||
test("Does not book round robin host outside availability with date override", async ({ page, users }) => {
|
||||
const [testUser] = users.get();
|
||||
testUser.apiLogin();
|
||||
|
||||
const team = await testUser.getFirstTeam();
|
||||
|
||||
// Click first event type (round robin)
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
// books 9AM slots for 120 minutes (test-user is not available at this time, availability starts at 10)
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
const host = await page.locator('[data-testid="booking-host-name"]');
|
||||
const hostName = await host.innerText();
|
||||
|
||||
//expect teammate-1 to be booked, test-user is not available at this time
|
||||
expect(hostName).toBe("teammate-1");
|
||||
|
||||
// make another booking to see if also for the second booking teammate-1 is booked
|
||||
await page.goto(`/team/${team.team.slug}`);
|
||||
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
// Again book a 9AM slot for 120 minutes where test-user is not available
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
const hostSecondBooking = await page.locator('[data-testid="booking-host-name"]');
|
||||
const hostNameSecondBooking = await hostSecondBooking.innerText();
|
||||
expect(hostNameSecondBooking).toBe("teammate-1"); // teammate-1 should be booked again
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
|
||||
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Event Types A/B tests", () => {
|
||||
test("should point to the /future/event-types page", async ({ page, users, context }) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "x-calcom-future-routes-override",
|
||||
value: "1",
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
const user = await users.create();
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/event-types");
|
||||
|
||||
await page.waitForLoadState();
|
||||
|
||||
const dataNextJsRouter = await page.evaluate(() =>
|
||||
window.document.documentElement.getAttribute("data-nextjs-router")
|
||||
);
|
||||
|
||||
expect(dataNextJsRouter).toEqual("app");
|
||||
|
||||
const locator = page.getByRole("heading", { name: "Event Types" });
|
||||
|
||||
await expect(locator).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Event Types tests", () => {
|
||||
test.describe("user", () => {
|
||||
testBothFutureAndLegacyRoutes.describe("user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
});
|
||||
@@ -254,6 +285,87 @@ test.describe("Event Types tests", () => {
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/);
|
||||
});
|
||||
|
||||
test("can add single organizer address location without display location public option", async ({
|
||||
page,
|
||||
}) => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
await firstEventTypeElement.click();
|
||||
await page.waitForURL((url) => {
|
||||
return !!url.pathname.match(/\/event-types\/.+/);
|
||||
});
|
||||
|
||||
const locationAddress = "New Delhi";
|
||||
|
||||
await fillLocation(page, locationAddress, 0, false);
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
|
||||
await page.goto("/event-types");
|
||||
|
||||
const previewLink = await page
|
||||
.locator("[data-testid=preview-link-button]")
|
||||
.first()
|
||||
.getAttribute("href");
|
||||
|
||||
await page.goto(previewLink ?? "");
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="where"]`)).toHaveText(locationAddress);
|
||||
});
|
||||
|
||||
test("can select 'display on booking page' option when multiple organizer input type are present", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFirstEventType(page);
|
||||
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator(`text="Link meeting"`).click();
|
||||
|
||||
const locationInputName = (idx: number) => `locations[${idx}].link`;
|
||||
|
||||
const testUrl1 = "https://cal.ai/";
|
||||
await page.locator(`input[name="${locationInputName(0)}"]`).fill(testUrl1);
|
||||
await page.locator("[data-testid=display-location]").last().check();
|
||||
await checkDisplayLocation(page);
|
||||
await unCheckDisplayLocation(page);
|
||||
|
||||
await page.locator("[data-testid=add-location]").click();
|
||||
|
||||
const testUrl2 = "https://cal.com/ai";
|
||||
await page.locator(`text="Link meeting"`).last().click();
|
||||
await page.locator(`input[name="${locationInputName(1)}"]`).waitFor();
|
||||
await page.locator(`input[name="${locationInputName(1)}"]`).fill(testUrl2);
|
||||
await checkDisplayLocation(page);
|
||||
await unCheckDisplayLocation(page);
|
||||
|
||||
// Remove Both of the locations
|
||||
const removeButtomId = "delete-locations.0.type";
|
||||
await page.getByTestId(removeButtomId).click();
|
||||
await page.getByTestId(removeButtomId).click();
|
||||
|
||||
// Add Multiple Organizer Phone Number options
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator(`text="Organizer Phone Number"`).click();
|
||||
|
||||
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
|
||||
|
||||
const testPhoneInputValue1 = "9199999999";
|
||||
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).waitFor();
|
||||
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).fill(testPhoneInputValue1);
|
||||
await page.locator("[data-testid=display-location]").last().check();
|
||||
await checkDisplayLocation(page);
|
||||
await unCheckDisplayLocation(page);
|
||||
await page.locator("[data-testid=add-location]").click();
|
||||
|
||||
const testPhoneInputValue2 = "9188888888";
|
||||
await page.locator(`text="Organizer Phone Number"`).last().click();
|
||||
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).waitFor();
|
||||
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).fill(testPhoneInputValue2);
|
||||
await checkDisplayLocation(page);
|
||||
await unCheckDisplayLocation(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -293,7 +405,7 @@ async function addAnotherLocation(page: Page, locationOptionText: string) {
|
||||
await page.locator(`text="${locationOptionText}"`).click();
|
||||
}
|
||||
|
||||
const fillLocation = async (page: Page, inputText: string, index: number) => {
|
||||
const fillLocation = async (page: Page, inputText: string, index: number, selectDisplayLocation = true) => {
|
||||
// Except the first location, dropdown automatically opens when adding another location
|
||||
if (index == 0) {
|
||||
await page.locator("#location-select").last().click();
|
||||
@@ -303,5 +415,17 @@ const fillLocation = async (page: Page, inputText: string, index: number) => {
|
||||
const locationInputName = `locations[${index}].address`;
|
||||
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
||||
await page.locator(`input[name="locations[${index}].address"]`).fill(inputText);
|
||||
await page.locator("[data-testid=display-location]").last().check();
|
||||
if (selectDisplayLocation) {
|
||||
await page.locator("[data-testid=display-location]").last().check();
|
||||
}
|
||||
};
|
||||
|
||||
const checkDisplayLocation = async (page: Page) => {
|
||||
await page.locator("[data-testid=display-location]").last().check();
|
||||
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked();
|
||||
};
|
||||
|
||||
const unCheckDisplayLocation = async (page: Page) => {
|
||||
await page.locator("[data-testid=display-location]").last().uncheck();
|
||||
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked({ checked: false });
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,34 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
E2E_CLIPBOARD_VALUE?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export type Window = typeof window;
|
||||
// creates the single server fixture
|
||||
export const createClipboardFixture = (page: Page) => {
|
||||
return {
|
||||
reset: async () => {
|
||||
await page.evaluate(() => {
|
||||
delete window.E2E_CLIPBOARD_VALUE;
|
||||
});
|
||||
},
|
||||
get: async () => {
|
||||
return getClipboardValue({ page });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function getClipboardValue({ page }: { page: Page }) {
|
||||
return page.evaluate(() => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
setInterval(() => {
|
||||
if (!window.E2E_CLIPBOARD_VALUE) return;
|
||||
resolve(window.E2E_CLIPBOARD_VALUE);
|
||||
}, 500);
|
||||
setTimeout(() => reject(new Error("Timeout")), 1000);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export function createBookingPageFixture(page: Page) {
|
||||
placeholder?: string
|
||||
) => {
|
||||
await page.getByTestId("add-field").click();
|
||||
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("test-field-type").click();
|
||||
await page.getByTestId(`select-option-${questionType}`).click();
|
||||
await page.getByLabel("Identifier").dblclick();
|
||||
await page.getByLabel("Identifier").fill(identifier);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { Schedule } from "@calcom/types/schedule";
|
||||
|
||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||
import { TimeZoneEnum } from "./types";
|
||||
@@ -46,6 +47,7 @@ const createTeamEventType = async (
|
||||
schedulingType?: SchedulingType;
|
||||
teamEventTitle?: string;
|
||||
teamEventSlug?: string;
|
||||
teamEventLength?: number;
|
||||
}
|
||||
) => {
|
||||
return await prisma.eventType.create({
|
||||
@@ -65,10 +67,16 @@ const createTeamEventType = async (
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
create: {
|
||||
userId: user.id,
|
||||
isFixed: scenario?.schedulingType === SchedulingType.COLLECTIVE ? true : false,
|
||||
},
|
||||
},
|
||||
schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE,
|
||||
title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`,
|
||||
slug: scenario?.teamEventSlug ?? `${teamEventSlug}-team-id-${team.id}`,
|
||||
length: 30,
|
||||
length: scenario?.teamEventLength ?? 30,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -78,12 +86,14 @@ const createTeamAndAddUser = async (
|
||||
user,
|
||||
isUnpublished,
|
||||
isOrg,
|
||||
isOrgVerified,
|
||||
hasSubteam,
|
||||
organizationId,
|
||||
}: {
|
||||
user: { id: number; username: string | null; role?: MembershipRole };
|
||||
user: { id: number; email: string; username: string | null; role?: MembershipRole };
|
||||
isUnpublished?: boolean;
|
||||
isOrg?: boolean;
|
||||
isOrgVerified?: boolean;
|
||||
hasSubteam?: true;
|
||||
organizationId?: number | null;
|
||||
},
|
||||
@@ -95,7 +105,14 @@ const createTeamAndAddUser = async (
|
||||
};
|
||||
data.metadata = {
|
||||
...(isUnpublished ? { requestedSlug: slug } : {}),
|
||||
...(isOrg ? { isOrganization: true } : {}),
|
||||
...(isOrg
|
||||
? {
|
||||
isOrganization: true,
|
||||
isOrganizationVerified: !!isOrgVerified,
|
||||
orgAutoAcceptEmail: user.email.split("@")[1],
|
||||
isOrganizationConfigured: false,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
data.slug = !isUnpublished ? slug : undefined;
|
||||
if (isOrg && hasSubteam) {
|
||||
@@ -135,7 +152,9 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||
schedulingType?: SchedulingType;
|
||||
teamEventTitle?: string;
|
||||
teamEventSlug?: string;
|
||||
teamEventLength?: number;
|
||||
isOrg?: boolean;
|
||||
isOrgVerified?: boolean;
|
||||
hasSubteam?: true;
|
||||
isUnpublished?: true;
|
||||
} = {}
|
||||
@@ -283,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||
if (scenario.hasTeam) {
|
||||
const team = await createTeamAndAddUser(
|
||||
{
|
||||
user: { id: user.id, username: user.username, role: "OWNER" },
|
||||
user: { id: user.id, email: user.email, username: user.username, role: "OWNER" },
|
||||
isUnpublished: scenario.isUnpublished,
|
||||
isOrg: scenario.isOrg,
|
||||
isOrgVerified: scenario.isOrgVerified,
|
||||
hasSubteam: scenario.hasSubteam,
|
||||
organizationId: opts?.organizationId,
|
||||
},
|
||||
@@ -376,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||
await prisma.user.delete({ where: { id } });
|
||||
store.users = store.users.filter((b) => b.id !== id);
|
||||
},
|
||||
set: async (email: string) => {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { email },
|
||||
include: userIncludes,
|
||||
});
|
||||
const userFixture = createUserFixture(user, store.page);
|
||||
store.users.push(userFixture);
|
||||
return userFixture;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -400,7 +429,11 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
eventTypes: user.eventTypes,
|
||||
routingForms: user.routingForms,
|
||||
self,
|
||||
apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page),
|
||||
apiLogin: async (password?: string) =>
|
||||
apiLogin({ ...(await self()), password: password || user.username }, store.page),
|
||||
/**
|
||||
* @deprecated use apiLogin instead
|
||||
*/
|
||||
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
||||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
@@ -489,6 +522,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
// ignores adding the worker-index after username
|
||||
useExactUsername?: boolean;
|
||||
roleInOrganization?: MembershipRole;
|
||||
schedule?: Schedule;
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
@@ -520,7 +554,7 @@ const createUser = (
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
availability: {
|
||||
createMany: {
|
||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
||||
data: getAvailabilityFromSchedule(opts?.schedule ?? DEFAULT_SCHEDULE),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -641,7 +675,7 @@ export async function apiLogin(
|
||||
|
||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||
await page.locator("div > .ml-auto").first().click();
|
||||
await page.locator("[data-testid='app-switch']").first().click();
|
||||
await page.getByPlaceholder("Price").fill("100");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type Prisma from "@prisma/client";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
@@ -34,6 +38,95 @@ test.describe("Stripe integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("when enabling Stripe, credentialId is included", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
await page.goto("/apps/installed");
|
||||
|
||||
await user.getPaymentCredential();
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||
await user.setupEventWithPrice(eventType);
|
||||
|
||||
// Need to wait for the DB to be updated with the metadata
|
||||
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||
|
||||
// Check event type metadata to see if credentialId is included
|
||||
const eventTypeMetadata = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
|
||||
|
||||
const stripeAppMetadata = metadata?.apps?.stripe;
|
||||
|
||||
expect(stripeAppMetadata).toHaveProperty("credentialId");
|
||||
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
|
||||
});
|
||||
|
||||
test("when enabling Stripe, team credentialId is included", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto("/apps/stripe");
|
||||
|
||||
/** We start the Stripe flow */
|
||||
await Promise.all([
|
||||
page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"),
|
||||
page.click('[data-testid="install-app-button"]'),
|
||||
page.click('[data-testid="anything else"]'),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL("/apps/installed/payment?hl=stripe"),
|
||||
/** We skip filling Stripe forms (testing mode only) */
|
||||
page.click('[id="skip-account-app"]'),
|
||||
]);
|
||||
|
||||
await owner.setupEventWithPrice(teamEvent);
|
||||
|
||||
// Need to wait for the DB to be updated with the metadata
|
||||
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||
|
||||
// Check event type metadata to see if credentialId is included
|
||||
const eventTypeMetadata = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: teamEvent.id,
|
||||
},
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
|
||||
|
||||
const stripeAppMetadata = metadata?.apps?.stripe;
|
||||
|
||||
expect(stripeAppMetadata).toHaveProperty("credentialId");
|
||||
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
|
||||
});
|
||||
|
||||
test("Can book a paid booking", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||
@@ -174,7 +267,7 @@ test.describe("Stripe integration", () => {
|
||||
await page.getByTestId("price-input-stripe").fill("200");
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("stripe-currency-select").click();
|
||||
await page.locator("#react-select-2-input").fill("mexi");
|
||||
await page.locator("#react-select-2-option-81").click();
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { API } from "mailhog";
|
||||
import mailhog from "mailhog";
|
||||
|
||||
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createClipboardFixture } from "../fixtures/clipboard";
|
||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||
import { createOrgsFixture } from "../fixtures/orgs";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
@@ -28,6 +30,7 @@ export interface Fixtures {
|
||||
emails?: API;
|
||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
||||
clipboard: ReturnType<typeof createClipboardFixture>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -85,6 +88,8 @@ export const test = base.extend<Fixtures>({
|
||||
const mailhogAPI = mailhog();
|
||||
await use(mailhogAPI);
|
||||
} else {
|
||||
//FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it
|
||||
logger.warn("Mailhog is not enabled - Skipping Emails verification");
|
||||
await use(undefined);
|
||||
}
|
||||
},
|
||||
@@ -92,4 +97,8 @@ export const test = base.extend<Fixtures>({
|
||||
const bookingPage = createBookingPageFixture(page);
|
||||
await use(bookingPage);
|
||||
},
|
||||
clipboard: async ({ page }, use) => {
|
||||
const clipboard = createClipboardFixture(page);
|
||||
await use(clipboard);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from "./fixtures";
|
||||
|
||||
export type RouteVariant = "future" | "legacy";
|
||||
|
||||
const routeVariants = ["future", "legacy"];
|
||||
|
||||
/**
|
||||
* Small wrapper around test.describe().
|
||||
* When using testbothFutureLegacyRoutes.describe() instead of test.describe(), this will run the specified
|
||||
* tests twice. One with the pages route, and one with the new app dir "future" route. It will also add the route variant
|
||||
* name to the test name for easier debugging.
|
||||
* Finally it also adds a parameter routeVariant to your testBothFutureAndLegacyRoutes.describe() callback, which
|
||||
* can be used to do any conditional rendering in the test for a specific route variant (should be as little
|
||||
* as possible).
|
||||
*
|
||||
* See apps/web/playwright/event-types.e2e.ts for an example.
|
||||
*/
|
||||
export const testBothFutureAndLegacyRoutes = {
|
||||
describe: (testName: string, testFn: (routeVariant: RouteVariant) => void) => {
|
||||
routeVariants.forEach((routeVariant) => {
|
||||
test.describe(`${testName} -- ${routeVariant}`, () => {
|
||||
if (routeVariant === "future") {
|
||||
test.beforeEach(({ context }) => {
|
||||
context.addCookies([
|
||||
{ name: "x-calcom-future-routes-override", value: "1", url: "http://localhost:3000" },
|
||||
]);
|
||||
});
|
||||
}
|
||||
testFn(routeVariant as RouteVariant);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Frame, Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { createHash } from "crypto";
|
||||
import EventEmitter from "events";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import { createServer } from "http";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import type { API, Messages } from "mailhog";
|
||||
import { totp } from "otplib";
|
||||
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees(
|
||||
});
|
||||
return { user, eventType, booking };
|
||||
}
|
||||
|
||||
export function generateTotpCode(email: string) {
|
||||
const secret = createHash("md5")
|
||||
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
|
||||
.digest("hex");
|
||||
|
||||
totp.options = { step: 90 };
|
||||
return totp.generate(secret);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ test.describe("2FA Tests", async () => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const userPassword = user.username!;
|
||||
await user.login();
|
||||
await user.apiLogin();
|
||||
|
||||
// expects the home page for an authorized user
|
||||
await page.goto("/settings/security/two-factor-auth");
|
||||
@@ -94,7 +94,7 @@ test.describe("2FA Tests", async () => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const userPassword = user.username!;
|
||||
await user.login();
|
||||
await user.apiLogin();
|
||||
|
||||
// expects the home page for an authorized user
|
||||
await page.goto("/settings/security/two-factor-auth");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
|
||||
import { localize } from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
@@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => {
|
||||
await page.goto("/event-types");
|
||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||
await page.getByTestId("vertical-tab-assignment").click();
|
||||
await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click();
|
||||
await page.getByTestId("assignment-dropdown").click();
|
||||
|
||||
await page.getByTestId(`select-option-${memberUser.id}`).click();
|
||||
await page.locator('[type="submit"]').click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
});
|
||||
|
||||
await adminUser.logout();
|
||||
await test.step("Managed event type can use Organizer's default app as location", async () => {
|
||||
await page.getByTestId("vertical-tab-event_setup_tab_title").click();
|
||||
|
||||
await page.locator("#location-select").click();
|
||||
const optionText = (await localize("en"))("organizer_default_conferencing_app");
|
||||
await page.locator(`text=${optionText}`).click();
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByTestId("vertical-tab-assignment").click();
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Managed event type has locked fields for added member", async () => {
|
||||
await adminUser.logout();
|
||||
|
||||
// Coming back as member user to see if there is a managed event present after assignment
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
@@ -91,3 +113,9 @@ test.describe("Managed Event Types tests", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function gotoBookingPage(page: Page) {
|
||||
const previewLink = await page.getByTestId("preview-button").getAttribute("href");
|
||||
|
||||
await page.goto(previewLink ?? "");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { API, Messages } from "mailhog";
|
||||
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
|
||||
export async function expectInvitationEmailToBeReceived(
|
||||
page: Page,
|
||||
emails: API | undefined,
|
||||
userEmail: string,
|
||||
subject: string,
|
||||
returnLink?: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
// We need to wait for the email to go through, otherwise it will fail
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(5000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
const [firstReceivedEmail] = (receivedEmails as Messages).items;
|
||||
expect(firstReceivedEmail.subject).toBe(subject);
|
||||
if (!returnLink) return;
|
||||
const dom = new JSDOM(firstReceivedEmail.html);
|
||||
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
|
||||
return anchor?.getAttribute("href");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
import { generateTotpCode } from "../lib/testUtils";
|
||||
import { expectInvitationEmailToBeReceived } from "./expects";
|
||||
|
||||
test.afterAll(({ users, emails }) => {
|
||||
users.deleteAll();
|
||||
emails?.deleteAll();
|
||||
});
|
||||
|
||||
function capitalize(text: string) {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("should be able to create an organization and complete onboarding", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const orgOwner = await users.create();
|
||||
const orgDomain = `${orgOwner.username}-org`;
|
||||
const orgName = capitalize(`${orgOwner.username}-org`);
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.locator(".text-red-700")).toHaveCount(3);
|
||||
|
||||
// Happy path
|
||||
await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`);
|
||||
expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName);
|
||||
expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain);
|
||||
await page.locator("button[type=submit]").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check admin email about code verification
|
||||
await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
`john@${orgOwner.username}-org.com`,
|
||||
"Verify your email to create an organization"
|
||||
);
|
||||
|
||||
await test.step("Verification", async () => {
|
||||
// Code verification
|
||||
await expect(page.locator("#modal-title")).toBeVisible();
|
||||
await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`));
|
||||
await page.locator("button:text('Verify')").click();
|
||||
|
||||
// Check admin email about DNS pending action
|
||||
await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
"admin@example.com",
|
||||
"New organization created: pending action"
|
||||
);
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/set-password");
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Admin password", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.locator(".text-red-700")).toHaveCount(3); // 3 password hints
|
||||
|
||||
// Happy path
|
||||
await page.locator("input[name='password']").fill("ADMIN_user2023$");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/about");
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.locator('button:text("Upload")').click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.locator('button:text("Save")').click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-admins");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
// Required field
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Happy path
|
||||
await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`);
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Check if invited admin received the invitation email
|
||||
await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
`rick@${orgDomain}.com`,
|
||||
`${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
);
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Initial state
|
||||
await expect(page.locator('input[name="teams.0.name"]')).toHaveCount(1);
|
||||
await expect(page.locator('button:text("Continue")')).toBeDisabled();
|
||||
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
await expect(page.locator('button:text("Continue")')).toBeEnabled();
|
||||
|
||||
// Adding another team
|
||||
await page.locator('button:text("Add a team")').click();
|
||||
await expect(page.locator('button:text("Continue")')).toBeDisabled();
|
||||
await expect(page.locator('input[name="teams.1.name"]')).toHaveCount(1);
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
await expect(page.locator('button:text("Continue")')).toBeEnabled();
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.locator('button:text("Continue")').click();
|
||||
await page.waitForURL("/event-types");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
import { expectInvitationEmailToBeReceived } from "./expects";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(async ({ users, emails, clipboard }) => {
|
||||
clipboard.reset();
|
||||
await users.deleteAll();
|
||||
emails?.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
|
||||
const { team: org } = await orgOwner.getOrg();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("To the organization by email (external user)", async () => {
|
||||
const invitedUserEmail = `rick@domain-${Date.now()}.com`;
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator('button:text("Send invite")').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
// Check newly invited member exists and is pending
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(1);
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (!inviteLink) return null;
|
||||
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const newPage = await context.newPage();
|
||||
newPage.goto(inviteLink);
|
||||
await newPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check required fields
|
||||
await newPage.locator("button[type=submit]").click();
|
||||
await expect(newPage.locator(".text-red-700")).toHaveCount(3); // 3 password hints
|
||||
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await newPage.locator("button[type=submit]").click();
|
||||
await newPage.waitForURL("/getting-started?from=signup");
|
||||
await context.close();
|
||||
await newPage.close();
|
||||
|
||||
// Check newly invited member is not pending anymore
|
||||
await page.bringToFront();
|
||||
await page.goto("/settings/organizations/members");
|
||||
page.locator(`[data-testid="login-form"]`);
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("To the organization by invite link", async () => {
|
||||
// Get the invite link
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
|
||||
const inviteLink = await clipboard.get();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const inviteLinkPage = await context.newPage();
|
||||
await inviteLinkPage.goto(inviteLink);
|
||||
await inviteLinkPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check required fields
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints
|
||||
|
||||
// Happy path
|
||||
await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`);
|
||||
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await inviteLinkPage.waitForURL("/getting-started");
|
||||
});
|
||||
});
|
||||
|
||||
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
|
||||
const { team: org } = await orgOwner.getOrg();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("To the organization by email (internal user)", async () => {
|
||||
const invitedUserEmail = `rick@example.com`;
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator('button:text("Send invite")').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`
|
||||
);
|
||||
|
||||
// Check newly invited member exists and is pending
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ test.describe("Payment app", () => {
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("stripe-currency-select").click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
|
||||
await page.getByTestId("price-input-stripe").click();
|
||||
@@ -123,10 +123,10 @@ test.describe("Payment app", () => {
|
||||
await page.getByPlaceholder("Price").click();
|
||||
await page.getByPlaceholder("Price").fill("150");
|
||||
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("paypal-currency-select").click();
|
||||
await page.locator("#react-select-2-option-13").click();
|
||||
|
||||
await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("paypal-payment-option-select").click();
|
||||
|
||||
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe("UploadAvatar", async () => {
|
||||
test("can upload an image", async ({ page, users }) => {
|
||||
const user = await users.create({});
|
||||
await user.apiLogin();
|
||||
|
||||
await test.step("Can upload an initial picture", async () => {
|
||||
await page.goto("/settings/my-account/profile");
|
||||
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
// It is important to call waitForEvent before click to set up waiting.
|
||||
page.waitForEvent("filechooser"),
|
||||
// Opens the file chooser.
|
||||
page.getByTestId("open-upload-image-filechooser").click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`);
|
||||
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
await page.locator("input[name='name']").fill(user.email);
|
||||
|
||||
await page.getByText("Update").click();
|
||||
await page.waitForSelector("text=Settings updated successfully");
|
||||
|
||||
const response = await prisma.avatar.findUniqueOrThrow({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
userId: user.id,
|
||||
teamId: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// todo: remove this; ideally the organization-avatar is updated the moment
|
||||
// 'Settings updated succesfully' is saved.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey);
|
||||
|
||||
const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
await expect(urlResponse?.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { API, Messages } from "mailhog";
|
||||
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
|
||||
export async function expectInvitationEmailToBeReceived(
|
||||
page: Page,
|
||||
emails: API | undefined,
|
||||
userEmail: string,
|
||||
subject: string,
|
||||
returnLink?: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(10000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
|
||||
const [firstReceivedEmail] = (receivedEmails as Messages).items;
|
||||
|
||||
expect(firstReceivedEmail.subject).toBe(subject);
|
||||
if (!returnLink) return;
|
||||
const dom = new JSDOM(firstReceivedEmail.html);
|
||||
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
|
||||
return anchor?.getAttribute("href");
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
import { localize } from "../lib/testUtils";
|
||||
import { expectInvitationEmailToBeReceived } from "./expects";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(async ({ users, emails, clipboard }) => {
|
||||
clipboard.reset();
|
||||
await users.deleteAll();
|
||||
emails?.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Team", () => {
|
||||
test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => {
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create(undefined, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeam();
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("To the team by email (external user)", async () => {
|
||||
const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`;
|
||||
await page.locator(`button:text("${t("add")}")`).click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator(`button:text("${t("send_invite")}")`).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${team.name}'s admin invited you to join the team ${team.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
//Check newly invited member exists and is pending
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(1);
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (!inviteLink) return null;
|
||||
|
||||
// Follow invite link to new window
|
||||
const context = await browser.newContext();
|
||||
const newPage = await context.newPage();
|
||||
await newPage.goto(inviteLink);
|
||||
await newPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check required fields
|
||||
await newPage.locator("button[type=submit]").click();
|
||||
await expect(newPage.locator('[data-testid="hint-error"]')).toHaveCount(3);
|
||||
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await newPage.locator("button[type=submit]").click();
|
||||
await newPage.waitForURL("/getting-started?from=signup");
|
||||
await newPage.close();
|
||||
await context.close();
|
||||
|
||||
// Check newly invited member is not pending anymore
|
||||
await page.bringToFront();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("To the team by invite link", async () => {
|
||||
const user = await users.create({
|
||||
email: `user-invite-${Date.now()}@domain.com`,
|
||||
password: "P4ssw0rd!",
|
||||
});
|
||||
await page.locator(`button:text("${t("add")}")`).click();
|
||||
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
|
||||
const inviteLink = await clipboard.get();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const context = await browser.newContext();
|
||||
const inviteLinkPage = await context.newPage();
|
||||
await inviteLinkPage.goto(inviteLink);
|
||||
await inviteLinkPage.waitForLoadState("domcontentloaded");
|
||||
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await expect(inviteLinkPage.locator('[data-testid="field-error"]')).toHaveCount(2);
|
||||
|
||||
await inviteLinkPage.locator("input[name=email]").fill(user.email);
|
||||
await inviteLinkPage.locator("input[name=password]").fill(user.username || "P4ssw0rd!");
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
|
||||
await inviteLinkPage.waitForURL(`${WEBAPP_URL}/teams**`);
|
||||
});
|
||||
});
|
||||
|
||||
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeam();
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("To the organization by email (internal user)", async () => {
|
||||
const invitedUserEmail = `rick@example.com`;
|
||||
await page.locator(`button:text("${t("add")}")`).click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator(`button:text("${t("send_invite")}")`).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${teamOwner.name} invited you to join the team ${team.name} on Cal.com`
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -134,7 +134,7 @@ test.describe("Teams - NonOrg", () => {
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
@@ -351,7 +351,7 @@ test.describe("Teams - Org", () => {
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
||||
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
|
||||
await expect(page.locator("text=This page could not be found")).toBeVisible();
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
@@ -370,7 +370,7 @@ test.describe("Teams - Org", () => {
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
|
||||
// All the teammates should be in the booking
|
||||
for (const teammate of teamMatesObj) {
|
||||
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
|
||||
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -412,7 +412,7 @@ test.describe("Teams - Org", () => {
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,12 +103,11 @@ test.describe("BOOKING_CREATED", async () => {
|
||||
body.payload.videoCallData = dynamic;
|
||||
body.payload.appsStatus = dynamic;
|
||||
body.payload.metadata.videoCallUrl = dynamic;
|
||||
|
||||
expect(body).toMatchObject({
|
||||
triggerEvent: "BOOKING_CREATED",
|
||||
createdAt: "[redacted/dynamic]",
|
||||
payload: {
|
||||
type: "30 min",
|
||||
type: "30-min",
|
||||
title: "30 min between Nameless and Test Testson",
|
||||
description: "",
|
||||
additionalNotes: "",
|
||||
@@ -236,7 +235,7 @@ test.describe("BOOKING_REJECTED", async () => {
|
||||
triggerEvent: "BOOKING_REJECTED",
|
||||
createdAt: "[redacted/dynamic]",
|
||||
payload: {
|
||||
type: "Opt in",
|
||||
type: "opt-in",
|
||||
title: "Opt in between Nameless and Test Testson",
|
||||
customInputs: {},
|
||||
startTime: "[redacted/dynamic]",
|
||||
@@ -357,7 +356,7 @@ test.describe("BOOKING_REQUESTED", async () => {
|
||||
triggerEvent: "BOOKING_REQUESTED",
|
||||
createdAt: "[redacted/dynamic]",
|
||||
payload: {
|
||||
type: "Opt in",
|
||||
type: "opt-in",
|
||||
title: "Opt in between Nameless and Test Testson",
|
||||
customInputs: {},
|
||||
startTime: "[redacted/dynamic]",
|
||||
|
||||
@@ -17,7 +17,7 @@ test.describe("Workflow tests", () => {
|
||||
async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const [eventType] = user.eventTypes;
|
||||
await user.login();
|
||||
await user.apiLogin();
|
||||
await page.goto(`/workflows`);
|
||||
|
||||
await page.click('[data-testid="create-button"]');
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
|
||||
"availability_settings": "إعدادات التوافرية",
|
||||
"continue_without_calendar": "المتابعة من دون تقويم",
|
||||
"continue_with": "الاستمرار مع {{appName}}",
|
||||
"connect_your_calendar": "ربط التقويم لديك",
|
||||
"connect_your_video_app": "اربط تطبيقات الفيديو لديك",
|
||||
"connect_your_video_app_instructions": "قم بتوصيل تطبيقات الفيديو لديك لاستخدامها في أنواع الأحداث الخاصة بك.",
|
||||
@@ -288,6 +289,8 @@
|
||||
"when": "متى",
|
||||
"where": "أين",
|
||||
"add_to_calendar": "إضافة إلى التقويم",
|
||||
"add_to_calendar_description": "اختر أين ستُضاف الأحداث الجديدة عندما يتم الحجز لديك.",
|
||||
"add_events_to": "إضافة أحداث إلى",
|
||||
"add_another_calendar": "إضافة تقويم آخر",
|
||||
"other": "آخر",
|
||||
"email_sign_in_subject": "رابط تسجيل الدخول بك لـ{{appName}}",
|
||||
@@ -422,6 +425,7 @@
|
||||
"booking_created": "تم إنشاء الحجز",
|
||||
"booking_rejected": "تم رفض الحجز",
|
||||
"booking_requested": "تم طلب الحجز",
|
||||
"booking_payment_initiated": "بدء الدفع للحجز",
|
||||
"meeting_ended": "انتهى الاجتماع",
|
||||
"form_submitted": "تم إرسال النموذج",
|
||||
"booking_paid": "تم الدفع لقاء الحجز",
|
||||
@@ -456,6 +460,7 @@
|
||||
"no_event_types_have_been_setup": "لم يقم هذا المستخدم بإعداد أي أنواع للحدث حتى الآن.",
|
||||
"edit_logo": "تعديل الشعار",
|
||||
"upload_a_logo": "تحميل شعار",
|
||||
"upload_logo": "تحميل شعار",
|
||||
"remove_logo": "إزالة الشعار",
|
||||
"enable": "تمكين",
|
||||
"code": "الرمز",
|
||||
@@ -568,6 +573,7 @@
|
||||
"your_team_name": "اسم فريقك",
|
||||
"team_updated_successfully": "تم تحديث الفريق بنجاح",
|
||||
"your_team_updated_successfully": "تم تحديث فريقك بنجاح.",
|
||||
"your_org_updated_successfully": "تم تحديث منظمتك بنجاح.",
|
||||
"about": "حول",
|
||||
"team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.",
|
||||
"org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.",
|
||||
@@ -599,6 +605,7 @@
|
||||
"hide_book_a_team_member": "إخفاء الزر \"حجز عضو فريق\"",
|
||||
"hide_book_a_team_member_description": "إخفاء الزر \"حجز عضو فريق\" من صفحاتك العامة.",
|
||||
"danger_zone": "منطقة خطر",
|
||||
"account_deletion_cannot_be_undone": "احترس. لا يمكن التراجع عن حذف الحساب.",
|
||||
"back": "عودة",
|
||||
"cancel": "إلغاء",
|
||||
"cancel_all_remaining": "إلغاء كل ما تبقى",
|
||||
@@ -688,6 +695,7 @@
|
||||
"people": "الأشخاص",
|
||||
"your_email": "بريدك الإلكتروني",
|
||||
"change_avatar": "تغيير الصورة الرمزية",
|
||||
"upload_avatar": "تحميل صورة رمزية",
|
||||
"language": "اللغة",
|
||||
"timezone": "المنطقة الزمنية",
|
||||
"first_day_of_week": "أول يوم في الأسبوع",
|
||||
@@ -778,6 +786,7 @@
|
||||
"disable_guests": "تعطيل خاصية الضيوف",
|
||||
"disable_guests_description": "قم بتعطيل إضافة مزيد من الضيوف أثناء الحجز.",
|
||||
"private_link": "إنشاء رابط خاص",
|
||||
"enable_private_url": "تمكين عنوان URL خاص",
|
||||
"private_link_label": "رابط خاص",
|
||||
"private_link_hint": "سيتم إعادة توليد الرابط الخاص بك بعد كل استخدام",
|
||||
"copy_private_link": "نسخ الرابط الخاص",
|
||||
@@ -840,6 +849,7 @@
|
||||
"next_step": "تخطي الخطوة",
|
||||
"prev_step": "الخطوة السابقة",
|
||||
"install": "تثبيت",
|
||||
"install_paid_app": "اشتراك",
|
||||
"installed": "تم التثبيت",
|
||||
"active_install_one": "{{count}} تثبيت نشط",
|
||||
"active_install_other": "{{count}} تثبيت نشط",
|
||||
@@ -1088,6 +1098,7 @@
|
||||
"developer_documentation": "مستندات المطور",
|
||||
"get_in_touch": "تواصل معنا",
|
||||
"contact_support": "الاتصال بالدعم",
|
||||
"community_support": "الدعم المجتمعي",
|
||||
"feedback": "الملاحظات",
|
||||
"submitted_feedback": "نشكرك على ملاحظاتك!",
|
||||
"feedback_error": "حدث خطأ عند إرسال الملاحظات",
|
||||
@@ -1213,6 +1224,7 @@
|
||||
"organizer_name_variable": "اسم المنظم",
|
||||
"app_upgrade_description": "لاستخدام هذه الميزة، تحتاج إلى الترقية إلى حساب Pro.",
|
||||
"invalid_number": "رقم الهاتف غير صالح",
|
||||
"invalid_url_error_message": "عنوان URL غير صالح من أجل {{label}}. عنوان URL للعينة: {{sampleUrl}}",
|
||||
"navigate": "تنقّل",
|
||||
"open": "فتح",
|
||||
"close": "إغلاق",
|
||||
@@ -1276,6 +1288,7 @@
|
||||
"personal_cal_url": "عنوان {{appName}} URL الخاص بي",
|
||||
"bio_hint": "اكتب بضع جمل عن نفسك، والتي ستظهر على صفحة عنوان Url الشخصية لديك.",
|
||||
"user_has_no_bio": "لم يُضف هذا المستخدم لمحة عنه بعد.",
|
||||
"bio": "سيرة ذاتية",
|
||||
"delete_account_modal_title": "حذف الحساب",
|
||||
"confirm_delete_account_modal": "هل أنت متأكد من أنك تريد حذف حساب {{appName}} الخاص بك؟",
|
||||
"delete_my_account": "حذف حسابي",
|
||||
@@ -1286,6 +1299,7 @@
|
||||
"select_calendars": "حدد الرزنامات التي تريد التحقق منها بحثًا عن تضاربات لمنع الحجوزات المزدوجة.",
|
||||
"check_for_conflicts": "التحقق من وجود تضاربات",
|
||||
"view_recordings": "عرض التسجيلات",
|
||||
"check_for_recordings": "تحقق من وجود تسجيلات",
|
||||
"adding_events_to": "إضافة أحداث إلى",
|
||||
"follow_system_preferences": "اتبع تفضيلات النظام",
|
||||
"custom_brand_colors": "ألوان العلامة التجارية المخصصة",
|
||||
@@ -1530,6 +1544,7 @@
|
||||
"problem_registering_domain": "حدثت مشكلة في تسجيل المجال الفرعي، يُرجى المحاولة لاحقاً أو الاتصال بالمشرف",
|
||||
"team_publish": "نشر فريق",
|
||||
"number_text_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
|
||||
"number_sms_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
|
||||
"attendee_email_variable": "اسم الحاضر",
|
||||
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
|
||||
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
|
||||
@@ -1594,6 +1609,7 @@
|
||||
"options": "خيارات",
|
||||
"enter_option": "أدخل خيار {{index}}",
|
||||
"add_an_option": "إضافة خيار",
|
||||
"location_already_exists": "هذا الموقع موجود بالفعل. الرجاء اختيار موقع جديد",
|
||||
"radio": "الراديو",
|
||||
"google_meet_warning": "من أجل استخدام Google Meet، يجب عليك تعيين تقويم وجهتك على تقويم Google",
|
||||
"individual": "فرد",
|
||||
@@ -1613,6 +1629,7 @@
|
||||
"date_overrides_mark_all_day_unavailable_other": "وضع علامة غير متاح على التواريخ المحددة",
|
||||
"date_overrides_add_btn": "إضافة تجاوز",
|
||||
"date_overrides_update_btn": "تحديث التجاوز",
|
||||
"date_successfully_added": "تم إضافة تخطّي التاريخ بنجاح",
|
||||
"event_type_duplicate_copy_text": "{{slug}}-نسخ",
|
||||
"set_as_default": "تعيين كافتراضي",
|
||||
"hide_eventtype_details": "إخفاء تفاصيل نوع الحدث",
|
||||
@@ -1639,6 +1656,7 @@
|
||||
"minimum_round_robin_hosts_count": "عدد المضيفين المطلوب حضورهم",
|
||||
"hosts": "المضيفون",
|
||||
"upgrade_to_enable_feature": "تحتاج إلى إنشاء فريق لتمكين هذه الميزة. انقر لإنشاء فريق.",
|
||||
"orgs_upgrade_to_enable_feature": "تحتاج إلى الترقية إلى خطة enterprise لتفعيل هذه الميزة.",
|
||||
"new_attendee": "حضور جديد",
|
||||
"awaiting_approval": "في انتظار الموافقة",
|
||||
"requires_google_calendar": "يتطلب هذا التطبيق اتصال تقويم Google",
|
||||
@@ -1743,6 +1761,7 @@
|
||||
"show_on_booking_page": "إظهار في صفحة الحجز",
|
||||
"get_started_zapier_templates": "البدء في استخدام قوالب Zapier",
|
||||
"team_is_unpublished": "لم يُنشر {{team}}",
|
||||
"org_is_unpublished_description": "رابط هذه المنظمة غير متاح حاليًا. يرجى الاتصال بمالك المنظمة أو طلب النشر منه.",
|
||||
"team_is_unpublished_description": "رابط هذا {{entity}} غير متاح حاليًا. يرجى الاتصال بمالك {{entity}} أو طلب نشره منه.",
|
||||
"team_member": "عضو الفريق",
|
||||
"a_routing_form": "نموذج توجيه",
|
||||
@@ -1877,6 +1896,7 @@
|
||||
"edit_invite_link": "تعديل إعدادات الرابط",
|
||||
"invite_link_copied": "تم نسخ رابط الدعوة",
|
||||
"invite_link_deleted": "تم حذف رابط الدعوة",
|
||||
"api_key_deleted": "تم حذف مفتاح API",
|
||||
"invite_link_updated": "تم حفظ إعدادات رابط الدعوة",
|
||||
"link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...",
|
||||
"one_day": "1 يوم",
|
||||
@@ -2009,7 +2029,13 @@
|
||||
"attendee_last_name_variable": "اسم العائلة للحاضر",
|
||||
"attendee_first_name_info": "الاسم الأول للشخص صاحب الحجز",
|
||||
"attendee_last_name_info": "اسم العائلة للشخص صاحب الحجز",
|
||||
"your_monthly_digest": "موجزك الشهري",
|
||||
"member_name": "اسم العضو",
|
||||
"most_popular_events": "الأحداث الأكثر شعبية",
|
||||
"summary_of_events_for_your_team_for_the_last_30_days": "إليك ملخصك للأحداث الشعبية لفريقك {{teamName}} خلال آخر 30 يومًا",
|
||||
"me": "أنا",
|
||||
"monthly_digest_email": "الرسالة الإلكترونية للموجز الشهري",
|
||||
"monthly_digest_email_for_teams": "رسالة إلكترونية بالموجز الشهري للفرق",
|
||||
"verify_team_tooltip": "تأكيد فريقك لتمكين إرسال الرسائل إلى الحاضرين",
|
||||
"member_removed": "تمت إزالة العضو",
|
||||
"my_availability": "أوقاتي المتاحة",
|
||||
@@ -2039,12 +2065,41 @@
|
||||
"team_no_event_types": "ليس لدى هذا الفريق أنواع من الفعاليات",
|
||||
"seat_options_doesnt_multiple_durations": "لا يدعم خيار المقعد فترات متعددة",
|
||||
"include_calendar_event": "إدراج فعاليات في التقويم",
|
||||
"oAuth": "OAuth",
|
||||
"recently_added": "تمت الإضافة مؤخراً",
|
||||
"no_members_found": "لم يُعثر على أعضاء",
|
||||
"event_setup_length_error": "إعداد الفعالية: يجب أن تكون المدة لدقيقة على الأقل.",
|
||||
"availability_schedules": "جدولة التوافر",
|
||||
"unauthorized": "غير مصرح به",
|
||||
"access_cal_account": "يرغب {{clientName}} في الوصول إلى حسابك على {{appName}}",
|
||||
"select_account_team": "اختر حسابًا أو فريق",
|
||||
"allow_client_to": "سيمنح هذا {{clientName}} القدرة على",
|
||||
"associate_with_cal_account": "رافق نفسك بمعلوماتك الشخصية من {{clientName}}",
|
||||
"see_personal_info": "شاهد معلوماتك الشخصية، بما في ذلك أي معلومات شخصية جعلتها متاحة للجمهور",
|
||||
"see_primary_email_address": "طالع عنوان بريدك الإلكتروني الأساسي",
|
||||
"connect_installed_apps": "قم بالارتباط بتطبيقاتك المثبتة",
|
||||
"access_event_type": "اقرأ وعدّل واحذف أنواع الأحداث لديك",
|
||||
"access_availability": "اقرأ وعّدل واحذف توافرك",
|
||||
"access_bookings": "اقرأ وعدّل واحذف الحجوزات لديك",
|
||||
"allow_client_to_do": "السماح لـ {{clientName}} بالقيام بهذا؟",
|
||||
"oauth_access_information": "عبر نقرك على السماح، ستسمح لهذا التطبيق باستخدام معلوماتك وفقاً لشروط الخدمة وسياسة الخصوصية لديه. يمكنك إزالة هذا الوصول من متجر التطبيقات {{appName}}.",
|
||||
"allow": "السماح",
|
||||
"view_only_edit_availability_not_onboarded": "لم يكمل هذا المستخدم عملية الانضمام. لن تتمكن من تعيين توافره حتى يكتمل انضمامه.",
|
||||
"view_only_edit_availability": "أنت تشاهد توافر هذا المستخدم. يمكنك تعديل توافرك فقط.",
|
||||
"you_can_override_calendar_in_advanced_tab": "يمكنك تجاوز هذا لكل حدث على حدة في الإعدادات المتقدمة في كل نوع من أنواع الأحداث.",
|
||||
"edit_users_availability": "تعديل توافر المستخدم: {{username}}",
|
||||
"resend_invitation": "إعادة إرسال الدعوة",
|
||||
"invitation_resent": "تم إعادة إرسال الدعوة.",
|
||||
"add_client": "إضافة عميل",
|
||||
"copy_client_secret_info": "لن تتمكن من مطالعة السر بعد نسخه بعد الآن",
|
||||
"add_new_client": "إضافة عميل جديد",
|
||||
"this_app_is_not_setup_already": "لم يتم إعداد هذا التطبيق بعد",
|
||||
"as_csv": "كـ CSV",
|
||||
"overlay_my_calendar": "تركيب تقويمي",
|
||||
"overlay_my_calendar_toc": "من خلال الارتباط بتقويمك، أنت تقبل سياسة الخصوصية وشروط الاستخدام. يمكنك إلغاء الوصول في أي وقت.",
|
||||
"view_overlay_calendar_events": "طالع أحداث تقويمك لمنع التضارب بين الحجوزات.",
|
||||
"lock_timezone_toggle_on_booking_page": "قفل المنطقة الزمنية في صفحة الحجز",
|
||||
"description_lock_timezone_toggle_on_booking_page": "تقفل المنطقة الزمنية على صفحة الحجز، وهذا مفيد للأحداث وجهاً لوجه.",
|
||||
"extensive_whitelabeling": "عملية انضمام ودعم هندسي مخصصين",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"set_availability": "Nastavte, jak jste dostupní",
|
||||
"availability_settings": "Nastavení dostupnosti",
|
||||
"continue_without_calendar": "Pokračovat bez kalendáře",
|
||||
"continue_with": "Pokračovat přes {{appName}}",
|
||||
"connect_your_calendar": "Připojit svůj kalendář",
|
||||
"connect_your_video_app": "Propojte své video aplikace",
|
||||
"connect_your_video_app_instructions": "Propojte své video aplikace a používejte je ve svých typech událostí.",
|
||||
@@ -288,6 +289,8 @@
|
||||
"when": "Kdy",
|
||||
"where": "Kde",
|
||||
"add_to_calendar": "Přidat do kalendáře",
|
||||
"add_to_calendar_description": "Vyberte, kam se mají přidávat události, pokud jste zarezervováni.",
|
||||
"add_events_to": "Přidat události do",
|
||||
"add_another_calendar": "Přidat další kalendář",
|
||||
"other": "Ostatní",
|
||||
"email_sign_in_subject": "Váš přihlašovací odkaz pro {{appName}}",
|
||||
@@ -422,6 +425,7 @@
|
||||
"booking_created": "Rezervace vytvořena",
|
||||
"booking_rejected": "Rezervace byla zamítnuta",
|
||||
"booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán",
|
||||
"booking_payment_initiated": "Platba za rezervaci zahájena",
|
||||
"meeting_ended": "Schůzka skončila",
|
||||
"form_submitted": "Formulář byl odeslán",
|
||||
"booking_paid": "Rezervace uhrazena",
|
||||
@@ -456,6 +460,7 @@
|
||||
"no_event_types_have_been_setup": "Tento uživatel zatím nezaložil žádné typy událostí.",
|
||||
"edit_logo": "Upravit logo",
|
||||
"upload_a_logo": "Nahrát logo",
|
||||
"upload_logo": "Nahrát logo",
|
||||
"remove_logo": "Odstranit logo",
|
||||
"enable": "Povolit",
|
||||
"code": "Kód",
|
||||
@@ -568,6 +573,7 @@
|
||||
"your_team_name": "Název vašeho týmu",
|
||||
"team_updated_successfully": "Tým byl úspěšně aktualizován",
|
||||
"your_team_updated_successfully": "Váš tým byl úspěšně aktualizován.",
|
||||
"your_org_updated_successfully": "Vaše organizace byla aktualizována.",
|
||||
"about": "O aplikaci",
|
||||
"team_description": "Pár vět o vašem týmu. Objeví se na URL stránce vašeho týmu.",
|
||||
"org_description": "Několik vět o vaší organizaci. Zobrazí se na adrese URL vaší organizace.",
|
||||
@@ -599,6 +605,7 @@
|
||||
"hide_book_a_team_member": "Skrýt tlačítko Rezervovat člena týmu",
|
||||
"hide_book_a_team_member_description": "Skrýt tlačítko Rezervovat člena týmu na veřejných stránkách.",
|
||||
"danger_zone": "Nebezpečná zóna",
|
||||
"account_deletion_cannot_be_undone": "Pozor. Odstranění účtu nelze vrátit.",
|
||||
"back": "Zpět",
|
||||
"cancel": "Zrušit",
|
||||
"cancel_all_remaining": "Zrušit vše zbývající",
|
||||
@@ -688,6 +695,7 @@
|
||||
"people": "Lidé",
|
||||
"your_email": "Váš e-mail",
|
||||
"change_avatar": "Změnit avatar",
|
||||
"upload_avatar": "Nahrát avatar",
|
||||
"language": "Jazyk",
|
||||
"timezone": "Časová zóna",
|
||||
"first_day_of_week": "První den v týdnu",
|
||||
@@ -778,6 +786,7 @@
|
||||
"disable_guests": "Zakázat hosty",
|
||||
"disable_guests_description": "Zakázat přidávání dalších hostů během rezervace.",
|
||||
"private_link": "Vygenerovat soukromou adresu URL",
|
||||
"enable_private_url": "Zapnout soukromou adresu URL",
|
||||
"private_link_label": "Soukromý odkaz",
|
||||
"private_link_hint": "Váš soukromý odkaz se po každém použití obnoví",
|
||||
"copy_private_link": "Zkopírovat soukromý odkaz",
|
||||
@@ -840,6 +849,7 @@
|
||||
"next_step": "Přeskočit krok",
|
||||
"prev_step": "Předchozí krok",
|
||||
"install": "Nainstalovat",
|
||||
"install_paid_app": "Odebírat",
|
||||
"installed": "Nainstalováno",
|
||||
"active_install_one": "Aktivní instalace: {{count}}",
|
||||
"active_install_other": "Aktivní instalace: {{count}}",
|
||||
@@ -1088,6 +1098,7 @@
|
||||
"developer_documentation": "Dokumentace vývojáře",
|
||||
"get_in_touch": "Kontaktujte nás",
|
||||
"contact_support": "Kontaktujte podporu",
|
||||
"community_support": "Podpora komunity",
|
||||
"feedback": "Zpětná vazba",
|
||||
"submitted_feedback": "Děkujeme za vaši zpětnou vazbu!",
|
||||
"feedback_error": "Chyba při odesílání zpětné vazby",
|
||||
@@ -1213,6 +1224,7 @@
|
||||
"organizer_name_variable": "Jméno organizátora",
|
||||
"app_upgrade_description": "Pokud chcete použít tuto funkci, musíte provést aktualizaci na účet Pro.",
|
||||
"invalid_number": "Neplatné telefonní číslo",
|
||||
"invalid_url_error_message": "Neplatná adresa URL pro {{label}}. Příklad URL: {{sampleUrl}}",
|
||||
"navigate": "Navigace",
|
||||
"open": "Otevřít",
|
||||
"close": "Zavřít",
|
||||
@@ -1276,6 +1288,7 @@
|
||||
"personal_cal_url": "Moje osobní adresa URL {{appName}}",
|
||||
"bio_hint": "Několik vět o vás. Obsah se zobrazí se na vaší osobní stránce URL.",
|
||||
"user_has_no_bio": "Tento uživatel zatím nepřidal životopis.",
|
||||
"bio": "Bio",
|
||||
"delete_account_modal_title": "Odstranit účet",
|
||||
"confirm_delete_account_modal": "Opravdu chcete odstranit svůj účet {{appName}}?",
|
||||
"delete_my_account": "Odstranit můj účet",
|
||||
@@ -1286,6 +1299,7 @@
|
||||
"select_calendars": "Vyberte kalendáře, u kterých chcete kontrolovat konflikty v zájmu prevence dvojích rezervací.",
|
||||
"check_for_conflicts": "Zkontrolovat konflikty",
|
||||
"view_recordings": "Zobrazit nahrávky",
|
||||
"check_for_recordings": "Zkontrolovat nahrávky",
|
||||
"adding_events_to": "Přidání událostí do:",
|
||||
"follow_system_preferences": "Řídit se předvolbami systému",
|
||||
"custom_brand_colors": "Vlastní barvy značky",
|
||||
@@ -1530,6 +1544,7 @@
|
||||
"problem_registering_domain": "Při registraci subdomény došlo k problému, zkuste to prosím znovu nebo kontaktujte správce",
|
||||
"team_publish": "Zveřejnit tým",
|
||||
"number_text_notifications": "Telefonní číslo (SMS oznámení)",
|
||||
"number_sms_notifications": "Telefonní číslo (SMS oznámení)",
|
||||
"attendee_email_variable": "E-mail účastníka",
|
||||
"attendee_email_info": "E-mail osoby provádějící rezervaci",
|
||||
"kbar_search_placeholder": "Zadejte příkaz nebo vyhledejte...",
|
||||
@@ -1594,6 +1609,7 @@
|
||||
"options": "Možnosti",
|
||||
"enter_option": "Zadejte možnost {{index}}",
|
||||
"add_an_option": "Přidat možnost",
|
||||
"location_already_exists": "Toto místo již existuje. Vyberte prosím nové místo",
|
||||
"radio": "Přepínač",
|
||||
"google_meet_warning": "Abyste mohli používat službu Google Meet, musíte jako cílový kalendář nastavit Kalendář Google",
|
||||
"individual": "Jedinec",
|
||||
@@ -1613,6 +1629,7 @@
|
||||
"date_overrides_mark_all_day_unavailable_other": "Zadat nedostupnost ve vybraných datech",
|
||||
"date_overrides_add_btn": "Přidat změnu",
|
||||
"date_overrides_update_btn": "Aktualizovat změnu",
|
||||
"date_successfully_added": "Přidána změna dnů",
|
||||
"event_type_duplicate_copy_text": "{{slug}} – kopie",
|
||||
"set_as_default": "Nastavit jako výchozí",
|
||||
"hide_eventtype_details": "Skrýt podrobnosti o typu události",
|
||||
@@ -1639,6 +1656,7 @@
|
||||
"minimum_round_robin_hosts_count": "Počet hostitelů, kteří se musí zúčastnit",
|
||||
"hosts": "Hostitelé",
|
||||
"upgrade_to_enable_feature": "K povolení této funkce je třeba vytvořit tým. Tým vytvoříte kliknutím.",
|
||||
"orgs_upgrade_to_enable_feature": "Pokud chcete zapnout tuto funkci, musíte upgradovat na náš tarif Enterprise.",
|
||||
"new_attendee": "Nový účastník",
|
||||
"awaiting_approval": "Čeká na schválení",
|
||||
"requires_google_calendar": "Tato aplikace vyžaduje připojení ke Kalendáři Google",
|
||||
@@ -1743,6 +1761,7 @@
|
||||
"show_on_booking_page": "Zobrazit na stránce rezervace",
|
||||
"get_started_zapier_templates": "Začněte používat šablony Zapier",
|
||||
"team_is_unpublished": "Tým {{team}} není zveřejněn",
|
||||
"org_is_unpublished_description": "Tento odkaz organizace není v současné době k dispozici. Kontaktujte prosím vlastníka organizace nebo ho požádejte o jeho zveřejnění.",
|
||||
"team_is_unpublished_description": "Tento odkaz subjektu ({{entity}}) není v současné době k dispozici. Kontaktujte prosím vlastníka subjektu ({{entity}}) nebo ho požádejte o jeho zveřejnění.",
|
||||
"team_member": "Člen týmu",
|
||||
"a_routing_form": "Směrovací formulář",
|
||||
@@ -1877,6 +1896,7 @@
|
||||
"edit_invite_link": "Upravit nastavení odkazu",
|
||||
"invite_link_copied": "Odkaz pozvánky byl zkopírován",
|
||||
"invite_link_deleted": "Odkaz pozvánky byl odstraněn",
|
||||
"api_key_deleted": "Klíč API odstraněn",
|
||||
"invite_link_updated": "Nastavení odkazu pozvánky bylo uloženo",
|
||||
"link_expires_after": "Platnost odkazů vyprší za...",
|
||||
"one_day": "1 den",
|
||||
@@ -2009,7 +2029,13 @@
|
||||
"attendee_last_name_variable": "Příjmení účastníka",
|
||||
"attendee_first_name_info": "Jméno rezervující osoby",
|
||||
"attendee_last_name_info": "Příjmení rezervující osoby",
|
||||
"your_monthly_digest": "Váš měsíční přehled",
|
||||
"member_name": "Jméno člena",
|
||||
"most_popular_events": "Nejoblíbenější události",
|
||||
"summary_of_events_for_your_team_for_the_last_30_days": "Zde je přehled oblíbených událostí vašeho týmu {{teamName}} za posledních 30 dní",
|
||||
"me": "Já",
|
||||
"monthly_digest_email": "E-mail s měsíčním přehledem",
|
||||
"monthly_digest_email_for_teams": "Měsíční e-mail s přehledem pro týmy",
|
||||
"verify_team_tooltip": "Proveďte ověření svého týmu a zapněte odesílání zpráv účastníkům",
|
||||
"member_removed": "Člen odstraněn",
|
||||
"my_availability": "Moje dostupnost",
|
||||
@@ -2039,12 +2065,41 @@
|
||||
"team_no_event_types": "Tento tým nemá žádné typy událostí",
|
||||
"seat_options_doesnt_multiple_durations": "Volba místa nepodporuje více dob trvání",
|
||||
"include_calendar_event": "Zahrnout událost kalendáře",
|
||||
"oAuth": "OAuth",
|
||||
"recently_added": "Nedávno přidáno",
|
||||
"no_members_found": "Nenalezeni žádní členové",
|
||||
"event_setup_length_error": "Nastavení události: Doba trvání musí být alespoň 1 minuta.",
|
||||
"availability_schedules": "Plány dostupnosti",
|
||||
"unauthorized": "Neautorizováno",
|
||||
"access_cal_account": "{{clientName}} žádá o přístup k vašemu účtu {{appName}}",
|
||||
"select_account_team": "Vyberte účet nebo tým",
|
||||
"allow_client_to": "To umožní klientovi {{clientName}}",
|
||||
"associate_with_cal_account": "Přiřadit vám vaše osobní údaje z klienta {{clientName}}",
|
||||
"see_personal_info": "Zobrazit vaše osobní údaje, včetně všech osobních údajů, které jste zveřejnili",
|
||||
"see_primary_email_address": "Zobrazit vaši primární e-mailovou adresu",
|
||||
"connect_installed_apps": "Připojit se k vašim nainstalovaným aplikacím",
|
||||
"access_event_type": "Číst, upravovat a odstraňovat vaše typy událostí",
|
||||
"access_availability": "Číst, upravovat a odstraňovat vaši dostupnost",
|
||||
"access_bookings": "Číst, upravovat a odstraňovat rezervace",
|
||||
"allow_client_to_do": "Chcete povolit klientovi {{clientName}} provádět uvedené akce?",
|
||||
"oauth_access_information": "Kliknutím na tlačítko Povolit této aplikaci umožníte používat vaše údaje v souladu s podmínkami služby a zásadami ochrany osobních údajů. Přístup k aplikaci {{appName}} můžete odebrat přes App Store.",
|
||||
"allow": "Povolit",
|
||||
"view_only_edit_availability_not_onboarded": "Tento uživatel ještě nedokončil onboarding. Dokud nedokončí onboarding, dostupnost nebude možné nastavit.",
|
||||
"view_only_edit_availability": "Právě máte zobrazenou dostupnost tohoto uživatele. Upravovat lze pouze vlastní dostupnost.",
|
||||
"you_can_override_calendar_in_advanced_tab": "Tuto možnost můžete zrušit pro každou událost zvlášť v pokročilém nastavení jednotlivých typů událostí.",
|
||||
"edit_users_availability": "Upravte dostupnost uživatele: {{username}}",
|
||||
"resend_invitation": "Znovu odeslat pozvánku",
|
||||
"invitation_resent": "Pozvánka byla odeslána znovu.",
|
||||
"add_client": "Přidat klienta",
|
||||
"copy_client_secret_info": "Po zkopírování již nebude možné tajný klíč zobrazit",
|
||||
"add_new_client": "Přidat nového klienta",
|
||||
"this_app_is_not_setup_already": "Tato aplikace ještě nebyla nastavena",
|
||||
"as_csv": "jako CSV",
|
||||
"overlay_my_calendar": "Překryv mého kalendáře",
|
||||
"overlay_my_calendar_toc": "Připojením ke svému kalendáři přijímáte naše zásady ochrany osobních údajů a podmínky používání. Přístup můžete kdykoli odvolat.",
|
||||
"view_overlay_calendar_events": "Zobrazení událostí v kalendáři, aby se zabránilo kolidujícím rezervacím.",
|
||||
"lock_timezone_toggle_on_booking_page": "Uzamčení časového pásma na stránce rezervace",
|
||||
"description_lock_timezone_toggle_on_booking_page": "Uzamčení časového pásma na stránce rezervace (užitečné pro osobní události).",
|
||||
"extensive_whitelabeling": "Vyhrazená podpora zaškolovací a inženýrská podpora",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -717,6 +717,7 @@
|
||||
"next_step": "Spring trin over",
|
||||
"prev_step": "Forrige trin",
|
||||
"install": "Installér",
|
||||
"install_paid_app": "Abonnér",
|
||||
"installed": "Installeret",
|
||||
"active_install_one": "{{count}} aktiv installation",
|
||||
"active_install_other": "{{count}} aktive installationer",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"set_availability": "Verfügbarkeit festlegen",
|
||||
"availability_settings": "Verfügbarkeitseinstellungen",
|
||||
"continue_without_calendar": "Ohne Kalender fortfahren",
|
||||
"continue_with": "Mit {{appName}} fortfahren",
|
||||
"connect_your_calendar": "Kalender verbinden",
|
||||
"connect_your_video_app": "Verbinden Sie Ihre Video-Apps",
|
||||
"connect_your_video_app_instructions": "Verbinden Sie Ihre Video-Apps, um sie für Ihre Termintypen zu verwenden.",
|
||||
@@ -289,6 +290,7 @@
|
||||
"where": "Wo",
|
||||
"add_to_calendar": "Zum Kalender hinzufügen",
|
||||
"add_to_calendar_description": "Legen Sie fest, wo neue Termine hinzugefügt werden sollen, wenn Sie gebucht werden.",
|
||||
"add_events_to": "Termine hinzufügen zu",
|
||||
"add_another_calendar": "Einen weiteren Kalender hinzufügen",
|
||||
"other": "Sonstige",
|
||||
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
|
||||
@@ -423,6 +425,7 @@
|
||||
"booking_created": "Termin erstellt",
|
||||
"booking_rejected": "Termin abgelehnt",
|
||||
"booking_requested": "Buchung angefragt",
|
||||
"booking_payment_initiated": "Buchungszahlung eingeleitet",
|
||||
"meeting_ended": "Meeting beendet",
|
||||
"form_submitted": "Formular gesendet",
|
||||
"booking_paid": "Buchung bezahlt",
|
||||
@@ -457,6 +460,7 @@
|
||||
"no_event_types_have_been_setup": "Dieser Benutzer hat noch keine Termintypen eingerichtet.",
|
||||
"edit_logo": "Logo bearbeiten",
|
||||
"upload_a_logo": "Logo hochladen",
|
||||
"upload_logo": "Logo hochladen",
|
||||
"remove_logo": "Logos entfernen",
|
||||
"enable": "Aktivieren",
|
||||
"code": "Code",
|
||||
@@ -569,6 +573,7 @@
|
||||
"your_team_name": "Ihr Teamname",
|
||||
"team_updated_successfully": "Team erfolgreich aktualisiert",
|
||||
"your_team_updated_successfully": "Ihr Team wurde erfolgreich aktualisiert.",
|
||||
"your_org_updated_successfully": "Ihre Org wurde erfolgreich aktualisiert.",
|
||||
"about": "Beschreibung",
|
||||
"team_description": "Ein paar Sätze über Ihr Team auf der öffentlichen Teamseite.",
|
||||
"org_description": "Ein paar Sätze zu Ihrer Organisation. Dies wird auf der URL-Seite Ihrer Organisation erscheinen.",
|
||||
@@ -781,6 +786,7 @@
|
||||
"disable_guests": "Gäste deaktivieren",
|
||||
"disable_guests_description": "Das Hinzufügen zusätzlicher Gäste deaktivieren.",
|
||||
"private_link": "Privaten Link generieren",
|
||||
"enable_private_url": "Private URL aktivieren",
|
||||
"private_link_label": "Privater Link",
|
||||
"private_link_hint": "Ihr privater Link wird nach jeder Nutzung neu generiert",
|
||||
"copy_private_link": "Privaten Link kopieren",
|
||||
@@ -843,6 +849,7 @@
|
||||
"next_step": "Schritt überspringen",
|
||||
"prev_step": "Vorheriger Schritt",
|
||||
"install": "Installieren",
|
||||
"install_paid_app": "Abonnieren",
|
||||
"installed": "Installiert",
|
||||
"active_install_one": "{{count}} aktive Installation",
|
||||
"active_install_other": "{{count}} aktive Installationen",
|
||||
@@ -1091,6 +1098,7 @@
|
||||
"developer_documentation": "Entwickler-Dokumentation",
|
||||
"get_in_touch": "Kontakt aufnehmen",
|
||||
"contact_support": "Support kontaktieren",
|
||||
"community_support": "Community Support",
|
||||
"feedback": "Feedback",
|
||||
"submitted_feedback": "Vielen Dank für Ihr Feedback!",
|
||||
"feedback_error": "Fehler beim Senden des Feedbacks",
|
||||
@@ -1216,6 +1224,7 @@
|
||||
"organizer_name_variable": "Organisator Name",
|
||||
"app_upgrade_description": "Um diese Funktion nutzen zu können, müssen Sie ein Upgrade auf einen Pro-Account durchführen.",
|
||||
"invalid_number": "Ungültige Telefonnummer",
|
||||
"invalid_url_error_message": "Ungültige URL für {{label}}. Beispiel-URL: {{sampleUrl}}",
|
||||
"navigate": "Navigieren",
|
||||
"open": "Öffnen",
|
||||
"close": "Schließen",
|
||||
@@ -1290,6 +1299,7 @@
|
||||
"select_calendars": "Wählen Sie aus, in welchen Kalendern Sie nach Konflikten suchen wollen, um Doppelbuchungen zu vermeiden.",
|
||||
"check_for_conflicts": "Auf Konflikte prüfen",
|
||||
"view_recordings": "Aufnahmen anzeigen",
|
||||
"check_for_recordings": "Nach Aufnahmen suchen",
|
||||
"adding_events_to": "Termine hinzufügen zu",
|
||||
"follow_system_preferences": "Systemeinstellungen folgen",
|
||||
"custom_brand_colors": "Eigene Marken-Farben",
|
||||
@@ -1599,6 +1609,7 @@
|
||||
"options": "Optionen",
|
||||
"enter_option": "Option {{index}} eingeben",
|
||||
"add_an_option": "Option hinzufügen",
|
||||
"location_already_exists": "Dieser Standort existiert bereits. Bitte wählen Sie einen neuen Standort",
|
||||
"radio": "Radio",
|
||||
"google_meet_warning": "Um Google Meet nutzen zu können, müssen Sie Ihren Zielkalender zu einem Google Calendar ändern",
|
||||
"individual": "Person",
|
||||
@@ -1618,6 +1629,7 @@
|
||||
"date_overrides_mark_all_day_unavailable_other": "An ausgewählten Daten als „nicht verfügbar“ markieren",
|
||||
"date_overrides_add_btn": "Überschreibung hinzufügen",
|
||||
"date_overrides_update_btn": "Überschreiben aktualisieren",
|
||||
"date_successfully_added": "Datumsüberbrückung erfolgreich hinzugefügt",
|
||||
"event_type_duplicate_copy_text": "{{slug}}-Kopie",
|
||||
"set_as_default": "Als Standard festlegen",
|
||||
"hide_eventtype_details": "Ereignistyp-Einzelheiten ausblenden",
|
||||
@@ -2062,6 +2074,7 @@
|
||||
"access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen",
|
||||
"select_account_team": "Konto oder Team auswählen",
|
||||
"allow_client_to": "Dies wird {{clientName}} erlauben",
|
||||
"associate_with_cal_account": "Verknüpfen Sie sich mit Ihren persönlichen Daten von {{clientName}}",
|
||||
"see_personal_info": "Ihre persönlichen Daten einzusehen, einschließlich persönlicher Informationen, die Sie öffentlich zugänglich gemacht haben",
|
||||
"see_primary_email_address": "Ihre primäre E-Mail-Adresse einzusehen",
|
||||
"connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden",
|
||||
@@ -2069,12 +2082,24 @@
|
||||
"access_availability": "Lesen, Bearbeiten, Löschen Ihrer Verfügbarkeiten",
|
||||
"access_bookings": "Lesen, Bearbeiten, Löschen Ihrer Termine",
|
||||
"allow_client_to_do": "{{clientName}} zulassen, dies zu tun?",
|
||||
"oauth_access_information": "Indem Sie auf „Erlauben“ klicken, erlauben Sie dieser App, Ihre Informationen gemäß ihrer Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können den Zugriff im {{appName}} App Store aufheben.",
|
||||
"allow": "Zulassen",
|
||||
"view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.",
|
||||
"view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.",
|
||||
"you_can_override_calendar_in_advanced_tab": "Sie können dies in den erweiterten Einstellungen pro Termin für jeden Termintyp überschreiben.",
|
||||
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
|
||||
"resend_invitation": "Einladung erneut senden",
|
||||
"invitation_resent": "Die Einladung wurde erneut gesendet.",
|
||||
"add_client": "Kunde hinzufügen",
|
||||
"copy_client_secret_info": "Nach dem Kopieren des Geheimnisses können Sie es nicht mehr ansehen",
|
||||
"add_new_client": "Neuen Kunden hinzufügen",
|
||||
"this_app_is_not_setup_already": "Diese App wurde noch nicht eingerichtet",
|
||||
"as_csv": "als CSV",
|
||||
"overlay_my_calendar": "Meinen Kalender überlagern",
|
||||
"overlay_my_calendar_toc": "Durch das Verbinden mit Ihrem Kalender akzeptieren Sie unsere Datenschutzerklärung und Nutzungsbedingungen. Sie können den Zugriff jederzeit widerrufen.",
|
||||
"view_overlay_calendar_events": "Sehen Sie sich Ihre Kalendertermine an, um Buchungskonflikte zu vermeiden.",
|
||||
"lock_timezone_toggle_on_booking_page": "Zeitzone auf der Buchungsseite sperren",
|
||||
"description_lock_timezone_toggle_on_booking_page": "Um die Zeitzone auf der Buchungsseite zu sperren, nützlich für Termine in Person.",
|
||||
"extensive_whitelabeling": "Dedizierte Onboarding- und Engineeringsupport",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"identity_provider": "Πάροχος ταυτότητας",
|
||||
"day_one": "{{count}} ημέρα",
|
||||
"day_other": "{{count}} ημέρες",
|
||||
"second_one": "{{count}} δευτερόλεπτο",
|
||||
@@ -7,8 +8,13 @@
|
||||
"accept_invitation": "Αποδοχή Πρόσκλησης",
|
||||
"have_any_questions": "Έχετε ερωτήσεις? Είμαστε εδώ για να βοηθήσουμε.",
|
||||
"reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης",
|
||||
"verify_email_email_header": "Επιβεβαιώστε τη διεύθυνση ηλεκτρονικού ταχυδρομείου σας",
|
||||
"verify_email_email_button": "Επιβεβαίωση ηλεκτρονικού ταχυδρομείου",
|
||||
"copy_somewhere_safe": "Αποθηκεύστε αυτό το κλειδί API κάπου ασφαλές. Δεν θα μπορείτε να το δείτε ξανά.",
|
||||
"event_declined_subject": "Απορρίφθηκε: {{title}} στις {{date}}",
|
||||
"organizer": "Διοργανωτής",
|
||||
"need_to_reschedule_or_cancel": "Χρειάζεται να επαναπρογραμματίσετε ή να ακυρώσετε;",
|
||||
"no_options_available": "Καμία διαθέσιμη επιλογή",
|
||||
"cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)",
|
||||
"rejection_reason": "Λόγος απόρριψης",
|
||||
"rejection_reason_title": "Απόρριψη του αιτήματος κράτησης;",
|
||||
@@ -21,6 +27,8 @@
|
||||
"meeting_awaiting_payment": "Η πληρωμή της συνάντησής σας εκκρεμεί",
|
||||
"help": "Βοήθεια",
|
||||
"price": "Τιμή",
|
||||
"paid": "Πληρώθηκε",
|
||||
"refunded": "Πιστώθηκε",
|
||||
"payment": "Πληρωμή",
|
||||
"missing_card_fields": "Λείπουν τα πεδία της κάρτας",
|
||||
"pay_now": "Πληρωμή τώρα",
|
||||
|
||||
@@ -56,6 +56,17 @@
|
||||
"a_refund_failed": "A refund failed",
|
||||
"awaiting_payment_subject": "Awaiting Payment: {{title}} on {{date}}",
|
||||
"meeting_awaiting_payment": "Your meeting is awaiting payment",
|
||||
"dark_theme_contrast_error":"Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
|
||||
"light_theme_contrast_error":"Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
|
||||
"payment_not_created_error": "Payment could not be created",
|
||||
"couldnt_charge_card_error": "Could not charge card for Payment",
|
||||
"no_available_users_found_error": "No available users found. Could you try another time slot?",
|
||||
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
|
||||
"create_calendar_event_error": "Unable to create Calendar event in Organizer's calendar",
|
||||
"update_calendar_event_error": "Unable to update Calendar event.",
|
||||
"delete_calendar_event_error": "Unable to delete Calendar event.",
|
||||
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
|
||||
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
|
||||
"help": "Help",
|
||||
"price": "Price",
|
||||
"paid": "Paid",
|
||||
@@ -606,6 +617,7 @@
|
||||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||
"danger_zone": "Danger zone",
|
||||
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
|
||||
"team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
@@ -849,6 +861,8 @@
|
||||
"next_step": "Skip step",
|
||||
"prev_step": "Prev step",
|
||||
"install": "Install",
|
||||
"install_paid_app": "Subscribe",
|
||||
"start_paid_trial": "Start free Trial",
|
||||
"installed": "Installed",
|
||||
"active_install_one": "{{count}} active install",
|
||||
"active_install_other": "{{count}} active installs",
|
||||
@@ -1097,6 +1111,8 @@
|
||||
"developer_documentation": "Developer Documentation",
|
||||
"get_in_touch": "Get in touch",
|
||||
"contact_support": "Contact Support",
|
||||
"premium_support": "Premium Support",
|
||||
"community_support": "Community Support",
|
||||
"feedback": "Feedback",
|
||||
"submitted_feedback": "Thank you for your feedback!",
|
||||
"feedback_error": "Error sending feedback",
|
||||
@@ -1361,6 +1377,7 @@
|
||||
"event_name_info": "The event type name",
|
||||
"event_date_info": "The event date",
|
||||
"event_time_info": "The event start time",
|
||||
"event_type_not_found": "EventType not Found",
|
||||
"location_info": "The location of the event",
|
||||
"additional_notes_info": "The additional notes of booking",
|
||||
"attendee_name_info": "The person booking's name",
|
||||
@@ -1401,6 +1418,7 @@
|
||||
"slot_length": "Slot length",
|
||||
"booking_appearance": "Booking Appearance",
|
||||
"appearance_team_description": "Manage settings for your team's booking appearance",
|
||||
"appearance_org_description": "Manage settings for your organization's booking appearance",
|
||||
"only_owner_change": "Only the owner of this team can make changes to the team's booking ",
|
||||
"team_disable_cal_branding_description": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}'",
|
||||
"invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}",
|
||||
@@ -2096,7 +2114,28 @@
|
||||
"overlay_my_calendar":"Overlay my calendar",
|
||||
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
||||
"troubleshooting":"Troubleshooting",
|
||||
"calendars_were_checking_for_conflicts":"Calendars we’re checking for conflicts",
|
||||
"availabilty_schedules":"Availability schedules",
|
||||
"manage_calendars":"Manage calendars",
|
||||
"manage_availability_schedules":"Manage availability schedules",
|
||||
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
|
||||
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
|
||||
"install_calendar":"Install Calendar",
|
||||
"branded_subdomain": "Branded Subdomain",
|
||||
"branded_subdomain_description": "Get your own branded subdomain, such as acme.cal.com",
|
||||
"org_insights": "Organization-wide Insights",
|
||||
"org_insights_description": "Understand how your entire organization is spending time",
|
||||
"extensive_whitelabeling": "Extensive Whitelabeling",
|
||||
"extensive_whitelabeling_description": "Customize your scheduling experience with your own logo, colors, and more",
|
||||
"unlimited_teams": "Unlimited Teams",
|
||||
"unlimited_teams_description": "Add as many subteams as you need to your organization",
|
||||
"unified_billing": "Unified Billing",
|
||||
"unified_billing_description": "Add a single credit card to pay for all your team's subscriptions",
|
||||
"advanced_managed_events": "Advanced Managed Event Types",
|
||||
"advanced_managed_events_description": "Add a single credit card to pay for all your team's subscriptions",
|
||||
"enterprise_description": "Upgrade to Enterprise to create your Organization",
|
||||
"create_your_org": "Create your Organization",
|
||||
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user