Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477ee1c526 | ||
|
|
6704e18e32 | ||
|
|
20f17903b9 | ||
|
|
8dabbbd09f | ||
|
|
32933ed6ef | ||
|
|
c765ea9b31 | ||
|
|
d3ddbf418f | ||
|
|
79d0513428 | ||
|
|
230b82d3bf | ||
|
|
c9b50ffb78 | ||
|
|
ada0ef242b | ||
|
|
b26e688950 | ||
|
|
d9041d03b7 | ||
|
|
f60b3c14e1 | ||
|
|
fbcc43931e | ||
|
|
a48e7cb39a | ||
|
|
db3f718ba6 | ||
|
|
ca5dd4626d | ||
|
|
4524d722f6 | ||
|
|
2094d59856 | ||
|
|
7b350a5b8f | ||
|
|
5a869228d3 | ||
|
|
dabd5eae73 | ||
|
|
cb78de231a | ||
|
|
b0a1ef8a49 | ||
|
|
0a43aa3351 | ||
|
|
ed4cce1c7e | ||
|
|
d595aa23c0 | ||
|
|
a6df49235f | ||
|
|
5df41e37a0 | ||
|
|
fca5778b6d | ||
|
|
0ddefaa141 | ||
|
|
c11f7aeffc | ||
|
|
9903fcaa05 | ||
|
|
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 | ||
|
|
2236dfe411 | ||
|
|
a67369460a | ||
|
|
b2526300a7 | ||
|
|
d69395434f | ||
|
|
2720c9cc67 | ||
|
|
f1465a15f6 | ||
|
|
1d24f2654d | ||
|
|
f7eb86577c | ||
|
|
cc515f00b7 | ||
|
|
e695f75113 | ||
|
|
0d1b6ea5b2 | ||
|
|
26637cda08 | ||
|
|
529054aa76 | ||
|
|
8deee738c2 | ||
|
|
6848362683 | ||
|
|
09fc7e1a4c | ||
|
|
2f73be0ee6 | ||
|
|
3cdf59f2cc | ||
|
|
1f9880204e | ||
|
|
d0be48ebde |
@@ -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=
|
||||
|
||||
@@ -141,6 +155,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET_APPS=
|
||||
STRIPE_PRIVATE_KEY=
|
||||
STRIPE_CLIENT_ID=
|
||||
PAYMENT_FEE_FIXED=
|
||||
@@ -207,6 +222,8 @@ CSP_POLICY=
|
||||
EDGE_CONFIG=
|
||||
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
# Control time intervals on a user's Schedule availability
|
||||
NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL=
|
||||
|
||||
# - ORGANIZATIONS *******************************************************************************************
|
||||
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
||||
@@ -234,6 +251,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
|
||||
@@ -261,3 +284,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,27 +0,0 @@
|
||||
name: "Welcome new contributors"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: opened
|
||||
pull_request:
|
||||
types: opened
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
welcome-message:
|
||||
name: Welcoming New Users
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: |-
|
||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||
Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
|
||||
issue-message: |
|
||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||
@@ -83,6 +83,10 @@
|
||||
"NEXT_PUBLIC_TEAM_IMPERSONATION": {
|
||||
"description": "Set the following value to true if you wish to enable Team Impersonation",
|
||||
"value": "false"
|
||||
},
|
||||
"NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL": {
|
||||
"description": "Control time intervals on a user's Schedule availability",
|
||||
"value": "15"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
@@ -89,7 +90,9 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
|
||||
if (req.isAdmin) return true;
|
||||
if (eventType?.teamId) {
|
||||
req.query.teamId = String(eventType.teamId);
|
||||
await canAccessTeamEventOrThrow(req, "MEMBER");
|
||||
await canAccessTeamEventOrThrow(req, {
|
||||
in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
|
||||
});
|
||||
}
|
||||
if (eventType?.userId === req.userId) return true; // is owner.
|
||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
|
||||
@@ -299,7 +299,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
data.hosts = { createMany: { data: hosts } };
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.create({ data });
|
||||
const eventType = await prisma.eventType.create({ data, include: { hosts: true } });
|
||||
|
||||
return {
|
||||
event_type: schemaEventTypeReadPublic.parse(eventType),
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ export async function checkPermissions(
|
||||
role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER
|
||||
) {
|
||||
const { userId, prisma, isAdmin } = req;
|
||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
||||
const { teamId } = schemaQueryTeamId.parse({
|
||||
teamId: req.query.teamId,
|
||||
version: req.query.version,
|
||||
apiKey: req.query.apiKey,
|
||||
});
|
||||
const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } };
|
||||
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
||||
if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
||||
|
||||
@@ -56,6 +56,14 @@ async function getHandler(req: NextApiRequest) {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
customInputs: true,
|
||||
team: { select: { slug: true } },
|
||||
users: true,
|
||||
hosts: { select: { userId: true, isFixed: true } },
|
||||
owner: { select: { username: true, id: true } },
|
||||
children: { select: { id: true, userId: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const data = await prisma.eventType.findMany(args);
|
||||
|
||||
@@ -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("subscribe")}
|
||||
</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({
|
||||
|
||||
@@ -134,7 +134,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Label className="font-medium">{t("add_to_calendar")}</Label>
|
||||
<div>
|
||||
<Label className="text-emphasis mb-0 font-medium">{t("add_to_calendar")}</Label>
|
||||
</div>
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
@@ -142,21 +144,20 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
{t("add_another_calendar")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="-mt-1 w-full">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="destinationCalendar"
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="destinationCalendar"
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
hideAdvancedText
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { AvatarGroup } from "@calcom/ui";
|
||||
@@ -11,6 +12,7 @@ export function UserAvatarGroup(props: UserAvatarProps) {
|
||||
<AvatarGroup
|
||||
{...rest}
|
||||
items={users.map((user) => ({
|
||||
href: `${CAL_URL}/${user.username}?redirect=false`,
|
||||
alt: user.name || "",
|
||||
title: user.name || "",
|
||||
image: getUserAvatarUrl(user),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { AvatarGroup } from "@calcom/ui";
|
||||
@@ -10,8 +11,11 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> &
|
||||
|
||||
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
|
||||
const { users, organization, ...rest } = props;
|
||||
const orgBranding = useOrgBranding();
|
||||
const baseUrl = `${orgBranding?.fullDomain ?? CAL_URL}`;
|
||||
const items = [
|
||||
{
|
||||
href: baseUrl,
|
||||
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
|
||||
alt: organization.name || undefined,
|
||||
title: organization.name,
|
||||
@@ -19,6 +23,7 @@ export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
|
||||
].concat(
|
||||
users.map((user) => {
|
||||
return {
|
||||
href: `${baseUrl}/${user.username}/?redirect=false`,
|
||||
image: getUserAvatarUrl(user),
|
||||
alt: user.name || undefined,
|
||||
title: user.name || user.username || "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
-4
@@ -71,13 +71,21 @@ if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CRED
|
||||
}
|
||||
|
||||
const informAboutDuplicateTranslations = () => {
|
||||
const valueSet = new Set();
|
||||
const valueMap = {};
|
||||
|
||||
for (const key in englishTranslation) {
|
||||
if (valueSet.has(englishTranslation[key])) {
|
||||
console.warn("\x1b[33mDuplicate value found in:", "\x1b[0m", key);
|
||||
const value = englishTranslation[key];
|
||||
|
||||
if (valueMap[value]) {
|
||||
console.warn(
|
||||
"\x1b[33mDuplicate value found in common.json keys:",
|
||||
"\x1b[0m ",
|
||||
key,
|
||||
"and",
|
||||
valueMap[value]
|
||||
);
|
||||
} else {
|
||||
valueSet.add(englishTranslation[key]);
|
||||
valueMap[value] = key;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.4.7",
|
||||
"version": "3.5.3",
|
||||
"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 (
|
||||
|
||||
@@ -54,6 +54,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
// So it doesn't display in the Link (and make tests fail)
|
||||
user: _user,
|
||||
orgSlug: _orgSlug,
|
||||
redirect: _redirect,
|
||||
...query
|
||||
} = useRouterQuery();
|
||||
|
||||
@@ -82,7 +83,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={{
|
||||
@@ -116,7 +117,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
|
||||
<h1 className="font-cal text-emphasis my-1 text-3xl" data-testid="name-title">
|
||||
{profile.name}
|
||||
{user.verified && (
|
||||
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
|
||||
@@ -245,7 +246,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 +296,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
metadata: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
avatarUrl: true,
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
@@ -363,6 +365,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,
|
||||
@@ -386,6 +389,16 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||
}));
|
||||
|
||||
// if profile only has one public event-type, redirect to it
|
||||
if (eventTypes.length === 1 && context.query.redirect !== "false") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/${user.username}/${eventTypes[0].slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const safeBio = markdownToSafeHTML(user.bio) || "";
|
||||
|
||||
const markdownStrippedBio = stripMarkdown(user?.bio || "");
|
||||
@@ -397,6 +410,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,
|
||||
})),
|
||||
|
||||
@@ -1,218 +1,53 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { IS_CALCOM } from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).end();
|
||||
}
|
||||
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
||||
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
||||
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
function ensureSignupIsEnabled() {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
|
||||
res.status(403).json({ message: "Signup is disabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = req.body;
|
||||
const { email, password, language, token } = signupSchema.parse(data);
|
||||
|
||||
const username = slugify(data.username);
|
||||
const userEmail = email.toLowerCase();
|
||||
|
||||
if (!username) {
|
||||
res.status(422).json({ message: "Invalid username" });
|
||||
return;
|
||||
}
|
||||
|
||||
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
|
||||
if (token) {
|
||||
foundToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
expires: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundToken) {
|
||||
return res.status(401).json({ message: "Invalid Token" });
|
||||
}
|
||||
|
||||
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
||||
return res.status(401).json({ message: "Token expired" });
|
||||
}
|
||||
if (foundToken?.teamId) {
|
||||
const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId);
|
||||
if (!teamUserValidation.isValid) {
|
||||
return res.status(409).json({ message: "Username or email is already taken" });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const userValidation = await validateUsername(username, userEmail);
|
||||
if (!userValidation.isValid) {
|
||||
return res.status(409).json({ message: "Username or email is already taken" });
|
||||
}
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
if (foundToken && foundToken?.teamId) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: foundToken.teamId,
|
||||
},
|
||||
});
|
||||
if (team) {
|
||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||
|
||||
if (IS_CALCOM && (!teamMetadata?.isOrganization || !!team.parentId)) {
|
||||
const checkUsername = await checkPremiumUsername(username);
|
||||
if (checkUsername.premium) {
|
||||
// This signup page is ONLY meant for team invites and local setup. Not for every day users.
|
||||
// In singup redesign/refactor coming up @sean will tackle this to make them the same API/page instead of two.
|
||||
return res.status(422).json({
|
||||
message: "Sign up from https://cal.com/signup to claim your premium username",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Identify the org id in an org context signup, either the invited team is an org
|
||||
// or has a parentId, otherwise parentId will be null, making orgId null
|
||||
const orgId = teamMetadata?.isOrganization ? team.id : team.parentId;
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: userEmail },
|
||||
update: {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
organizationId: orgId,
|
||||
},
|
||||
create: {
|
||||
username,
|
||||
email: userEmail,
|
||||
password: hashedPassword,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
organizationId: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await prisma.membership.upsert({
|
||||
where: {
|
||||
userId_teamId: { userId: user.id, teamId: team.id },
|
||||
},
|
||||
update: {
|
||||
accepted: true,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
closeComUpsertTeamUser(team, user, membership.role);
|
||||
|
||||
// Accept any child team invites for orgs and create a membership for the org itself
|
||||
if (team.parentId) {
|
||||
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
|
||||
await prisma.membership.upsert({
|
||||
where: {
|
||||
userId_teamId: { userId: user.id, teamId: team.parentId },
|
||||
},
|
||||
update: {
|
||||
accepted: true,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
teamId: team.parentId,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
|
||||
// We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG
|
||||
await prisma.membership.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
team: {
|
||||
id: team.parentId,
|
||||
},
|
||||
accepted: false,
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Join any other invites
|
||||
await prisma.membership.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
team: {
|
||||
parentId: team.parentId,
|
||||
},
|
||||
accepted: false,
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup token after use
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
id: foundToken.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (IS_CALCOM) {
|
||||
const checkUsername = await checkPremiumUsername(username);
|
||||
if (checkUsername.premium) {
|
||||
res.status(422).json({
|
||||
message: "Sign up from https://cal.com/signup to claim your premium username",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await prisma.user.upsert({
|
||||
where: { email: userEmail },
|
||||
update: {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
create: {
|
||||
username,
|
||||
email: userEmail,
|
||||
password: hashedPassword,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
});
|
||||
await sendEmailVerification({
|
||||
email: userEmail,
|
||||
username,
|
||||
language,
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: "Signup is disabled",
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({ message: "Created user" });
|
||||
}
|
||||
|
||||
function ensureReqIsPost(req: RequestWithUsernameStatus) {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpError({
|
||||
statusCode: 405,
|
||||
message: "Method not allowed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
|
||||
// Use a try catch instead of returning res every time
|
||||
try {
|
||||
ensureReqIsPost(req);
|
||||
ensureSignupIsEnabled();
|
||||
|
||||
/**
|
||||
* Im not sure its worth merging these two handlers. They are different enough to be separate.
|
||||
* Calcom handles things like creating a stripe customer - which we don't need to do for self hosted.
|
||||
* It also handles things like premium username.
|
||||
* TODO: (SEAN) - Extract a lot of the logic from calcomHandler into a separate file and import it into both handlers.
|
||||
* @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI.
|
||||
*/
|
||||
if (IS_PREMIUM_USERNAME_ENABLED) {
|
||||
return await calcomSignupHandler(req, res);
|
||||
}
|
||||
|
||||
return await selfHostedSignupHandler(req, res);
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError) {
|
||||
return res.status(e.statusCode).json({ message: e.message });
|
||||
}
|
||||
logger.error(e);
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,39 +3,49 @@ 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[] = [];
|
||||
const allRecurringDates: string[] = data.map((booking) => booking.start);
|
||||
let appsStatus: AppsStatus[] | undefined = undefined;
|
||||
const appsStatus: AppsStatus[] | undefined = undefined;
|
||||
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
const numSlotsToCheckForAvailability = 2;
|
||||
|
||||
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
|
||||
// emailed booking taking into account accumulated results to send app status accurately
|
||||
for (let key = data.length - 1; key >= 0; key--) {
|
||||
for (let key = 0; key < data.length; key++) {
|
||||
const booking = data[key];
|
||||
if (key === 0) {
|
||||
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||
.reduce((prev, curr) => {
|
||||
if (prev[curr.type]) {
|
||||
prev[curr.type].failures += curr.failures;
|
||||
prev[curr.type].success += curr.success;
|
||||
} else {
|
||||
prev[curr.type] = curr;
|
||||
}
|
||||
return prev;
|
||||
}, {} as { [key: string]: AppsStatus });
|
||||
appsStatus = Object.values(calcAppsStatus);
|
||||
}
|
||||
// Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses
|
||||
// It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email.
|
||||
// It is more important to iterate forward and check for conflicts for only first few bookings defined by 'numSlotsToCheckForAvailability'
|
||||
// if (key === 0) {
|
||||
// const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||
// .flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||
// .reduce((prev, curr) => {
|
||||
// if (prev[curr.type]) {
|
||||
// prev[curr.type].failures += curr.failures;
|
||||
// prev[curr.type].success += curr.success;
|
||||
// } else {
|
||||
// prev[curr.type] = curr;
|
||||
// }
|
||||
// return prev;
|
||||
// }, {} as { [key: string]: AppsStatus });
|
||||
// appsStatus = Object.values(calcAppsStatus);
|
||||
// }
|
||||
|
||||
const recurringEventReq: NextApiRequest & { userId?: number } = req;
|
||||
|
||||
@@ -49,6 +59,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
||||
|
||||
const eachRecurringBooking = await handleNewBooking(recurringEventReq, {
|
||||
isNotAnApiCall: true,
|
||||
skipAvailabilityCheck: key >= numSlotsToCheckForAvailability,
|
||||
});
|
||||
|
||||
createdBookings.push(eachRecurringBooking);
|
||||
@@ -56,4 +67,6 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
||||
return createdBookings;
|
||||
}
|
||||
|
||||
export const handleRecurringEventBooking = handler;
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -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_APPS) {
|
||||
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET_APPS" });
|
||||
}
|
||||
const requestBuffer = await buffer(req);
|
||||
const payload = requestBuffer.toString();
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET_APPS);
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -105,8 +105,6 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const identity = await getIdentityData(req);
|
||||
const img = identity?.avatar;
|
||||
// We cache for one day
|
||||
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
||||
// If image isn't set or links to this route itself, use default avatar
|
||||
if (!img) {
|
||||
if (identity?.org) {
|
||||
|
||||
@@ -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
|
||||
@@ -97,9 +98,9 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||
callbackUrl = safeCallbackUrl || "";
|
||||
|
||||
const LoginFooter = (
|
||||
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||
{t("dont_have_an_account")}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const TwoFactorFooter = (
|
||||
@@ -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,8 @@ 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 { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -22,7 +24,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(() => {
|
||||
@@ -71,7 +73,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||
if (usernameParam && session.user.email) {
|
||||
const availability = await checkUsername(usernameParam, currentOrgDomain);
|
||||
if (availability.available && availability.premium) {
|
||||
if (availability.available && availability.premium && IS_PREMIUM_USERNAME_ENABLED) {
|
||||
const stripePremiumUrl = await getStripePremiumUsernameUrl({
|
||||
userEmail: session.user.email,
|
||||
username: usernameParam,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
@@ -94,7 +97,7 @@ export default function Bookings() {
|
||||
}
|
||||
);
|
||||
|
||||
// Animate page (tab) tranistions to look smoothing
|
||||
// Animate page (tab) transitions to look smoothing
|
||||
|
||||
const buttonInView = useInViewObserver(() => {
|
||||
if (!query.isFetching && query.hasNextPage && query.status === "success") {
|
||||
@@ -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";
|
||||
@@ -11,7 +13,7 @@ import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
@@ -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}
|
||||
@@ -68,17 +69,17 @@ export default function InsightsPage() {
|
||||
<FiltersProvider>
|
||||
<Filters />
|
||||
|
||||
<div className="mb-4 space-y-6">
|
||||
<div className="mb-4 space-y-4">
|
||||
<BookingKPICards />
|
||||
|
||||
<BookingStatusLineChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<PopularEventsTable />
|
||||
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MostBookedTeamMembersTable />
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (team) {
|
||||
return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } });
|
||||
}
|
||||
return GSSUserPage({ ...ctx, query: { user: ctx.query.user } });
|
||||
return GSSUserPage({ ...ctx, query: { user: ctx.query.user, redirect: ctx.query.redirect } });
|
||||
};
|
||||
|
||||
type Props = UserPageProps | TeamPageProps;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
showToast,
|
||||
Label,
|
||||
} from "@calcom/ui";
|
||||
import { Plus, Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -113,17 +114,20 @@ const CalendarsView = () => {
|
||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||
{t("add_to_calendar")}
|
||||
</h2>
|
||||
<p className="text-default text-sm leading-tight">{t("add_to_calendar_description")}</p>
|
||||
<p className="text-subtle text-sm leading-tight">{t("add_to_calendar_description")}</p>
|
||||
</div>
|
||||
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={selectedDestinationCalendarOption?.externalId}
|
||||
onChange={(option) => {
|
||||
setSelectedDestinationCalendar(option);
|
||||
}}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
<div>
|
||||
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={selectedDestinationCalendarOption?.externalId}
|
||||
onChange={(option) => {
|
||||
setSelectedDestinationCalendar(option);
|
||||
}}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
|
||||
+393
-105
@@ -1,25 +1,34 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CalendarHeart, Info, Link2, ShieldCheckIcon, StarIcon, Users } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
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 { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, IS_CALCOM, IS_SELF_HOSTED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
||||
import { Button, HeadSeo, PasswordField, TextField, Form, Alert } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
@@ -34,10 +43,32 @@ type FormValues = z.infer<typeof signupSchema>;
|
||||
|
||||
type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
||||
const FEATURES = [
|
||||
{
|
||||
title: "connect_all_calendars",
|
||||
description: "connect_all_calendars_description",
|
||||
i18nOptions: {
|
||||
appName: APP_NAME,
|
||||
},
|
||||
icon: CalendarHeart,
|
||||
},
|
||||
{
|
||||
title: "set_availability",
|
||||
description: "set_availbility_description",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "share_a_link_or_embed",
|
||||
description: "share_a_link_or_embed_description",
|
||||
icon: Link2,
|
||||
i18nOptions: {
|
||||
appName: APP_NAME,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
|
||||
const [emailUser, emailDomain] = email.split("@");
|
||||
const [emailUser, emailDomain = ""] = email.split("@");
|
||||
const username =
|
||||
emailDomain === autoAcceptEmailDomain
|
||||
? slugify(emailUser)
|
||||
@@ -46,31 +77,124 @@ const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) =
|
||||
return username;
|
||||
};
|
||||
|
||||
function UsernameField({
|
||||
username,
|
||||
setPremium,
|
||||
premium,
|
||||
setUsernameTaken,
|
||||
usernameTaken,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TextField> & {
|
||||
username: string;
|
||||
setPremium: (value: boolean) => void;
|
||||
premium: boolean;
|
||||
usernameTaken: boolean;
|
||||
setUsernameTaken: (value: boolean) => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { register, formState } = useFormContext<FormValues>();
|
||||
const debouncedUsername = useDebounce(username, 600);
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.isSubmitting || formState.isSubmitSuccessful) return;
|
||||
|
||||
async function checkUsername() {
|
||||
if (!debouncedUsername) {
|
||||
setPremium(false);
|
||||
setUsernameTaken(false);
|
||||
return;
|
||||
}
|
||||
fetchUsername(debouncedUsername).then(({ data }) => {
|
||||
setPremium(data.premium);
|
||||
setUsernameTaken(!data.available);
|
||||
});
|
||||
}
|
||||
checkUsername();
|
||||
}, [debouncedUsername, setPremium, setUsernameTaken, formState.isSubmitting, formState.isSubmitSuccessful]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextField
|
||||
{...props}
|
||||
{...register("username")}
|
||||
data-testid="signup-usernamefield"
|
||||
addOnFilled={false}
|
||||
/>
|
||||
{(!formState.isSubmitting || !formState.isSubmitted) && (
|
||||
<div className="text-gray text-default flex items-center text-sm">
|
||||
<p className="flex items-center text-sm ">
|
||||
{usernameTaken ? (
|
||||
<div className="text-error">
|
||||
<Info className="mr-1 inline-block h-4 w-4" />
|
||||
{t("already_in_use_error")}
|
||||
</div>
|
||||
) : premium ? (
|
||||
<div data-testid="premium-username-warning">
|
||||
<StarIcon className="mr-1 inline-block h-4 w-4" />
|
||||
{t("premium_username", {
|
||||
price: getPremiumPlanPriceValue(),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
||||
|
||||
function addOrUpdateQueryParam(url: string, key: string, value: string) {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const param = `${key}=${encodeURIComponent(value)}`;
|
||||
return `${url}${separator}${param}`;
|
||||
}
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
|
||||
const searchParams = useSearchParams();
|
||||
export default function Signup({
|
||||
prepopulateFormValues,
|
||||
token,
|
||||
orgSlug,
|
||||
isGoogleLoginEnabled,
|
||||
isSAMLLoginEnabled,
|
||||
orgAutoAcceptEmail,
|
||||
}: SignupProps) {
|
||||
const [premiumUsername, setPremiumUsername] = useState(false);
|
||||
const [usernameTaken, setUsernameTaken] = useState(false);
|
||||
|
||||
const searchParams = useCompatSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const flags = useFlagMap();
|
||||
const methods = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
const formMethods = useForm<FormValues>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
defaultValues: prepopulateFormValues,
|
||||
defaultValues: prepopulateFormValues satisfies FormValues,
|
||||
mode: "onChange",
|
||||
});
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
} = methods;
|
||||
watch,
|
||||
formState: { isSubmitting, errors, isSubmitSuccessful },
|
||||
} = formMethods;
|
||||
|
||||
const handleErrors = async (resp: Response) => {
|
||||
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
|
||||
|
||||
const handleErrorsAndStripe = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
if (err.checkoutSessionId) {
|
||||
const stripe = await getStripe();
|
||||
if (stripe) {
|
||||
console.log("Redirecting to stripe checkout");
|
||||
const { error } = await stripe.redirectToCheckout({
|
||||
sessionId: err.checkoutSessionId,
|
||||
});
|
||||
console.warn(error.message);
|
||||
}
|
||||
} else {
|
||||
throw new Error(err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,7 +212,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
|
||||
},
|
||||
method: "POST",
|
||||
})
|
||||
.then(handleErrors)
|
||||
.then(handleErrorsAndStripe)
|
||||
.then(async () => {
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
|
||||
@@ -106,109 +230,262 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
methods.setError("apiError", { message: err.message });
|
||||
formMethods.setError("apiError", { message: err.message });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="bg-muted flex min-h-screen flex-col justify-center "
|
||||
style={
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
"--cal-brand-emphasis": "#101010",
|
||||
"--cal-brand-text": "white",
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div
|
||||
className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center"
|
||||
style={
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
"--cal-brand-emphasis": "#101010",
|
||||
"--cal-brand-text": "white",
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}>
|
||||
<div className="bg-muted 2xl:border-subtle grid max-h-[800px] w-full max-w-[1440px] grid-cols-1 grid-rows-1 lg:grid-cols-2 2xl:rounded-lg 2xl:border ">
|
||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="font-cal text-emphasis text-center text-3xl font-extrabold">
|
||||
{t("create_your_account")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-default mx-2 p-6 shadow sm:rounded-lg lg:p-8">
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
<div className="flex w-full flex-col px-4 py-6 sm:px-16 md:px-24 2xl:px-28">
|
||||
{/* Header */}
|
||||
{errors.apiError && (
|
||||
<Alert severity="error" message={errors.apiError?.message} data-testid="signup-error-message" />
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="font-cal text-[28px] ">
|
||||
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
|
||||
</h1>
|
||||
{IS_CALCOM ? (
|
||||
<p className="text-subtle text-base font-medium leading-6">{t("cal_signup_description")}</p>
|
||||
) : (
|
||||
<p className="text-subtle text-base font-medium leading-6">
|
||||
{t("calcom_explained", {
|
||||
appName: APP_NAME,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Form Container */}
|
||||
<div className="mt-10">
|
||||
<Form
|
||||
className="flex flex-col gap-4"
|
||||
form={formMethods}
|
||||
handleSubmit={async (values) => {
|
||||
let updatedValues = values;
|
||||
if (!formMethods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||
updatedValues = {
|
||||
...values,
|
||||
username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail),
|
||||
};
|
||||
}
|
||||
await signUp(updatedValues);
|
||||
}}>
|
||||
{/* Username */}
|
||||
<UsernameField
|
||||
label={t("username")}
|
||||
username={watch("username")}
|
||||
premium={premiumUsername}
|
||||
usernameTaken={usernameTaken}
|
||||
setUsernameTaken={(value) => setUsernameTaken(value)}
|
||||
data-testid="signup-usernamefield"
|
||||
setPremium={(value) => setPremiumUsername(value)}
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
/>
|
||||
{/* Email */}
|
||||
<TextField
|
||||
{...register("email")}
|
||||
label={t("email")}
|
||||
type="email"
|
||||
data-testid="signup-emailfield"
|
||||
/>
|
||||
|
||||
if (methods.formState?.errors?.apiError) {
|
||||
methods.clearErrors("apiError");
|
||||
}
|
||||
|
||||
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||
methods.setValue(
|
||||
"username",
|
||||
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
|
||||
);
|
||||
}
|
||||
methods.handleSubmit(signUp)(event);
|
||||
}}
|
||||
className="bg-default space-y-6">
|
||||
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
{!isOrgInviteByLink && (
|
||||
<TextField
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
{/* Password */}
|
||||
<PasswordField
|
||||
data-testid="signup-passwordfield"
|
||||
label={t("password")}
|
||||
{...register("password")}
|
||||
hintErrors={["caplow", "min", "num"]}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="my-2 w-full justify-center"
|
||||
loading={loadingSubmitState}
|
||||
disabled={
|
||||
!!formMethods.formState.errors.username ||
|
||||
!!formMethods.formState.errors.email ||
|
||||
usernameTaken
|
||||
}>
|
||||
{premiumUsername && !usernameTaken
|
||||
? `Create Account for ${getPremiumPlanPriceValue()}`
|
||||
: t("create_account")}
|
||||
</Button>
|
||||
</Form>
|
||||
{/* Continue with Social Logins */}
|
||||
{token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
|
||||
<div className="mt-6">
|
||||
<div className="relative flex items-center">
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
<span className="text-subtle leadning-none mx-2 flex-shrink text-sm font-normal ">
|
||||
{t("or_continue_with")}
|
||||
</span>
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Social Logins */}
|
||||
{!token && (
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row">
|
||||
{isGoogleLoginEnabled ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={!!formMethods.formState.errors.username || premiumUsername}
|
||||
className={classNames(
|
||||
"w-full justify-center rounded-md text-center",
|
||||
formMethods.formState.errors.username ? "opacity-50" : ""
|
||||
)}
|
||||
onClick={async () => {
|
||||
const username = formMethods.getValues("username");
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
|
||||
if (username) {
|
||||
// If username is present we save it in query params to check for premium
|
||||
const searchQueryParams = new URLSearchParams();
|
||||
searchQueryParams.set("username", formMethods.getValues("username"));
|
||||
localStorage.setItem("username", username);
|
||||
router.push(`${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`);
|
||||
return;
|
||||
}
|
||||
{...register("username")}
|
||||
disabled={!!orgSlug}
|
||||
required
|
||||
router.push(GOOGLE_AUTH_URL);
|
||||
}}>
|
||||
<img
|
||||
className={classNames("text-emphasis mr-2 h-5 w-5", premiumUsername && "opacity-50")}
|
||||
src="/google-icon.svg"
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<EmailField
|
||||
{...register("email")}
|
||||
disabled={prepopulateFormValues?.email}
|
||||
className="disabled:bg-emphasis disabled:hover:cursor-not-allowed"
|
||||
/>
|
||||
<PasswordField
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
{...register("password")}
|
||||
hintErrors={["caplow", "min", "num"]}
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button type="submit" loading={isSubmitting} className="w-full justify-center">
|
||||
{t("create_account")}
|
||||
Google
|
||||
</Button>
|
||||
{!token && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="w-full justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/getting-started`,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
) : null}
|
||||
{isSAMLLoginEnabled ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={
|
||||
!!formMethods.formState.errors.username ||
|
||||
!!formMethods.formState.errors.email ||
|
||||
premiumUsername
|
||||
}
|
||||
className={classNames(
|
||||
"w-full justify-center rounded-md text-center",
|
||||
formMethods.formState.errors.username && formMethods.formState.errors.email
|
||||
? "opacity-50"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!formMethods.getValues("username")) {
|
||||
formMethods.trigger("username");
|
||||
}
|
||||
if (!formMethods.getValues("email")) {
|
||||
formMethods.trigger("email");
|
||||
return;
|
||||
}
|
||||
const username = formMethods.getValues("username");
|
||||
localStorage.setItem("username", username);
|
||||
const sp = new URLSearchParams();
|
||||
// @NOTE: don't remove username query param as it's required right now for stripe payment page
|
||||
sp.set("username", formMethods.getValues("username"));
|
||||
sp.set("email", formMethods.getValues("email"));
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
|
||||
);
|
||||
}}>
|
||||
<ShieldCheckIcon className="mr-2 h-5 w-5" />
|
||||
{t("saml_sso")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Already have an account & T&C */}
|
||||
<div className="mt-6">
|
||||
<div className="flex flex-col text-sm">
|
||||
<Link href="/auth/login" className="text-emphasis hover:underline">
|
||||
{t("already_have_account")}
|
||||
</Link>
|
||||
<div className="text-subtle">
|
||||
By signing up, you agree to our{" "}
|
||||
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/terms`}>
|
||||
Terms of Service{" "}
|
||||
</Link>
|
||||
<span>and</span>{" "}
|
||||
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/privacy`}>
|
||||
Privacy Policy.
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-subtle border-subtle hidden w-full flex-col justify-between rounded-l-2xl py-12 pl-12 lg:flex">
|
||||
{IS_CALCOM && (
|
||||
<div className="mb-12 mr-12 grid h-full w-full grid-cols-4 gap-4 ">
|
||||
<div className="">
|
||||
<img src="/product-cards/trustpilot.svg" className="h-[54px] w-full" alt="#" />
|
||||
</div>
|
||||
<div>
|
||||
<img src="/product-cards/g2.svg" className="h-[54px] w-full" alt="#" />
|
||||
</div>
|
||||
<div>
|
||||
<img src="/product-cards/producthunt.svg" className="h-[54px] w-full" alt="#" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="rounded-2xl border-y border-l border-dashed border-[#D1D5DB5A] py-[6px] pl-[6px]"
|
||||
style={{
|
||||
backgroundColor: "rgba(236,237,239,0.9)",
|
||||
}}>
|
||||
<img src="/mock-event-type-list.svg" alt="#" className="" />
|
||||
</div>
|
||||
<div className="mr-12 mt-8 grid h-full w-full grid-cols-3 gap-4 overflow-hidden">
|
||||
{!IS_CALCOM &&
|
||||
FEATURES.map((feature) => (
|
||||
<>
|
||||
<div className="flex flex-col leading-none">
|
||||
<div className="text-emphasis items-center">
|
||||
<feature.icon className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val || ""),
|
||||
email: z.string().email().optional(),
|
||||
});
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
@@ -222,6 +499,9 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
prepopulateFormValues: undefined,
|
||||
};
|
||||
|
||||
// username + email prepopulated from query params
|
||||
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -231,7 +511,15 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
// no token given, treat as a normal signup without verification token
|
||||
if (!token) {
|
||||
return {
|
||||
props: JSON.parse(JSON.stringify(props)),
|
||||
props: JSON.parse(
|
||||
JSON.stringify({
|
||||
...props,
|
||||
prepopulateFormValues: {
|
||||
username: preFillusername || null,
|
||||
email: prefilEmail || null,
|
||||
},
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
<h3>{t("invitee_timezone")}:</h3>
|
||||
<p>{booking.user?.timeZone}</p>
|
||||
<h3>{t("when")}:</h3>
|
||||
<p>
|
||||
<p suppressHydrationWarning={true}>
|
||||
{formatToLocalizedDate(startTime)} <br />
|
||||
{formatToLocalizedTime(startTime)}
|
||||
</p>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,337 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { Linter } from "eslint";
|
||||
import { parse } from "node-html-parser";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
function chooseEmbedType(page: Page, embedType: string) {
|
||||
const linter = new Linter();
|
||||
const eslintRules = {
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": "off",
|
||||
} as const;
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Embed Code Generator Tests", () => {
|
||||
test.describe("Non-Organization", () => {
|
||||
test.beforeEach(async ({ users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
});
|
||||
|
||||
test.describe("Event Types Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => {
|
||||
const [pro] = users.get();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "inline",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
await expectToContainValidCode(page, {
|
||||
language: "html",
|
||||
embedType: "inline",
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
await goToReactCodeTab(page);
|
||||
await expectToContainValidCode(page, {
|
||||
language: "react",
|
||||
embedType: "inline",
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
await goToPreviewTab(page);
|
||||
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => {
|
||||
const [pro] = users.get();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "floating-popup");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "floating-popup",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, {
|
||||
language: "html",
|
||||
embedType: "floating-popup",
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
await goToReactCodeTab(page);
|
||||
await expectToContainValidCode(page, {
|
||||
language: "react",
|
||||
embedType: "floating-popup",
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
await goToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "floating-popup",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => {
|
||||
const [pro] = users.get();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "element-click");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "element-click",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, {
|
||||
language: "html",
|
||||
embedType: "element-click",
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
await goToReactCodeTab(page);
|
||||
await expectToContainValidCode(page, {
|
||||
language: "react",
|
||||
embedType: "element-click",
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
await goToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "element-click",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});
|
||||
});
|
||||
});
|
||||
test.describe("Event Type Edit Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/event-types`);
|
||||
await Promise.all([
|
||||
page.locator('a[href*="/event-types/"]').first().click(),
|
||||
page.waitForURL((url) => url.pathname.startsWith("/event-types/")),
|
||||
]);
|
||||
});
|
||||
|
||||
test("open Embed Dialog for the Event Type", async ({ page }) => {
|
||||
const basePage = new URL(page.url()).pathname;
|
||||
const embedUrl = await clickEmbedButton(page);
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage,
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
basePage,
|
||||
embedType: "inline",
|
||||
});
|
||||
|
||||
await expectToContainValidCode(page, {
|
||||
language: "html",
|
||||
embedType: "inline",
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
await goToPreviewTab(page);
|
||||
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: decodeURIComponent(embedUrl),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test.beforeEach(async ({ users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
await user.apiLogin();
|
||||
});
|
||||
test.describe("Event Types Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => {
|
||||
const [user] = users.get();
|
||||
const { team: org } = await user.getOrgMembership();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "inline",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
// Default tab is HTML code tab
|
||||
await expectToContainValidCode(page, {
|
||||
language: "html",
|
||||
embedType: "inline",
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
|
||||
await goToReactCodeTab(page);
|
||||
await expectToContainValidCode(page, {
|
||||
language: "react",
|
||||
embedType: "inline",
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
|
||||
await goToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: `${user.username}/30-min`,
|
||||
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => {
|
||||
const [user] = users.get();
|
||||
const { team: org } = await user.getOrgMembership();
|
||||
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "floating-popup");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "floating-popup",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, {
|
||||
language: "html",
|
||||
embedType: "floating-popup",
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
|
||||
await goToReactCodeTab(page);
|
||||
await expectToContainValidCode(page, {
|
||||
language: "react",
|
||||
embedType: "floating-popup",
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
|
||||
await goToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "floating-popup",
|
||||
calLink: `${user.username}/30-min`,
|
||||
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => {
|
||||
const [user] = users.get();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
const { team: org } = await user.getOrgMembership();
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "element-click");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "element-click",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, {
|
||||
language: "html",
|
||||
embedType: "element-click",
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
|
||||
await goToReactCodeTab(page);
|
||||
await expectToContainValidCode(page, {
|
||||
language: "react",
|
||||
embedType: "element-click",
|
||||
orgSlug: org.slug,
|
||||
});
|
||||
|
||||
await goToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "element-click",
|
||||
calLink: `${user.username}/30-min`,
|
||||
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type EmbedType = "inline" | "floating-popup" | "element-click";
|
||||
function chooseEmbedType(page: Page, embedType: EmbedType) {
|
||||
page.locator(`[data-testid=${embedType}]`).click();
|
||||
}
|
||||
|
||||
async function gotToPreviewTab(page: Page) {
|
||||
async function goToPreviewTab(page: Page) {
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click();
|
||||
await page.locator("[data-testid=horizontal-tab-Preview]").click();
|
||||
}
|
||||
|
||||
async function goToReactCodeTab(page: Page) {
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await page.locator("[data-testid=horizontal-tab-React]").click();
|
||||
}
|
||||
|
||||
async function clickEmbedButton(page: Page) {
|
||||
@@ -55,7 +373,7 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog(
|
||||
basePage,
|
||||
}: {
|
||||
embedUrl: string | null;
|
||||
embedType: string;
|
||||
embedType: EmbedType;
|
||||
basePage: string;
|
||||
}
|
||||
) {
|
||||
@@ -73,10 +391,108 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog(
|
||||
});
|
||||
}
|
||||
|
||||
async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) {
|
||||
async function expectToContainValidCode(
|
||||
page: Page,
|
||||
{
|
||||
embedType,
|
||||
language,
|
||||
orgSlug,
|
||||
}: { embedType: EmbedType; language: "html" | "react"; orgSlug: string | null }
|
||||
) {
|
||||
if (language === "react") {
|
||||
return expectValidReactEmbedSnippet(page, { embedType, orgSlug });
|
||||
}
|
||||
if (language === "html") {
|
||||
return expectValidHtmlEmbedSnippet(page, { embedType, orgSlug });
|
||||
}
|
||||
throw new Error("Unknown language");
|
||||
}
|
||||
|
||||
async function expectValidHtmlEmbedSnippet(
|
||||
page: Page,
|
||||
{ embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null }
|
||||
) {
|
||||
const embedCode = await page.locator("[data-testid=embed-code]").inputValue();
|
||||
expect(embedCode.includes("(function (C, A, L)")).toBe(true);
|
||||
expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true);
|
||||
expect(embedCode).toContain("function (C, A, L)");
|
||||
expect(embedCode).toContain(`Cal ${embedType} embed code begins`);
|
||||
if (orgSlug) {
|
||||
expect(embedCode).toContain(orgSlug);
|
||||
}
|
||||
|
||||
const dom = parse(embedCode);
|
||||
const scripts = dom.getElementsByTagName("script");
|
||||
assertThatCodeIsValidVanillaJsCode(scripts[0].innerText);
|
||||
|
||||
return {
|
||||
message: () => `passed`,
|
||||
pass: true,
|
||||
};
|
||||
}
|
||||
|
||||
function assertThatCodeIsValidVanillaJsCode(code: string) {
|
||||
const lintResult = linter.verify(code, {
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
},
|
||||
globals: {
|
||||
Cal: "readonly",
|
||||
},
|
||||
rules: eslintRules,
|
||||
});
|
||||
if (lintResult.length) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
lintResult,
|
||||
code,
|
||||
})
|
||||
);
|
||||
}
|
||||
expect(lintResult.length).toBe(0);
|
||||
}
|
||||
|
||||
function assertThatCodeIsValidReactCode(code: string) {
|
||||
const lintResult = linter.verify(code, {
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
sourceType: "module",
|
||||
},
|
||||
rules: eslintRules,
|
||||
});
|
||||
if (lintResult.length) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
lintResult,
|
||||
code,
|
||||
})
|
||||
);
|
||||
}
|
||||
expect(lintResult.length).toBe(0);
|
||||
}
|
||||
|
||||
async function expectValidReactEmbedSnippet(
|
||||
page: Page,
|
||||
{ embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null }
|
||||
) {
|
||||
const embedCode = await page.locator("[data-testid=embed-react]").inputValue();
|
||||
expect(embedCode).toContain("export default function MyApp(");
|
||||
expect(embedCode).toContain(
|
||||
embedType === "floating-popup" ? "floatingButton" : embedType === "inline" ? `<Cal` : "data-cal-link"
|
||||
);
|
||||
if (orgSlug) {
|
||||
expect(embedCode).toContain(orgSlug);
|
||||
}
|
||||
|
||||
assertThatCodeIsValidReactCode(embedCode);
|
||||
|
||||
return {
|
||||
message: () => `passed`,
|
||||
pass: true,
|
||||
@@ -88,141 +504,10 @@ async function expectToContainValidCode(page: Page, { embedType }: { embedType:
|
||||
*/
|
||||
async function expectToContainValidPreviewIframe(
|
||||
page: Page,
|
||||
{ embedType, calLink }: { embedType: string; calLink: string }
|
||||
{ embedType, calLink, bookerUrl }: { embedType: EmbedType; calLink: string; bookerUrl?: string }
|
||||
) {
|
||||
const bookerUrl = `${WEBAPP_URL}`;
|
||||
bookerUrl = bookerUrl || `${WEBAPP_URL}`;
|
||||
expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain(
|
||||
`/preview.html?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${EMBED_LIB_URL}&bookerUrl=${bookerUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Embed Code Generator Tests", () => {
|
||||
test.beforeEach(async ({ users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
});
|
||||
|
||||
test.describe("Event Types Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => {
|
||||
const [pro] = users.get();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "inline",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
await expectToContainValidCode(page, { embedType: "inline" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => {
|
||||
const [pro] = users.get();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "floating-popup");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "floating-popup",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, { embedType: "floating-popup" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "floating-popup",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => {
|
||||
const [pro] = users.get();
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "element-click");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "element-click",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, { embedType: "element-click" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "element-click",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Event Type Edit Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/event-types`);
|
||||
await Promise.all([
|
||||
page.locator('a[href*="/event-types/"]').first().click(),
|
||||
page.waitForURL((url) => url.pathname.startsWith("/event-types/")),
|
||||
]);
|
||||
});
|
||||
|
||||
test("open Embed Dialog for the Event Type", async ({ page }) => {
|
||||
const basePage = new URL(page.url()).pathname;
|
||||
const embedUrl = await clickEmbedButton(page);
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
embedUrl,
|
||||
basePage,
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
basePage,
|
||||
embedType: "inline",
|
||||
});
|
||||
|
||||
await expectToContainValidCode(page, {
|
||||
embedType: "inline",
|
||||
});
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: decodeURIComponent(embedUrl),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,50 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Feature } from "@prisma/client";
|
||||
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
type FeatureSlugs = keyof AppFlags;
|
||||
|
||||
export const createFeatureFixture = (page: Page) => {
|
||||
const store = { features: [], page } as { features: Feature[]; page: typeof page };
|
||||
let initalFeatures: Feature[] = [];
|
||||
|
||||
// IIF to add all feautres to store on creation
|
||||
return {
|
||||
init: async () => {
|
||||
const features = await prisma.feature.findMany();
|
||||
store.features = features;
|
||||
initalFeatures = features;
|
||||
return features;
|
||||
},
|
||||
getAll: () => store.features,
|
||||
get: (slug: FeatureSlugs) => store.features.find((b) => b.slug === slug),
|
||||
deleteAll: async () => {
|
||||
await prisma.feature.deleteMany({
|
||||
where: { slug: { in: store.features.map((feature) => feature.slug) } },
|
||||
});
|
||||
store.features = [];
|
||||
},
|
||||
delete: async (slug: FeatureSlugs) => {
|
||||
await prisma.feature.delete({ where: { slug } });
|
||||
store.features = store.features.filter((b) => b.slug !== slug);
|
||||
},
|
||||
toggleFeature: async (slug: FeatureSlugs) => {
|
||||
const feature = store.features.find((b) => b.slug === slug);
|
||||
if (feature) {
|
||||
const enabled = !feature.enabled;
|
||||
await prisma.feature.update({ where: { slug }, data: { enabled } });
|
||||
store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b));
|
||||
}
|
||||
},
|
||||
set: async (slug: FeatureSlugs, enabled: boolean) => {
|
||||
const feature = store.features.find((b) => b.slug === slug);
|
||||
if (feature) {
|
||||
store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b));
|
||||
await prisma.feature.update({ where: { slug }, data: { enabled } });
|
||||
}
|
||||
},
|
||||
reset: () => (store.features = initalFeatures),
|
||||
};
|
||||
};
|
||||
@@ -73,10 +73,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
|
||||
},
|
||||
multiselect: async () => {
|
||||
if (customLocators.shouldChangeMultiSelectLocator) {
|
||||
await eventTypePage.locator("form svg").nth(1).click();
|
||||
await eventTypePage.getByLabel("multi-select-dropdown").click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
} else {
|
||||
await eventTypePage.locator("form svg").last().click();
|
||||
await eventTypePage.getByLabel("multi-select-dropdown").last().click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
}
|
||||
},
|
||||
@@ -88,10 +88,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
|
||||
},
|
||||
select: async () => {
|
||||
if (customLocators.shouldChangeSelectLocator) {
|
||||
await eventTypePage.locator("form svg").nth(1).click();
|
||||
await eventTypePage.getByLabel("select-dropdown").first().click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
} else {
|
||||
await eventTypePage.locator("form svg").last().click();
|
||||
await eventTypePage.getByLabel("select-dropdown").last().click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
}
|
||||
},
|
||||
@@ -138,11 +138,12 @@ const fillAllQuestions = async (eventTypePage: Page, questions: string[], option
|
||||
await eventTypePage.getByPlaceholder("Textarea test").fill("This is a sample text for textarea.");
|
||||
break;
|
||||
case "select":
|
||||
await eventTypePage.locator("form svg").last().click();
|
||||
await eventTypePage.getByLabel("select-dropdown").last().click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
break;
|
||||
case "multiselect":
|
||||
await eventTypePage.locator("form svg").nth(4).click();
|
||||
// select-dropdown
|
||||
await eventTypePage.getByLabel("multi-select-dropdown").click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
break;
|
||||
case "number":
|
||||
@@ -204,7 +205,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) {
|
||||
@@ -126,6 +143,17 @@ const createTeamAndAddUser = async (
|
||||
export const createUsersFixture = (page: Page, emails: API | undefined, workerInfo: WorkerInfo) => {
|
||||
const store = { users: [], page } as { users: UserFixture[]; page: typeof page };
|
||||
return {
|
||||
buildForSignup: (opts?: Pick<CustomUserOpts, "email" | "username" | "useExactUsername" | "password">) => {
|
||||
const uname =
|
||||
opts?.useExactUsername && opts?.username
|
||||
? opts.username
|
||||
: `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
|
||||
return {
|
||||
username: uname,
|
||||
email: opts?.email ?? `${uname}@example.com`,
|
||||
password: opts?.password ?? uname,
|
||||
};
|
||||
},
|
||||
create: async (
|
||||
opts?: CustomUserOpts | null,
|
||||
scenario: {
|
||||
@@ -135,7 +163,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 +313,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 +407,24 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||
await prisma.user.delete({ where: { id } });
|
||||
store.users = store.users.filter((b) => b.id !== id);
|
||||
},
|
||||
deleteByEmail: async (email: string) => {
|
||||
// Use deleteMany instead of delete to avoid the findUniqueOrThrow error that happens before the delete
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
store.users = store.users.filter((b) => b.email !== email);
|
||||
},
|
||||
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 +449,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");
|
||||
@@ -427,7 +480,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
}
|
||||
return membership;
|
||||
},
|
||||
getOrg: async () => {
|
||||
getOrgMembership: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -438,7 +491,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { team: { select: { children: true, metadata: true, name: true } } },
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
children: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
getFirstEventAsOwner: async () =>
|
||||
@@ -489,6 +548,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 +580,7 @@ const createUser = (
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
availability: {
|
||||
createMany: {
|
||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
||||
data: getAvailabilityFromSchedule(opts?.schedule ?? DEFAULT_SCHEDULE),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -641,7 +701,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,11 +4,13 @@ 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 { createEmbedsFixture } from "../fixtures/embeds";
|
||||
import { createFeatureFixture } from "../fixtures/features";
|
||||
import { createOrgsFixture } from "../fixtures/orgs";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createBookingPageFixture } from "../fixtures/regularBookings";
|
||||
@@ -28,6 +30,7 @@ export interface Fixtures {
|
||||
emails?: API;
|
||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
||||
features: ReturnType<typeof createFeatureFixture>;
|
||||
}
|
||||
|
||||
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,9 @@ export const test = base.extend<Fixtures>({
|
||||
const bookingPage = createBookingPageFixture(page);
|
||||
await use(bookingPage);
|
||||
},
|
||||
features: async ({ page }, use) => {
|
||||
const features = createFeatureFixture(page);
|
||||
await features.init();
|
||||
await use(features);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -201,6 +203,12 @@ export async function installAppleCalendar(page: Page) {
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
}
|
||||
|
||||
export async function getInviteLink(page: Page) {
|
||||
const response = await page.waitForResponse("**/api/trpc/teams/createInvite?batch=1");
|
||||
const json = await response.json();
|
||||
return json[0].result.data.json.inviteLink as string;
|
||||
}
|
||||
|
||||
export async function getEmailsReceivedByUser({
|
||||
emails,
|
||||
userEmail,
|
||||
@@ -278,3 +286,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user