Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528b27f31e | ||
|
|
4282cd221c | ||
|
|
38738c1a3a | ||
|
|
b5dcc7a991 | ||
|
|
5ceda11a0b | ||
|
|
787828d0ca | ||
|
|
89230474a5 | ||
|
|
d7fc11df5e | ||
|
|
1ff5d57ded | ||
|
|
19641ffc05 | ||
|
|
ea80e8c8d4 | ||
|
|
cba0fa8551 | ||
|
|
530cb94f0b | ||
|
|
d4ef6e83cf | ||
|
|
cddd8d7d9d | ||
|
|
1d6959c4ac | ||
|
|
a3fc17bc75 | ||
|
|
ad4b5957fc | ||
|
|
a7cb71726a | ||
|
|
3e7a848769 | ||
|
|
e6e67848b3 | ||
|
|
bf42cd4de9 | ||
|
|
6af7f8b0db | ||
|
|
421d03eb1a | ||
|
|
9d39742802 | ||
|
|
5267f8c3c1 | ||
|
|
c6ec42e735 | ||
|
|
1325e8488f | ||
|
|
d50a7b9d00 | ||
|
|
452729f4ab | ||
|
|
0206b364ee | ||
|
|
86353191d2 | ||
|
|
38f96d97d3 | ||
|
|
e6d44ce620 | ||
|
|
9e1e50e90b | ||
|
|
a73b804d48 | ||
|
|
4407a1952c | ||
|
|
dc3742d322 | ||
|
|
8b4f675cee | ||
|
|
53a931da4e | ||
|
|
228fbaf72e | ||
|
|
0c53717d0f | ||
|
|
e239b67171 | ||
|
|
fd390f9d43 | ||
|
|
eae779b81c | ||
|
|
6d621e0b60 | ||
|
|
ed3de528f3 | ||
|
|
92f1baa320 | ||
|
|
984cd64083 | ||
|
|
db8c7942a9 | ||
|
|
4e5d4f67d5 | ||
|
|
83488f0a35 | ||
|
|
9a52938f64 | ||
|
|
e24e1d8769 | ||
|
|
ef4be48ea6 | ||
|
|
5c9ad93923 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Refactor SettingsHeader to accept onBackButtonClick callback instead of using useRouter directly.
|
||||
Vendored
@@ -19,7 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
team: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
isAdminAPIEnabled: true,
|
||||
is: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -11,30 +11,6 @@ type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("PATCH /api/bookings", () => {
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
it("Returns 403 when user has no permission to the booking", async () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
|
||||
@@ -16,33 +16,14 @@ const DefaultPagination = {
|
||||
skip: 0,
|
||||
};
|
||||
|
||||
describe("GET /api/bookings", async () => {
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
describe("GET /api/bookings", () => {
|
||||
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
|
||||
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
beforeAll(async () => {
|
||||
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
});
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
it("Does not return bookings of other users when user has no permission", async () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -12,53 +12,6 @@ type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("isAdmin guard", () => {
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dunderOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "dunder-mifflin",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (dunderOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: dunderOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: false,
|
||||
},
|
||||
create: {
|
||||
organizationId: dunderOrg.id,
|
||||
orgAutoAcceptEmail: "dunder-mifflin.com",
|
||||
isAdminAPIEnabled: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
it("Returns false when user does not exist in the system", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -8,30 +8,6 @@ import {
|
||||
} from "../../../lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
describe("retrieveScopedAccessibleUsers tests", () => {
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
describe("getAccessibleUsers", () => {
|
||||
it("Does not return members when only admin user ID is supplied", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
|
||||
@@ -405,9 +405,9 @@ export class BookingsController_2024_08_13 {
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
@GetUser() reassignedByUser: ApiAuthGuardUser
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, reassignedByUser);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -430,13 +430,13 @@ export class BookingsController_2024_08_13 {
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Param("userId") userId: number,
|
||||
@GetUser("id") reassignedById: number,
|
||||
@GetUser() reassignedByUser: ApiAuthGuardUser,
|
||||
@Body() body: ReassignToUserBookingInput_2024_08_13
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBookingToUser(
|
||||
bookingUid,
|
||||
userId,
|
||||
reassignedById,
|
||||
reassignedByUser,
|
||||
body
|
||||
);
|
||||
|
||||
|
||||
+18
-17
@@ -12,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
@@ -20,7 +21,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -72,6 +72,7 @@ type EmailSetup = {
|
||||
team: Team;
|
||||
member1: User;
|
||||
member2: User;
|
||||
member1ApiKey: string;
|
||||
collectiveEventType: { id: number };
|
||||
roundRobinEventType: { id: number };
|
||||
};
|
||||
@@ -89,25 +90,21 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
|
||||
// Setup data for tests
|
||||
let emailsEnabledSetup: EmailSetup;
|
||||
let emailsDisabledSetup: EmailSetup;
|
||||
|
||||
const authEmail = "team-emails-2024-08-13-user-admin@example.com";
|
||||
|
||||
// Utility function to check response data type
|
||||
const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
const responseDataIsBooking = (data: unknown): data is BookingOutput_2024_08_13 => {
|
||||
return !Array.isArray(data) && data !== null && typeof data === "object" && data && "id" in data;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
authEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -122,6 +119,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
// Create a base organization for all tests
|
||||
@@ -131,11 +129,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
emailsEnabledSetup = await setupTestEnvironment(true);
|
||||
emailsDisabledSetup = await setupTestEnvironment(false);
|
||||
|
||||
await userRepositoryFixture.create({
|
||||
email: authEmail,
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
@@ -173,10 +166,15 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]);
|
||||
const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]);
|
||||
|
||||
// Create API key for member1 to use in authorized tests
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(member1.id, null);
|
||||
const member1ApiKey = `cal_test_${keyString}`;
|
||||
|
||||
return {
|
||||
team,
|
||||
member1,
|
||||
member2,
|
||||
member1ApiKey,
|
||||
collectiveEventType: { id: collectiveEvent.id },
|
||||
roundRobinEventType: { id: roundRobinEvent.id },
|
||||
};
|
||||
@@ -351,6 +349,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
: emailsDisabledSetup.member1.id;
|
||||
const manualReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(manualReassignResponse.status).toBe(200);
|
||||
@@ -359,6 +358,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
// --- 4. Automatic Reassign ---
|
||||
const autoReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(autoReassignResponse.status).toBe(200);
|
||||
@@ -484,6 +484,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
|
||||
const manualReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(manualReassignResponse.status).toBe(200);
|
||||
@@ -499,6 +500,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
jest.clearAllMocks();
|
||||
const autoReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(autoReassignResponse.status).toBe(200);
|
||||
@@ -523,7 +525,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
afterAll(async () => {
|
||||
// Clean up database records
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(authEmail);
|
||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
|
||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email);
|
||||
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
|
||||
|
||||
+67
-11
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||
import { BOOKING_REASSIGN_PERMISSION_ERROR } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
@@ -11,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
@@ -21,15 +23,11 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Reassign bookings", () => {
|
||||
let app: INestApplication;
|
||||
@@ -47,6 +45,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
|
||||
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
||||
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
||||
@@ -54,6 +53,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let teamUser1: User;
|
||||
let teamUser2: User;
|
||||
let teamUser3: User;
|
||||
let teamUser1ApiKey: string;
|
||||
let teamUser2ApiKey: string;
|
||||
|
||||
let teamRoundRobinEventTypeId: number;
|
||||
let teamRoundRobinFixedHostEventTypeId: number;
|
||||
@@ -69,12 +70,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let rescheduleReasonBookingInitialHostId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamUserEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -90,6 +88,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
@@ -126,6 +125,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teamUser1.id, null);
|
||||
teamUser1ApiKey = `cal_test_${keyString}`;
|
||||
|
||||
const { keyString: keyString2 } = await apiKeysRepositoryFixture.createApiKey(teamUser2.id, null);
|
||||
teamUser2ApiKey = `cal_test_${keyString2}`;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
@@ -473,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -512,6 +518,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
|
||||
.send(body)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -562,6 +569,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser2ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -611,6 +619,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -669,6 +678,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -704,6 +714,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -724,6 +735,51 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 403 when unauthorized user tries to reassign booking", async () => {
|
||||
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
|
||||
const unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedUserEmail,
|
||||
locale: "en",
|
||||
name: `fake-user-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
const unauthorizedApiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
|
||||
});
|
||||
|
||||
it("should return 403 when unauthorized user tries to reassign booking to specific user", async () => {
|
||||
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
|
||||
const unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedUserEmail,
|
||||
locale: "en",
|
||||
name: `fake-user-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
const unauthorizedApiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser2.id}`)
|
||||
.send({ reason: "Testing unauthorized access" })
|
||||
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
@@ -749,4 +805,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,17 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithEventType(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
include: {
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithUser(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
@@ -38,6 +38,8 @@ import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
|
||||
|
||||
import {
|
||||
getTranslation,
|
||||
getAllUserBookings,
|
||||
@@ -992,19 +994,34 @@ export class BookingsService_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
async reassignBooking(bookingUid: string, reassignedByUser: ApiAuthGuardUser) {
|
||||
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
if (!booking.eventType) {
|
||||
throw new BadRequestException(
|
||||
`Event type with id=${booking.eventTypeId} was not found in the database`
|
||||
);
|
||||
}
|
||||
|
||||
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||
reassignedByUser,
|
||||
booking.eventType
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
}
|
||||
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||
const profile = this.usersService.getUserMainProfile(reassignedByUser);
|
||||
|
||||
try {
|
||||
await roundRobinReassignment({
|
||||
@@ -1012,7 +1029,7 @@ export class BookingsService_2024_08_13 {
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
reassignedById: reassignedByUser.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@@ -1036,13 +1053,27 @@ export class BookingsService_2024_08_13 {
|
||||
async reassignBookingToUser(
|
||||
bookingUid: string,
|
||||
newUserId: number,
|
||||
reassignedById: number,
|
||||
reassignedByUser: ApiAuthGuardUser,
|
||||
body: ReassignToUserBookingInput_2024_08_13
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
if (!booking.eventType) {
|
||||
throw new BadRequestException(
|
||||
`Event type with id=${booking.eventTypeId} was not found in the database`
|
||||
);
|
||||
}
|
||||
|
||||
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||
reassignedByUser,
|
||||
booking.eventType
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findByIdWithProfile(newUserId);
|
||||
if (!user) {
|
||||
@@ -1063,7 +1094,7 @@ export class BookingsService_2024_08_13 {
|
||||
newUserId,
|
||||
orgId: profile?.organizationId || null,
|
||||
reassignReason: body.reason,
|
||||
reassignedById,
|
||||
reassignedById: reassignedByUser.id,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
+3
@@ -1381,6 +1381,9 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType.ownerId).toEqual(user.id);
|
||||
expect(Array.isArray(fetchedEventType.users)).toEqual(true);
|
||||
expect(fetchedEventType.users.length).toEqual(1);
|
||||
expect(fetchedEventType.users[0].id).toEqual(fetchedEventType.ownerId);
|
||||
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
|
||||
+11
-4
@@ -7,6 +7,7 @@ import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_202
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import type { DatabaseEventType } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import {
|
||||
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
@@ -25,6 +26,7 @@ import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/op
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import type { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -132,10 +134,9 @@ export class EventTypesController_2024_06_14 {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const responseEventType =
|
||||
"hosts" in eventType
|
||||
? await this.outputTeamEventTypesResponsePipe.transform(eventType)
|
||||
: this.eventTypeResponseTransformPipe.transform(eventType);
|
||||
const responseEventType = this.isTeamEventType(eventType)
|
||||
? await this.outputTeamEventTypesResponsePipe.transform(eventType)
|
||||
: this.eventTypeResponseTransformPipe.transform(eventType);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -143,6 +144,12 @@ export class EventTypesController_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
private isTeamEventType(
|
||||
eventType: DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType)
|
||||
): eventType is DatabaseTeamEventType {
|
||||
return !!eventType.teamId;
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Get all event types",
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("Me Endpoints", () => {
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
const userEmail = `me-controller-user-${randomString()}@api.com`;
|
||||
const name = "Me Controller User";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
|
||||
@@ -55,6 +56,7 @@ describe("Me Endpoints", () => {
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
name,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
@@ -92,6 +94,7 @@ describe("Me Endpoints", () => {
|
||||
|
||||
expect(responseBody.data.id).toEqual(user.id);
|
||||
expect(responseBody.data.email).toEqual(user.email);
|
||||
expect(responseBody.data.name).toEqual(user.name);
|
||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||
|
||||
@@ -23,6 +23,10 @@ export class MeOutput {
|
||||
@DocsProperty()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ type: String, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@IsInt()
|
||||
@DocsProperty()
|
||||
timeFormat!: number;
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
HostPriority,
|
||||
EmailSettings_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
export const HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR =
|
||||
"Hosts required when switching schedulingType. Please provide 'hosts' or set 'assignAllTeamMembers: true' to specify how hosts should be configured for the new scheduling type.";
|
||||
|
||||
export type TransformedCreateTeamEventTypeInput = Awaited<
|
||||
ReturnType<InstanceType<typeof InputOrganizationsEventTypesService>["transformInputCreateTeamEventType"]>
|
||||
@@ -116,10 +120,12 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
|
||||
|
||||
const isManagedEventType = rest.schedulingType === "MANAGED";
|
||||
|
||||
const defaultLocations: CreateTeamEventTypeInput_2024_06_14["locations"] = [
|
||||
{
|
||||
type: "integration",
|
||||
@@ -127,12 +133,13 @@ export class InputOrganizationsEventTypesService {
|
||||
},
|
||||
];
|
||||
|
||||
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
|
||||
const children = isManagedEventType
|
||||
? await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId)
|
||||
: undefined;
|
||||
|
||||
let metadata =
|
||||
rest.schedulingType === "MANAGED"
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
let metadata = isManagedEventType
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
|
||||
if (emailSettings) {
|
||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||
@@ -140,12 +147,7 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
hosts: !(rest.schedulingType === "MANAGED")
|
||||
? assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, inputEventType.schedulingType)
|
||||
: undefined,
|
||||
hosts: await this.transformInputCreateTeamEventTypeHosts(teamId, inputEventType),
|
||||
assignAllTeamMembers,
|
||||
locations: this.transformInputTeamLocations(locations || defaultLocations),
|
||||
metadata,
|
||||
@@ -155,6 +157,24 @@ export class InputOrganizationsEventTypesService {
|
||||
return teamEventType;
|
||||
}
|
||||
|
||||
private async transformInputCreateTeamEventTypeHosts(
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, schedulingType } = inputEventType;
|
||||
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children - each child managed event type is associated with
|
||||
// a specific user and hosts property is only for team event types e.g round robin and collective.
|
||||
if (schedulingType === "MANAGED") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (assignAllTeamMembers) {
|
||||
return await this.getAllTeamMembers(teamId, inputEventType.schedulingType);
|
||||
}
|
||||
return this.transformInputHosts(hosts, inputEventType.schedulingType);
|
||||
}
|
||||
|
||||
private addEmailSettingsToMetadata(
|
||||
emailSettings: EmailSettings_2024_06_14,
|
||||
metadata: NonNullable<EventTypeMetadata>
|
||||
@@ -190,7 +210,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
|
||||
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
||||
@@ -213,11 +233,11 @@ export class InputOrganizationsEventTypesService {
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
hosts: !children
|
||||
? assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, dbEventType.schedulingType)
|
||||
: undefined,
|
||||
hosts: await this.transformInputUpdateTeamEventTypeHosts(
|
||||
teamId,
|
||||
dbEventType.schedulingType,
|
||||
inputEventType
|
||||
),
|
||||
assignAllTeamMembers,
|
||||
children,
|
||||
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
|
||||
@@ -227,6 +247,33 @@ export class InputOrganizationsEventTypesService {
|
||||
return teamEventType;
|
||||
}
|
||||
|
||||
private async transformInputUpdateTeamEventTypeHosts(
|
||||
teamId: number,
|
||||
dbEventTypeSchedulingType: EventType["schedulingType"],
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers } = inputEventType;
|
||||
|
||||
if (dbEventTypeSchedulingType === "MANAGED") {
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the event type children
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isSchedulingTypeChanging =
|
||||
inputEventType.schedulingType && inputEventType.schedulingType !== dbEventTypeSchedulingType;
|
||||
|
||||
if (isSchedulingTypeChanging && !assignAllTeamMembers && !hosts) {
|
||||
throw new BadRequestException(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
|
||||
}
|
||||
|
||||
const nextSchedulingType = inputEventType.schedulingType || dbEventTypeSchedulingType;
|
||||
if (assignAllTeamMembers) {
|
||||
return await this.getAllTeamMembers(teamId, nextSchedulingType);
|
||||
}
|
||||
|
||||
return this.transformInputHosts(hosts, nextSchedulingType);
|
||||
}
|
||||
|
||||
async getChildEventTypesForManagedEventTypeUpdate(
|
||||
eventTypeId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
@@ -273,7 +320,7 @@ export class InputOrganizationsEventTypesService {
|
||||
}
|
||||
|
||||
async getChildEventTypesForManagedEventTypeCreate(
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">,
|
||||
teamId: number
|
||||
) {
|
||||
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
|
||||
@@ -289,7 +336,7 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
async getOwnersIdsForManagedEventTypeCreate(
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">
|
||||
) {
|
||||
if (inputEventType.assignAllTeamMembers) {
|
||||
return await this.getTeamUsersIds(teamId);
|
||||
|
||||
+95
@@ -209,4 +209,99 @@ describe("WebhooksController (e2e)", () => {
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
describe("DELEGATION_CREDENTIAL_ERROR webhook trigger", () => {
|
||||
let delegationWebhook: TeamWebhookOutputResponseDto["data"];
|
||||
|
||||
it("/organizations/:orgId/webhooks (POST) should create webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com/delegation-errors",
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(201)
|
||||
.then(async (res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com/delegation-errors",
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
teamId: org.id,
|
||||
});
|
||||
delegationWebhook = res.body.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (GET) should retrieve webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: delegationWebhook.id,
|
||||
subscriberUrl: "https://example.com/delegation-errors",
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
teamId: org.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (PATCH) should update webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
|
||||
.send({
|
||||
active: false,
|
||||
subscriberUrl: "https://example.com/delegation-errors-updated",
|
||||
} satisfies UpdateWebhookInputDto)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.data.active).toBe(false);
|
||||
expect(res.body.data.subscriberUrl).toBe("https://example.com/delegation-errors-updated");
|
||||
expect(res.body.data.triggers).toEqual(["DELEGATION_CREDENTIAL_ERROR"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks (POST) should allow combining DELEGATION_CREDENTIAL_ERROR with other triggers", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com/combined-webhook",
|
||||
triggers: ["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(201)
|
||||
.then(async (res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com/combined-webhook",
|
||||
triggers: expect.arrayContaining(["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"]),
|
||||
active: true,
|
||||
teamId: org.id,
|
||||
});
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${res.body.data.id}`)
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (DELETE) should delete webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: delegationWebhook.id,
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
teamId: org.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+581
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR } from "@/modules/organizations/event-types/services/input.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -758,6 +759,586 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(expected.priority).toEqual(received?.priority);
|
||||
}
|
||||
|
||||
describe("updating scheduling type", () => {
|
||||
it("should return 400 error if schedulingType: managed is passed", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
description: "Test collective event type",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
schedulingType: "managed",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody)
|
||||
.expect(400);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
|
||||
it("should require hosts when changing round robin event type to collective without providing hosts", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-scheduling-roundrobin-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-roundrobin-${randomString()}`,
|
||||
description: "Test round robin event type",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
priority: "low",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
schedulingType: "collective",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
|
||||
it("should require hosts when changing collective event type to roundRobin without providing hosts", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
description: "Test collective event type",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
schedulingType: "roundRobin",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
|
||||
it("should change round robin event type to collective and pass new hosts", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-scheduling-roundrobin-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-roundrobin-${randomString()}`,
|
||||
description: "Test round robin event type",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
priority: "high",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
schedulingType: "collective",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.schedulingType).toEqual("collective");
|
||||
expect(responseBody.data.hosts).toHaveLength(1);
|
||||
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
|
||||
it("should change collective event type to roundRobin and pass new hosts", async () => {
|
||||
const createBody = {
|
||||
title: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
description: "Test collective event type",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "collective",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
schedulingType: "roundRobin",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.schedulingType).toEqual("roundRobin");
|
||||
expect(responseBody.data.hosts).toHaveLength(1);
|
||||
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
|
||||
it("should change collective event type to roundRobin with assignAllTeamMembers: true", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-collective-${randomString()}`,
|
||||
description: "Test collective event type",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
schedulingType: "roundRobin",
|
||||
assignAllTeamMembers: true,
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.schedulingType).toEqual("roundRobin");
|
||||
expect(responseBody.data.hosts).toHaveLength(3);
|
||||
const hostUserIds = responseBody.data.hosts.map((host) => host.userId);
|
||||
expect(hostUserIds).toContain(teamMember1.id);
|
||||
expect(hostUserIds).toContain(teamMember2.id);
|
||||
expect(hostUserIds).toContain(userAdmin.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
|
||||
it("should change round robin event type to collective with assignAllTeamMembers: true", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-scheduling-roundrobin-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-roundrobin-${randomString()}`,
|
||||
description: "Test round robin event type",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
priority: "high",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
schedulingType: "collective",
|
||||
assignAllTeamMembers: true,
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.schedulingType).toEqual("collective");
|
||||
expect(responseBody.data.hosts).toHaveLength(3);
|
||||
const hostUserIds = responseBody.data.hosts.map((host) => host.userId);
|
||||
expect(hostUserIds).toContain(teamMember1.id);
|
||||
expect(hostUserIds).toContain(teamMember2.id);
|
||||
expect(hostUserIds).toContain(userAdmin.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
|
||||
it("should preserve existing hosts when updating without changing scheduling type", async () => {
|
||||
const createBody = {
|
||||
title: `teams-event-types-scheduling-preserve-${randomString()}`,
|
||||
slug: `teams-event-types-scheduling-preserve-${randomString()}`,
|
||||
description: "Test preserve hosts",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "collective",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
|
||||
|
||||
const updateBody = {
|
||||
title: "Updated title",
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.title).toEqual("Updated title");
|
||||
expect(responseBody.data.schedulingType).toEqual("collective");
|
||||
expect(responseBody.data.hosts).toHaveLength(2);
|
||||
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember1.id);
|
||||
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember2.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should update event type title", () => {
|
||||
it("should preserve hosts when updating collective event type title", async () => {
|
||||
const collectiveEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `collective-preserve-hosts-${randomString()}`,
|
||||
slug: `collective-preserve-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
team: {
|
||||
connect: {
|
||||
id: team.id,
|
||||
},
|
||||
},
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
isFixed: true,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
isFixed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
userAdmin.id
|
||||
);
|
||||
|
||||
const newTitle = `updated-collective-title-${randomString()}`;
|
||||
const updateBody = {
|
||||
title: newTitle,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.title).toEqual(newTitle);
|
||||
expect(responseBody.data.schedulingType).toEqual("collective");
|
||||
expect(responseBody.data.hosts).toHaveLength(2);
|
||||
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember1.id);
|
||||
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember2.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(collectiveEventType.id);
|
||||
});
|
||||
|
||||
it("should preserve hosts when updating round robin event type title", async () => {
|
||||
const roundRobinEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `roundrobin-preserve-hosts-${randomString()}`,
|
||||
slug: `roundrobin-preserve-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
team: {
|
||||
connect: {
|
||||
id: team.id,
|
||||
},
|
||||
},
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
isFixed: false,
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
isFixed: true,
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
userAdmin.id
|
||||
);
|
||||
|
||||
const newTitle = `updated-roundrobin-title-${randomString()}`;
|
||||
const updateBody = {
|
||||
title: newTitle,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${roundRobinEventType.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.title).toEqual(newTitle);
|
||||
expect(responseBody.data.schedulingType).toEqual("roundRobin");
|
||||
expect(responseBody.data.hosts).toHaveLength(2);
|
||||
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember1.id);
|
||||
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember2.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(roundRobinEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should update event type hosts", () => {
|
||||
it("should update collective event type hosts", async () => {
|
||||
const collectiveEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `collective-update-hosts-${randomString()}`,
|
||||
slug: `collective-update-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
team: {
|
||||
connect: {
|
||||
id: team.id,
|
||||
},
|
||||
},
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
isFixed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
userAdmin.id
|
||||
);
|
||||
|
||||
const updateBody = {
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.schedulingType).toEqual("collective");
|
||||
expect(responseBody.data.hosts).toHaveLength(1);
|
||||
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(collectiveEventType.id);
|
||||
});
|
||||
|
||||
it("should update round robin event type hosts", async () => {
|
||||
const roundRobinEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `roundrobin-update-hosts-${randomString()}`,
|
||||
slug: `roundrobin-update-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
team: {
|
||||
connect: {
|
||||
id: team.id,
|
||||
},
|
||||
},
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
isFixed: true,
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
userAdmin.id
|
||||
);
|
||||
|
||||
const updateBody = {
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
priority: "high",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${roundRobinEventType.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.schedulingType).toEqual("roundRobin");
|
||||
expect(responseBody.data.hosts).toHaveLength(1);
|
||||
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(roundRobinEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(teamMember1.email);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { ConflictException, Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
|
||||
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypeWebhooksService {
|
||||
constructor(private readonly webhooksRepository: WebhooksRepository) {}
|
||||
|
||||
async createEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) {
|
||||
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
|
||||
throw new BadRequestException(
|
||||
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"
|
||||
);
|
||||
}
|
||||
|
||||
const existingWebhook = await this.webhooksRepository.getEventTypeWebhookByUrl(
|
||||
eventTypeId,
|
||||
body.subscriberUrl
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { ConflictException, Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
|
||||
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
@Injectable()
|
||||
export class UserWebhooksService {
|
||||
constructor(private readonly webhooksRepository: WebhooksRepository) {}
|
||||
|
||||
async createUserWebhook(userId: number, body: PipedInputWebhookType) {
|
||||
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
|
||||
throw new BadRequestException(
|
||||
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"
|
||||
);
|
||||
}
|
||||
|
||||
const existingWebhook = await this.webhooksRepository.getUserWebhookByUrl(userId, body.subscriberUrl);
|
||||
if (existingWebhook) {
|
||||
throw new ConflictException("Webhook with this subscriber url already exists for this user");
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { viewParser, type BookingView } from "~/bookings/lib/viewParser";
|
||||
|
||||
export function ViewToggleButton() {
|
||||
const { t } = useLocale();
|
||||
const [view, setView] = useQueryState(
|
||||
"view",
|
||||
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
|
||||
);
|
||||
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// Force list view on mobile
|
||||
if (isMobile && view !== "list") {
|
||||
setView("list");
|
||||
}
|
||||
}, [isMobile, view, setView]);
|
||||
|
||||
return (
|
||||
<div className="hidden sm:block">
|
||||
<ToggleGroup
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const newView = value as BookingView;
|
||||
|
||||
// When switching from calendar to list view, remove the dateRange filter
|
||||
if (view === "calendar" && newView === "list") {
|
||||
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
|
||||
}
|
||||
|
||||
setView(newView);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "list",
|
||||
label: "",
|
||||
tooltip: t("list_view"),
|
||||
iconLeft: <Icon name="menu" className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "calendar",
|
||||
label: "",
|
||||
tooltip: t("calendar_view"),
|
||||
iconLeft: <Icon name="calendar" className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -16,6 +14,8 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import BookingsList from "~/bookings/views/bookings-view";
|
||||
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
|
||||
const querySchema = z.object({
|
||||
status: z.enum(validStatuses),
|
||||
});
|
||||
@@ -54,18 +54,12 @@ const Page = async ({ params }: PageProps) => {
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"booking-calendar-view"
|
||||
);
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<ShellMainAppDir heading={t("bookings")} CTA={<ViewToggleButton />}>
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
permissions={{ canReadOthersBookings }}
|
||||
isCalendarViewEnabled={isCalendarViewEnabled}
|
||||
/>
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -33,3 +32,4 @@ const ServerPage = async () => {
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { OrganizationInviteEmailView } from "~/onboarding/organization/invite/email/organization-invite-email-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/invite/email"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <OrganizationInviteEmailView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/teams/invite/email"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <TeamInviteEmailView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
+5
-6
@@ -1,7 +1,7 @@
|
||||
import type { PageProps } from "app/_types";
|
||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
|
||||
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
|
||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
@@ -24,13 +24,12 @@ const Page = async ({ params: _params }: PageProps) => {
|
||||
const webhook = await webhookRepository.findByWebhookId(id);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
<SettingsHeaderWithBackButton
|
||||
title={t("edit_webhook")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
borderInShellHeader={true}
|
||||
backButton>
|
||||
borderInShellHeader={true}>
|
||||
<EditWebhookView webhook={webhook} />
|
||||
</SettingsHeader>
|
||||
</SettingsHeaderWithBackButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -79,15 +79,28 @@ async function handler(req: NextRequest) {
|
||||
clientId: client_id,
|
||||
};
|
||||
|
||||
const accessTokenExpiresIn = 1800; // 30 minutes
|
||||
|
||||
const access_token = jwt.sign(payloadAuthToken, secretKey, {
|
||||
expiresIn: 1800, // 30 min
|
||||
expiresIn: accessTokenExpiresIn,
|
||||
});
|
||||
|
||||
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
|
||||
expiresIn: 30 * 24 * 60 * 60, // 30 days
|
||||
});
|
||||
|
||||
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
|
||||
// @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
|
||||
return NextResponse.json(
|
||||
{ access_token, token_type: "bearer", refresh_token, expires_in: accessTokenExpiresIn },
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Cache-Control": "no-store",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
|
||||
import { sendOrganizerRequestReminderEmail } from "@calcom/emails/email-manager";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
|
||||
import { sendMonthlyDigestEmail } from "@calcom/emails/workflow-email-service";
|
||||
import { EventsInsights } from "@calcom/features/insights/server/events";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -295,7 +295,7 @@ async function postHandler(request: NextRequest) {
|
||||
|
||||
// Only send email if user has allowed to receive monthly digest emails
|
||||
if (owner.receiveMonthlyDigestEmail) {
|
||||
await sendMonthlyDigestEmails({
|
||||
await sendMonthlyDigestEmail({
|
||||
...EventData,
|
||||
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
|
||||
language: t,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { renderEmail } from "@calcom/emails";
|
||||
import renderEmail from "@calcom/emails/src/renderEmail";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const requestorIp = getIP(req);
|
||||
return NextResponse.json({ ip: requestorIp });
|
||||
}
|
||||
@@ -1,36 +1,83 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonAvatar, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
<div className="animate-pulse">
|
||||
{/* Table rows with separator at the beginning */}
|
||||
<div className="divide-subtle divide-y">
|
||||
{/* Month separator skeleton */}
|
||||
<div className="bg-muted rounded-t py-2">
|
||||
<SkeletonItem isHeader={true} />
|
||||
</div>
|
||||
<div className="bg-muted">
|
||||
<SkeletonText className="ml-2 mt-3 h-4 w-28 rounded" />
|
||||
</div>
|
||||
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
function SkeletonItem({ isHeader = false }: { isHeader?: boolean }) {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-5 w-16" />
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[132px_130px_185px_150px_140px_280px] gap-6 px-2",
|
||||
isHeader ? "py-2" : "py-2.5"
|
||||
)}>
|
||||
{/* Date column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-20")} />
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText className="h-6 w-16" />
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
</div>
|
||||
|
||||
{/* Time column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-28")} />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{/* Event column - 200px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-16" : "w-full")} />
|
||||
</div>
|
||||
|
||||
{/* Who column - 160px, Avatar group */}
|
||||
<div className="flex items-center -space-x-1">
|
||||
{isHeader && <SkeletonText className="h-4 w-20 rounded" />}
|
||||
{!isHeader && (
|
||||
<>
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-4 w-20 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Actions column - 280px */}
|
||||
<div className="mr-2 flex items-center justify-end gap-2">
|
||||
{isHeader && <SkeletonText className="h-4 w-16 rounded-md" />}
|
||||
{!isHeader && (
|
||||
<>
|
||||
<SkeletonText className="h-4 w-16 rounded-md" />
|
||||
<SkeletonText className="h-4 w-20 rounded-md" />
|
||||
<SkeletonText className="h-4 w-8 rounded-md" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+59
-18
@@ -1,4 +1,3 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -46,8 +45,12 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { BookingActionsDropdown } from "./BookingActionsDropdown";
|
||||
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
|
||||
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
|
||||
import { BookingActionsDropdown } from "./actions/BookingActionsDropdown";
|
||||
import {
|
||||
useBookingActionsStoreContext,
|
||||
BookingActionsStoreProvider,
|
||||
} from "./actions/BookingActionsStoreProvider";
|
||||
import {
|
||||
getPendingActions,
|
||||
getCancelEventAction,
|
||||
@@ -56,7 +59,7 @@ import {
|
||||
shouldShowIndividualReportButton,
|
||||
type BookingActionContext,
|
||||
getReportAction,
|
||||
} from "./bookingActions";
|
||||
} from "./actions/bookingActions";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
|
||||
@@ -94,10 +97,51 @@ const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooki
|
||||
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
};
|
||||
|
||||
const ConditionalLink = ({
|
||||
children,
|
||||
onClick,
|
||||
bookingLink,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
bookingLink: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (onClick) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
aria-label={t("view_booking_details")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={bookingLink} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
const parsedBooking = buildParsedBooking(booking);
|
||||
|
||||
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
const { onClick } = booking;
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
@@ -258,15 +302,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||
|
||||
const buildBookingLink = () => {
|
||||
const urlSearchParams = new URLSearchParams({
|
||||
allRemainingBookings: isTabRecurring.toString(),
|
||||
});
|
||||
if (booking.attendees?.[0]?.email) urlSearchParams.set("email", booking.attendees[0].email);
|
||||
return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
|
||||
};
|
||||
|
||||
const bookingLink = buildBookingLink();
|
||||
const bookingLink = buildBookingLink({
|
||||
bookingUid: booking.uid,
|
||||
allRemainingBookings: isTabRecurring,
|
||||
email: booking.attendees?.[0]?.email,
|
||||
});
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
@@ -321,7 +361,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{eventTypeColor && (
|
||||
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
|
||||
)}
|
||||
<Link href={bookingLink} className="ml-3">
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
@@ -350,7 +390,8 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
<Image
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
@@ -367,11 +408,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
|
||||
<Link href={bookingLink}>
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
@@ -454,7 +495,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
+139
-16
@@ -15,9 +15,9 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
@@ -31,6 +31,8 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { buildBookingLink } from "../../../modules/bookings/lib/buildBookingLink";
|
||||
import type { BookingItemProps } from "../types";
|
||||
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
getCancelEventAction,
|
||||
@@ -38,15 +40,29 @@ import {
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowEditActions,
|
||||
shouldShowPendingActions,
|
||||
getPendingActions,
|
||||
type BookingActionContext,
|
||||
} from "./bookingActions";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
interface BookingActionsDropdownProps {
|
||||
booking: BookingItemProps;
|
||||
size?: "xs" | "sm" | "base" | "lg";
|
||||
className?: string;
|
||||
/**
|
||||
* Whether to use a portal for the dropdown menu.
|
||||
* Set to false when rendering inside a Sheet/Dialog to keep the dropdown within the modal's stacking context.
|
||||
* @default true
|
||||
*/
|
||||
usePortal?: boolean;
|
||||
}
|
||||
|
||||
export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) {
|
||||
export function BookingActionsDropdown({
|
||||
booking,
|
||||
size = "base",
|
||||
className,
|
||||
usePortal = true,
|
||||
}: BookingActionsDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -162,6 +178,13 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
|
||||
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
|
||||
// Build booking confirmation link
|
||||
const bookingLink = buildBookingLink({
|
||||
bookingUid: booking.uid,
|
||||
allRemainingBookings: isRecurring,
|
||||
email: booking.attendees?.[0]?.email,
|
||||
});
|
||||
|
||||
const userEmail = booking.loggedInUser.userEmail;
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
const isAttendee = !!userSeat;
|
||||
@@ -209,6 +232,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate showPendingPayment based on payment logic
|
||||
const hasPayment = booking.payment.length > 0;
|
||||
const showPendingPayment = hasPayment;
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
booking,
|
||||
isUpcoming,
|
||||
@@ -226,7 +253,7 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
isDisabledCancelling,
|
||||
isDisabledRescheduling,
|
||||
isCalVideoLocation,
|
||||
showPendingPayment: false, // This will be calculated below
|
||||
showPendingPayment,
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
@@ -236,9 +263,25 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
|
||||
const cancelEventAction = getCancelEventAction(actionContext);
|
||||
|
||||
// Get pending actions (accept/reject)
|
||||
const shouldShowPending = shouldShowPendingActions(actionContext);
|
||||
const basePendingActions = shouldShowPending ? getPendingActions(actionContext) : [];
|
||||
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
|
||||
...action,
|
||||
disabled: mutation.isPending,
|
||||
onClick:
|
||||
action.id === "confirm"
|
||||
? () => bookingConfirm(true)
|
||||
: action.id === "reject"
|
||||
? () => setRejectionDialogIsOpen(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const shouldShowEdit = shouldShowEditActions(actionContext);
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
...action,
|
||||
disabled: !shouldShowEdit || action.disabled, // Disable all edit actions if shouldn't show edit actions
|
||||
onClick:
|
||||
action.id === "reschedule_request"
|
||||
? () => setIsOpenRescheduleDialog(true)
|
||||
@@ -494,26 +537,94 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
</>
|
||||
);
|
||||
|
||||
// Don't render dropdown if edit actions shouldn't be shown
|
||||
if (!shouldShowEditActions(actionContext)) {
|
||||
// Check if there are any available actions across all action groups
|
||||
const hasAnyAvailableActions = () => {
|
||||
// Check if any pending action is available
|
||||
const hasAvailablePendingAction = pendingActions.some((action) => !action.disabled);
|
||||
|
||||
// Check if any edit action is available
|
||||
const hasAvailableEditAction = editEventActions.some((action) => !action.disabled);
|
||||
|
||||
// Check if any after event action is available
|
||||
const hasAvailableAfterAction = afterEventActions.some((action) => !action.disabled);
|
||||
|
||||
// Check report and cancel actions
|
||||
const isReportAvailable = !reportActionWithHandler.disabled;
|
||||
const isCancelAvailable = !cancelEventAction.disabled;
|
||||
|
||||
return (
|
||||
hasAvailablePendingAction ||
|
||||
hasAvailableEditAction ||
|
||||
hasAvailableAfterAction ||
|
||||
isReportAvailable ||
|
||||
isCancelAvailable
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render dropdown if no actions are available
|
||||
if (!hasAnyAvailableActions()) {
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
// Conditional portal wrapper to avoid portal when inside Sheet/Dialog
|
||||
const ConditionalPortal = usePortal
|
||||
? DropdownMenuPortal
|
||||
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
size={size}
|
||||
StartIcon="ellipsis"
|
||||
data-testid="booking-actions-dropdown"
|
||||
className={className}
|
||||
// Prevent click from bubbling to parent row/container click handlers
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<ConditionalPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="rounded-lg">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="external-link"
|
||||
href={bookingLink}
|
||||
target="_blank"
|
||||
data-testid="view-booking"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{t("view")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{pendingActions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("booking_response")}</DropdownMenuLabel>
|
||||
{pendingActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
@@ -523,7 +634,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
@@ -540,7 +654,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
@@ -559,7 +676,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reportActionWithHandler.onClick?.();
|
||||
}}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
@@ -577,7 +697,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEventAction.onClick?.(e);
|
||||
}}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
@@ -586,7 +709,7 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</ConditionalPortal>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
+9
-9
@@ -54,7 +54,7 @@ function createMockContext(overrides: Partial<BookingActionContext> = {}): Booki
|
||||
locale: "en",
|
||||
bookingId: 1,
|
||||
noShow: false,
|
||||
} as any,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
id: 1,
|
||||
@@ -137,18 +137,18 @@ describe("Booking Actions", () => {
|
||||
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
id: "reject",
|
||||
label: "reject",
|
||||
icon: "ban",
|
||||
disabled: false,
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
id: "confirm",
|
||||
bookingId: 1,
|
||||
label: "confirm",
|
||||
icon: "check",
|
||||
disabled: false,
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
id: "reject",
|
||||
label: "reject",
|
||||
icon: "ban",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return reject action only for non-pending booking", () => {
|
||||
@@ -167,8 +167,8 @@ describe("Booking Actions", () => {
|
||||
});
|
||||
const actions = getPendingActions(context);
|
||||
|
||||
expect(actions[0].label).toBe("reject_all");
|
||||
expect(actions[1].label).toBe("confirm_all");
|
||||
expect(actions[0].label).toBe("confirm_all");
|
||||
expect(actions[1].label).toBe("reject_all");
|
||||
});
|
||||
});
|
||||
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
|
||||
import type { BookingItemProps } from "./types";
|
||||
import type { BookingItemProps } from "../types";
|
||||
|
||||
export interface BookingActionContext {
|
||||
booking: BookingItemProps;
|
||||
@@ -38,14 +38,7 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } =
|
||||
context;
|
||||
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
icon: "ban",
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
},
|
||||
];
|
||||
const actions: ActionType[] = [];
|
||||
|
||||
// For bookings with payment, only confirm if the booking is paid for
|
||||
// Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid)
|
||||
@@ -59,6 +52,13 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: "reject",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
icon: "ban",
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
export type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
@@ -14,4 +14,5 @@ export type BookingItemProps = BookingItem & {
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
isToday: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<DialogContent enableOverflow data-testid="reschedule-dialog">
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
|
||||
<Icon name="clock" className="m-auto h-6 w-6" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||
|
||||
@@ -16,6 +17,7 @@ import type { TRPCClientErrorLike } from "@trpc/client";
|
||||
interface UsernameAvailabilityFieldProps {
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ICustomUsernameProps extends UsernameAvailabilityFieldProps {
|
||||
@@ -37,14 +39,19 @@ const UsernameTextfield = dynamic(() => import("./UsernameTextfield").then((m) =
|
||||
});
|
||||
|
||||
export const UsernameAvailability = (props: ICustomUsernameProps) => {
|
||||
const { isPremium, ...otherProps } = props;
|
||||
const { isPremium, disabled, ...otherProps } = props;
|
||||
const UsernameAvailabilityComponent = isPremium ? PremiumTextfield : UsernameTextfield;
|
||||
return <UsernameAvailabilityComponent {...otherProps} />;
|
||||
// PremiumTextfield uses `readonly` prop, UsernameTextfield uses `disabled` prop
|
||||
const componentProps = isPremium
|
||||
? { ...otherProps, readonly: disabled }
|
||||
: { ...otherProps, disabled };
|
||||
return <UsernameAvailabilityComponent {...componentProps} />;
|
||||
};
|
||||
|
||||
export const UsernameAvailabilityField = ({
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
disabled,
|
||||
}: UsernameAvailabilityFieldProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
@@ -56,7 +63,7 @@ export const UsernameAvailabilityField = ({
|
||||
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
username: currentUsername,
|
||||
username: slugify(currentUsername || user.username || ""),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,10 +85,14 @@ export const UsernameAvailabilityField = ({
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={value}
|
||||
usernameRef={ref}
|
||||
setInputUsernameValue={onChange}
|
||||
setInputUsernameValue={(val) => {
|
||||
const displayValue = slugify(val, true);
|
||||
formMethods.setValue("username", displayValue);
|
||||
onChange?.(displayValue);
|
||||
}}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
disabled={!!user.organization?.id}
|
||||
disabled={disabled ?? !!user.organization?.id}
|
||||
addOnLeading={`${usernamePrefix}/`}
|
||||
isPremium={isPremium}
|
||||
/>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/** @deprecated use zod instead */
|
||||
export function asStringOrNull(str: unknown) {
|
||||
return typeof str === "string" ? str : null;
|
||||
}
|
||||
|
||||
/** @deprecated use zod instead */
|
||||
export function asStringOrUndefined(str: unknown) {
|
||||
return typeof str === "string" ? str : undefined;
|
||||
}
|
||||
|
||||
/** @deprecated use zod instead */
|
||||
export function asNumberOrUndefined(str: unknown) {
|
||||
return typeof str === "string" ? parseInt(str) : undefined;
|
||||
}
|
||||
|
||||
/** @deprecated use zod instead */
|
||||
export function asNumberOrThrow(str: unknown) {
|
||||
return parseInt(asStringOrThrow(str));
|
||||
}
|
||||
|
||||
/** @deprecated use zod instead */
|
||||
export function asStringOrThrow(str: unknown): string {
|
||||
if (typeof str !== "string") {
|
||||
throw new Error(`Expected "string" - got ${typeof str}`);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
@@ -2,7 +2,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(false);
|
||||
const [matches, setMatches] = useState(() => {
|
||||
// Initialize with actual media query value on client
|
||||
if (typeof window !== "undefined") {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
@@ -10,8 +16,8 @@ const useMediaQuery = (query: string) => {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
|
||||
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
+117
-43
@@ -1,6 +1,6 @@
|
||||
// Import mocked functions
|
||||
import { get as edgeConfigGet } from "@vercel/edge-config";
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { Mock } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { checkPostMethod } from "./middleware";
|
||||
// We'll test the wrapped middleware as it would be used in production
|
||||
import middleware from "./middleware";
|
||||
import { config } from "./middleware";
|
||||
|
||||
// Mock dependencies at module level
|
||||
vi.mock("@vercel/edge-config", () => ({
|
||||
@@ -16,6 +17,7 @@ vi.mock("@vercel/edge-config", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("next-collect/server", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
collectEvents: vi.fn((config: any) => config.middleware),
|
||||
}));
|
||||
|
||||
@@ -29,11 +31,13 @@ vi.mock("next/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("next/server")>("next/server");
|
||||
|
||||
// Create a NextResponse constructor that returns Response objects
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const NextResponse = function (body: any, init?: ResponseInit) {
|
||||
return new Response(body, init);
|
||||
};
|
||||
|
||||
// Add static methods
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
NextResponse.json = (body: any, init?: ResponseInit) => {
|
||||
return new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
@@ -55,6 +59,7 @@ vi.mock("next/server", async () => {
|
||||
});
|
||||
|
||||
// Add cookies property
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(response as any).cookies = {
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
@@ -91,6 +96,7 @@ vi.mock("next/server", async () => {
|
||||
});
|
||||
|
||||
// Add cookies property
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(response as any).cookies = {
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
@@ -128,6 +134,7 @@ const createTestRequest = (overrides?: {
|
||||
return req;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createEdgeConfigMock = (config: Record<string, any>) => {
|
||||
return (key: string) => {
|
||||
if (key in config) return Promise.resolve(config[key]);
|
||||
@@ -147,6 +154,7 @@ const expectStatus = (res: Response, status: number) => {
|
||||
|
||||
// Wrapper for middleware calls to handle type casting
|
||||
const callMiddleware = async (req: NextRequest): Promise<Response> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (await (middleware as any)(req)) as Response;
|
||||
};
|
||||
|
||||
@@ -165,15 +173,6 @@ describe("Middleware - POST requests restriction", () => {
|
||||
expect(res1).toBeNull();
|
||||
});
|
||||
|
||||
it("should block POST requests to not-allowed app routes", async () => {
|
||||
const req = createRequest("/team/xyz", "POST");
|
||||
const res = checkPostMethod(req);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.status).toBe(405);
|
||||
expect(res?.statusText).toBe("Method Not Allowed");
|
||||
expect(res?.headers.get("Allow")).toBe("GET");
|
||||
});
|
||||
|
||||
it("should allow GET requests to app routes", async () => {
|
||||
const req = createRequest("/team/xyz", "GET");
|
||||
const res = checkPostMethod(req);
|
||||
@@ -213,29 +212,6 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST Method Protection", () => {
|
||||
it("should allow POST to /api/auth/signup", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/api/auth/signup`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
|
||||
it("should block POST to regular routes", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 405);
|
||||
expect(getHeader(res, "Allow")).toBe("GET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Maintenance Mode", () => {
|
||||
it("should redirect to maintenance when enabled", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
@@ -429,24 +405,58 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple Features", () => {
|
||||
it("should handle POST protection before maintenance mode", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
createEdgeConfigMock({
|
||||
isInMaintenanceMode: true,
|
||||
})
|
||||
);
|
||||
describe("Header sanitization", () => {
|
||||
it("sanitizes non-ASCII request header values to prevent Vercel Runtime Malformed Response Header", async () => {
|
||||
const spy = vi.spyOn(NextResponse, "next");
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
method: "POST",
|
||||
// Next.js will translate request overrides into x-middleware-request-* headers internally
|
||||
headers: {
|
||||
"cf-region": "São Paulo", // contains non-ASCII "ã"
|
||||
},
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
// POST protection should trigger first
|
||||
expectStatus(res, 405);
|
||||
expectStatus(res, 200);
|
||||
|
||||
// Assert that middleware forwarded sanitized ASCII-only value
|
||||
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
|
||||
request?: { headers?: Headers };
|
||||
};
|
||||
expect(initArg?.request?.headers).toBeDefined();
|
||||
|
||||
const forwarded = initArg?.request?.headers as Headers;
|
||||
expect(forwarded.get("cf-region")).toBe("Sao Paulo");
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("strips non-ASCII bytes in mojibake values (e.g., 'São Paulo' -> 'So Paulo')", async () => {
|
||||
const spy = vi.spyOn(NextResponse, "next");
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
headers: {
|
||||
"cf-region": "São Paulo", // mojibake for "São Paulo"; includes non-ASCII bytes
|
||||
},
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 200);
|
||||
|
||||
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
|
||||
request?: { headers?: Headers };
|
||||
};
|
||||
const forwarded = initArg?.request?.headers as Headers;
|
||||
expect(forwarded.get("cf-region")).toBe("So Paulo");
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple Features", () => {
|
||||
|
||||
it("should handle embed route with routing forms rewrite", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
|
||||
@@ -483,3 +493,67 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Middleware Matcher - Comprehensive Coverage", () => {
|
||||
const matcher = config.matcher[0];
|
||||
const pattern = matcher.replace(/^\/|\/$/g, "");
|
||||
const regex = new RegExp(`^/${pattern}`);
|
||||
|
||||
const cases = [
|
||||
// pages & apis
|
||||
{ path: "/", expected: true, reason: "Root page" },
|
||||
{ path: "/home", expected: true, reason: "Regular page" },
|
||||
{ path: "/team/abc", expected: true, reason: "Nested page" },
|
||||
{ path: "/api/auth/login", expected: true, reason: "API route" },
|
||||
{ path: "/api/bookings", expected: true, reason: "Top-level API" },
|
||||
{ path: "/dashboard/settings", expected: true, reason: "Deep nested page" },
|
||||
{ path: "/user/john/profile", expected: true, reason: "Multiple nested path" },
|
||||
{ path: "/apps/routing_forms/form", expected: true, reason: "App page under /apps" },
|
||||
{ path: "/embed?ui.color-scheme=dark", expected: true, reason: "Embed query param" },
|
||||
|
||||
// should be ignored (internal / static / public)
|
||||
{ path: "/_next/static/chunks/app.js", expected: false, reason: "Internal static asset" },
|
||||
{ path: "/_next/image?url=%2Flogo.png&w=256&q=75", expected: false, reason: "Internal image handler" },
|
||||
{ path: "/_next/data/build-id/page.json", expected: false, reason: "Next.js data route" },
|
||||
{ path: "/favicon.ico", expected: false, reason: "Favicon asset" },
|
||||
{ path: "/robots.txt", expected: false, reason: "Robots file" },
|
||||
{ path: "/sitemap.xml", expected: false, reason: "Sitemap file" },
|
||||
{ path: "/public/images/logo.png", expected: false, reason: "Public folder asset" },
|
||||
{ path: "/public/fonts/inter.woff2", expected: false, reason: "Public folder font" },
|
||||
{ path: "/static/js/main.js", expected: false, reason: "Static folder JavaScript" },
|
||||
{ path: "/static/css/app.css", expected: false, reason: "Static folder stylesheet" },
|
||||
|
||||
// edge cases
|
||||
{ path: "/manifest.json", expected: true, reason: "Manifest is a public page, not ignored" },
|
||||
{ path: "/_nextsomething", expected: true, reason: "Looks like _next but not reserved" },
|
||||
{ path: "/nextconfig", expected: true, reason: "Normal route with 'next' in name" },
|
||||
{ path: "/_NEXT/image", expected: true, reason: "Case-sensitive test (should match)" },
|
||||
{ path: "/favicon-abc.ico", expected: true, reason: "Favicon variant should still match" },
|
||||
{ path: "/robots-custom.txt", expected: true, reason: "Custom robots file should match" },
|
||||
{ path: "/sitemap-other.xml", expected: true, reason: "Custom sitemap file should match" },
|
||||
{ path: "/api_", expected: true, reason: "Partial match with api underscore" },
|
||||
{ path: "//double-slash", expected: true, reason: "Double slash URL" },
|
||||
{ path: "/_next", expected: false, reason: "Bare _next path" },
|
||||
];
|
||||
|
||||
it("should match only the intended routes", () => {
|
||||
for (const { path, expected, reason } of cases) {
|
||||
const result = regex.test(path);
|
||||
expect(result, `${path} → ${reason}`).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not accidentally match internal Next.js routes", () => {
|
||||
const internalPaths = ["/_next/static", "/_next/image", "/_next/data"];
|
||||
for (const path of internalPaths) {
|
||||
expect(regex.test(path)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should match all user-facing routes and APIs", () => {
|
||||
const publicPaths = ["/", "/api/user", "/settings", "/dashboard"];
|
||||
for (const path of publicPaths) {
|
||||
expect(regex.test(path)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+114
-29
@@ -3,19 +3,74 @@ import { collectEvents } from "next-collect/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
import { getCspHeader, getCspNonce } from "@lib/csp";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
return get<T>(key);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Don't crash if EDGE_CONFIG env var is missing
|
||||
}
|
||||
};
|
||||
|
||||
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup"];
|
||||
export const POST_METHODS_ALLOWED_API_ROUTES = [
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/oauth/me",
|
||||
"/api/auth/oauth/refreshToken",
|
||||
"/api/auth/oauth/token",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/saml/callback",
|
||||
"/api/auth/saml/token",
|
||||
"/api/auth/setup",
|
||||
"/api/auth/signup",
|
||||
"/api/auth/two-factor/totp/disable",
|
||||
"/api/auth/two-factor/totp/enable",
|
||||
"/api/auth/two-factor/totp/setup",
|
||||
"/api/auth/session",
|
||||
"/api/availability/calendar",
|
||||
"/api/cancel",
|
||||
"/api/cron/bookingReminder",
|
||||
"/api/cron/calendar-cache-cleanup",
|
||||
"/api/cron/changeTimeZone",
|
||||
"/api/cron/checkSmsPrices",
|
||||
"/api/cron/downgradeUsers",
|
||||
"/api/cron/monthlyDigestEmail",
|
||||
"/api/cron/syncAppMeta",
|
||||
"/api/cron/webhookTriggers",
|
||||
"/api/cron/workflows/scheduleEmailReminders",
|
||||
"/api/cron/workflows/scheduleSMSReminders",
|
||||
"/api/cron/workflows/scheduleWhatsappReminders",
|
||||
"/api/get-inbound-dynamic-variables",
|
||||
"/api/integrations/", // for /api/integrations/[...args] and webhooks
|
||||
"/api/recorded-daily-video",
|
||||
"/api/router",
|
||||
"/api/routing-forms/queued-response",
|
||||
"/api/scim/v2.0/", // /api/scim/v2.0/[...directory]
|
||||
"/api/support/conversation",
|
||||
"/api/sync/helpscout",
|
||||
"/api/twilio/webhook",
|
||||
"/api/username",
|
||||
"/api/verify-booking-token",
|
||||
"/api/video/guest-session",
|
||||
"/api/webhook/app-credential",
|
||||
"/api/webhooks/calendar-subscription/", // /api/webhooks/calendar-subscription/[provider]
|
||||
"/api/webhooks/retell-ai",
|
||||
"/api/workflows/sms/user-response",
|
||||
"/api/trpc/", // for tRPC
|
||||
"/api/auth/callback/", // for NextAuth
|
||||
"/api/book/event",
|
||||
"/api/book/instant-event",
|
||||
"/api/book/recurring-event",
|
||||
"/availability",
|
||||
];
|
||||
|
||||
export function checkPostMethod(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
|
||||
@@ -30,13 +85,47 @@ export function checkPostMethod(req: NextRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function checkStaticFiles(pathname: string) {
|
||||
const hasFileExtension = /\.(svg|png|jpg|jpeg|gif|webp|ico)$/.test(pathname);
|
||||
// Skip Next.js internal paths (_next) and static assets
|
||||
if (pathname.startsWith("/_next") || hasFileExtension) {
|
||||
return NextResponse.next();
|
||||
// Vercel/Edge rejects non‑ASCII header values (see: https://github.com/vercel/next.js/issues/85631)
|
||||
const isAscii = (s: string) => {
|
||||
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
|
||||
return true;
|
||||
};
|
||||
const stripNonAscii = (s: string) => {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) <= 0x7f) out += s[i];
|
||||
return out;
|
||||
};
|
||||
const sanitizeRequestHeaders = (headers: Iterable<[string, string]>): Headers => {
|
||||
const out = new Headers();
|
||||
for (const [name, raw] of Array.from(headers)) {
|
||||
if (!isAscii(name)) continue;
|
||||
let value = raw;
|
||||
if (!isAscii(value)) {
|
||||
// Heuristic: if the string contains common mojibake markers (Ã: 0xC3, Â: 0xC2),
|
||||
// prefer a simple strip (avoids introducing spurious ASCII letters like 'A').
|
||||
let hasMojibakeMarker = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code === 0xc3 || code === 0xc2) {
|
||||
hasMojibakeMarker = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMojibakeMarker) {
|
||||
value = stripNonAscii(value);
|
||||
} else {
|
||||
try {
|
||||
value = stripNonAscii(value.normalize("NFKD"));
|
||||
} catch {
|
||||
value = stripNonAscii(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value) out.set(name, value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const isPagePathRequest = (url: URL) => {
|
||||
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
|
||||
@@ -50,11 +139,21 @@ const shouldEnforceCsp = (url: URL) => {
|
||||
};
|
||||
|
||||
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const postCheckResult = checkPostMethod(req);
|
||||
if (postCheckResult) return postCheckResult;
|
||||
const requestorIp = getIP(req);
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "common",
|
||||
identifier: piiHasher.hash(`${req.nextUrl.pathname}-${requestorIp}`),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
return new NextResponse(error.message, { status: error.statusCode });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isStaticFile = checkStaticFiles(req.nextUrl.pathname);
|
||||
if (isStaticFile) return isStaticFile;
|
||||
// const postCheckResult = checkPostMethod(req);
|
||||
// if (postCheckResult) return postCheckResult;
|
||||
|
||||
const url = req.nextUrl;
|
||||
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
||||
@@ -88,7 +187,7 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
|
||||
const res = NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
headers: sanitizeRequestHeaders(requestHeaders),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -168,22 +267,8 @@ function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
|
||||
// https://github.com/vercel/next.js/discussions/42458
|
||||
// WARNING: DO NOT ADD AN ENDING SLASH "/" TO THE PATHS BELOW
|
||||
// THIS WILL MAKE THEM NOT MATCH AND HENCE NOT HIT MIDDLEWARE
|
||||
matcher: [
|
||||
// Routes to enforce CSP
|
||||
"/auth/login",
|
||||
"/login",
|
||||
// Routes to set cookies
|
||||
"/apps/installed",
|
||||
"/auth/logout",
|
||||
// Embed Routes,
|
||||
"/:path*/embed",
|
||||
// API routes
|
||||
"/api/auth/signup",
|
||||
],
|
||||
runtime: "nodejs",
|
||||
matcher: ["/((?!_next(?:/|$)|static(?:/|$)|public(?:/|$)|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$).*)"],
|
||||
};
|
||||
|
||||
export default collectEvents({
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { VisibilityState } from "@tanstack/react-table";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
|
||||
import type { RowData } from "../types";
|
||||
|
||||
interface BuildFilterColumnsParams {
|
||||
t: (key: string) => string;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of filter columns that should be hidden in the UI
|
||||
* These columns are used for filtering but not displayed
|
||||
*/
|
||||
const FILTER_COLUMN_IDS = [
|
||||
"eventTypeId",
|
||||
"teamId",
|
||||
"userId",
|
||||
"attendeeName",
|
||||
"attendeeEmail",
|
||||
"dateRange",
|
||||
"bookingUid",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Gets the column visibility state for filter columns
|
||||
* All filter columns are hidden by default as they only provide filtering functionality
|
||||
*/
|
||||
export function getFilterColumnVisibility(): VisibilityState {
|
||||
return Object.fromEntries(FILTER_COLUMN_IDS.map((id) => [id, false]));
|
||||
}
|
||||
|
||||
export function buildFilterColumns({ t, permissions, status }: BuildFilterColumnsParams) {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.id : null), {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.team?.id ?? null : null), {
|
||||
id: "teamId",
|
||||
header: t("team"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.user?.id ?? null : null), {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "dateRange",
|
||||
header: t("date_range"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.uid : null), {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { JoinMeetingButton } from "../components/JoinMeetingButton";
|
||||
import type { RowData } from "../types";
|
||||
|
||||
interface PendingActionHandlers {
|
||||
onAccept: (bookingId: number, recurringEventId?: string | null) => void;
|
||||
onReject: (bookingId: number, recurringEventId?: string | null) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface BuildListDisplayColumnsParams {
|
||||
t: (key: string) => string;
|
||||
user?: {
|
||||
id?: number;
|
||||
email?: string;
|
||||
timeZone?: string;
|
||||
timeFormat?: number | null;
|
||||
};
|
||||
pendingActionHandlers: PendingActionHandlers;
|
||||
}
|
||||
|
||||
// Maximum number of attendees to display before showing "..." button
|
||||
const MAX_DISPLAYED_ATTENDEES = 3;
|
||||
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
// Component for individual attendee avatar with dropdown
|
||||
const IndividualAttendee = ({
|
||||
attendee,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendee: AttendeeProps;
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none" data-testid="guest">
|
||||
<Avatar
|
||||
size="sm"
|
||||
imageSrc={getPlaceholderAvatar(null, attendee.name)}
|
||||
alt={attendee.name || attendee.email}
|
||||
title={attendee.name || attendee.email}
|
||||
className="border-subtle cursor-pointer hover:opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent className="w-48" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-emphasis px-3 py-2 text-sm font-medium">
|
||||
{attendee.name || attendee.email}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{!isSmsCalEmail(attendee.email) && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${attendee.email}`}
|
||||
onClick={() => {
|
||||
setOpenDropdown(false);
|
||||
}}>
|
||||
<a href={`mailto:${attendee.email}`}>{t("email")}</a>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const smsCalEmail = isSmsCalEmail(attendee.email);
|
||||
copyToClipboard(smsCalEmail ? attendee.email : attendee.phoneNumber ?? "");
|
||||
setOpenDropdown(false);
|
||||
showToast(smsCalEmail ? t("email_copied") : t("phone_number_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenDropdown(false);
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
|
||||
});
|
||||
}}
|
||||
StartIcon={attendee.noShow ? "eye" : "eye-off"}>
|
||||
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for the "..." dropdown that shows all attendees with action buttons
|
||||
const AllAttendeesDropdown = ({
|
||||
attendees,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleEmail = (email: string) => {
|
||||
window.location.href = `mailto:${email}`;
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
|
||||
const handleCopy = (attendee: AttendeeProps) => {
|
||||
const isSmsAttendee = isSmsCalEmail(attendee.email);
|
||||
copyToClipboard(isSmsAttendee ? attendee.email : attendee.phoneNumber ?? "");
|
||||
showToast(isSmsAttendee ? t("email_copied") : t("phone_number_copied"), "success");
|
||||
};
|
||||
|
||||
const handleToggleNoShow = (attendee: AttendeeProps) => {
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkAllAsNoShow = () => {
|
||||
const allAttendees = attendees.map((attendee) => ({
|
||||
noShow: true,
|
||||
email: attendee.email,
|
||||
}));
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: allAttendees,
|
||||
});
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="ellipsis"
|
||||
className="px-1.5"
|
||||
data-testid="more-guests"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent className="w-80" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("attendees")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{attendees.map((attendee) => (
|
||||
<div key={attendee.id} className="hover:bg-muted flex items-center justify-between px-2 py-2">
|
||||
<div className="flex-1 truncate text-sm">
|
||||
<div className="font-medium">{attendee.name || attendee.email}</div>
|
||||
{attendee.name && <div className="text-subtle text-xs">{attendee.email}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isSmsCalEmail(attendee.email) && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="mail"
|
||||
className="px-1.5"
|
||||
tooltip={t("email")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEmail(attendee.email);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="clipboard"
|
||||
className="px-1.5"
|
||||
tooltip={t("copy")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(attendee);
|
||||
}}
|
||||
/>
|
||||
{isBookingInPast && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon={attendee.noShow ? "eye" : "eye-off"}
|
||||
className="px-1.5"
|
||||
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
|
||||
tooltip={attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleNoShow(attendee);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isBookingInPast && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end p-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
data-testid="update-no-show"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAllAsNoShow();
|
||||
}}>
|
||||
{t("mark_as_no_show_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// Component that renders the attendees with appropriate dropdown
|
||||
const AttendeeCell = ({
|
||||
attendees,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
// Show "..." button if there are more attendees than the max display count
|
||||
const hasMoreThanMax = attendees.length > MAX_DISPLAYED_ATTENDEES;
|
||||
const displayedAttendees = attendees.slice(0, MAX_DISPLAYED_ATTENDEES);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{displayedAttendees.map((attendee, idx) => (
|
||||
<div key={attendee.id} className={idx > 0 ? "-ml-1" : ""}>
|
||||
<IndividualAttendee attendee={attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</div>
|
||||
))}
|
||||
{hasMoreThanMax && (
|
||||
<AllAttendeesDropdown
|
||||
attendees={attendees}
|
||||
bookingUid={bookingUid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function buildListDisplayColumns({ t, user, pendingActionHandlers }: BuildListDisplayColumnsParams) {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: "date",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("date")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
return (
|
||||
<div className="text-default text-sm font-medium">
|
||||
{dayjs(row.booking.startTime).tz(user?.timeZone).format("ddd, DD MMM")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "time",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("time")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
const startTime = dayjs(row.booking.startTime).tz(user?.timeZone);
|
||||
const endTime = dayjs(row.booking.endTime).tz(user?.timeZone);
|
||||
return (
|
||||
<div className="text-default text-sm font-medium">
|
||||
{startTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{endTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "event",
|
||||
minSize: 200,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("event")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
return (
|
||||
<div className="text-emphasis flex-1 truncate text-sm font-medium" data-testid="title">
|
||||
{row.booking.title}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "who",
|
||||
size: 160,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("who")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
const isBookingInPast = new Date(row.booking.endTime) < new Date();
|
||||
|
||||
const attendees = row.booking.attendees.map((attendee) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AttendeeCell
|
||||
attendees={attendees}
|
||||
bookingUid={row.booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "team",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("team")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
if (row.booking.eventType.team) {
|
||||
return (
|
||||
<Badge variant="gray" size="sm">
|
||||
{row.booking.eventType.team.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
size: 280,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => null,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
const booking = row.booking;
|
||||
const isPending = booking.status === "PENDING";
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isCancelled = booking.status === "CANCELLED";
|
||||
const isRejected = booking.status === "REJECTED";
|
||||
const isAccepted = booking.status === "ACCEPTED";
|
||||
|
||||
// Determine if we should show pending actions
|
||||
const shouldShowPendingActions = isPending && isUpcoming && !isCancelled;
|
||||
|
||||
// Determine which buttons to show based on payment status
|
||||
const hasPayment = Array.isArray(booking.payment) && booking.payment.length > 0;
|
||||
const isPaid = booking.paid;
|
||||
const shouldShowAccept = shouldShowPendingActions && (!hasPayment || isPaid);
|
||||
const shouldShowReject = shouldShowPendingActions;
|
||||
|
||||
// Show join meeting button only for upcoming accepted/confirmed bookings
|
||||
const shouldShowJoinButton = isAccepted && isUpcoming && !isCancelled && !isRejected;
|
||||
|
||||
// Determine if this is a recurring booking for the label
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = row.type === "data" && row.recurringInfo !== undefined;
|
||||
const isTabUnconfirmed = booking.status === "PENDING";
|
||||
const showAllLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{shouldShowReject && (
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon="ban"
|
||||
disabled={pendingActionHandlers.isLoading}
|
||||
data-testid="reject"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
|
||||
pendingActionHandlers.onReject(booking.id, recurringEventId);
|
||||
}}>
|
||||
{showAllLabel ? t("reject_all") : t("reject")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowAccept && (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="check"
|
||||
disabled={pendingActionHandlers.isLoading}
|
||||
data-testid="confirm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
|
||||
pendingActionHandlers.onAccept(booking.id, recurringEventId);
|
||||
}}>
|
||||
{showAllLabel ? t("confirm_all") : t("confirm")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowJoinButton && (
|
||||
<JoinMeetingButton
|
||||
size="sm"
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
)}
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: row.type === "data" ? row.recurringInfo : undefined,
|
||||
loggedInUser: {
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
},
|
||||
isToday: row.type === "data" ? row.isToday : false,
|
||||
}}
|
||||
className="px-1.5"
|
||||
size="sm"
|
||||
/>
|
||||
</BookingActionsStoreProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useBookingLocation } from "@calcom/features/bookings/hooks";
|
||||
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
|
||||
import { formatPrice } from "@calcom/lib/currencyConversions";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { bookingMetadataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetBody,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import type { BookingOutput } from "../types";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
|
||||
|
||||
interface BookingDetailsSheetProps {
|
||||
booking: BookingOutput | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userTimeZone?: string;
|
||||
userTimeFormat?: number;
|
||||
userId?: number;
|
||||
userEmail?: string;
|
||||
onPrevious?: () => void;
|
||||
hasPrevious?: boolean;
|
||||
onNext?: () => void;
|
||||
hasNext?: boolean;
|
||||
}
|
||||
|
||||
interface BookingDetailsSheetInnerProps extends Omit<BookingDetailsSheetProps, "booking"> {
|
||||
booking: BookingOutput;
|
||||
}
|
||||
|
||||
export function BookingDetailsSheet(props: BookingDetailsSheetProps) {
|
||||
if (!props.booking) return null;
|
||||
|
||||
return (
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingDetailsSheetInner {...props} booking={props.booking} />
|
||||
</BookingActionsStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingDetailsSheetInner({
|
||||
booking,
|
||||
isOpen,
|
||||
onClose,
|
||||
userTimeZone,
|
||||
userTimeFormat,
|
||||
userId,
|
||||
userEmail,
|
||||
onPrevious,
|
||||
hasPrevious = false,
|
||||
onNext,
|
||||
hasNext = false,
|
||||
}: BookingDetailsSheetInnerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
const endTime = dayjs(booking.endTime).tz(userTimeZone);
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (booking.status) {
|
||||
case "ACCEPTED":
|
||||
return { variant: "green" as const, label: t("confirmed") };
|
||||
case "PENDING":
|
||||
return { variant: "orange" as const, label: t("pending") };
|
||||
case "CANCELLED":
|
||||
return { variant: "red" as const, label: t("cancelled") };
|
||||
case "REJECTED":
|
||||
return { variant: "red" as const, label: t("rejected") };
|
||||
default:
|
||||
return { variant: "gray" as const, label: booking.status };
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = getStatusBadge();
|
||||
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
|
||||
const recurringInfo =
|
||||
booking.recurringEventId && booking.eventType?.recurringEvent
|
||||
? {
|
||||
count: booking.eventType.recurringEvent.count,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
}
|
||||
: null;
|
||||
|
||||
const customResponses = booking.responses
|
||||
? Object.entries(booking.responses as Record<string, unknown>)
|
||||
.filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName))
|
||||
.map(([question, answer]) => [question, answer] as [string, unknown])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader
|
||||
showCloseButton={false}
|
||||
rightContent={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon="chevron-up"
|
||||
disabled={!hasPrevious}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onPrevious?.();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!hasNext}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNext?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusBadge.variant} className="capitalize">
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
{recurringInfo && (
|
||||
<Badge variant="gray">
|
||||
<Icon name="repeat" className="mr-1 h-3 w-3" />
|
||||
{recurringInfo.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<SheetTitle className="text-2xl font-semibold">{booking.title}</SheetTitle>
|
||||
<p className="text-subtle text-sm">
|
||||
{startTime.format("dddd, MMMM D, YYYY h:mma")} - {endTime.format("h:mma")} (
|
||||
{userTimeZone || startTime.format("Z")})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RescheduleRequestMessage booking={booking} />
|
||||
|
||||
<WhoSection booking={booking} />
|
||||
|
||||
<WhereSection booking={booking} meta={bookingMetadata} />
|
||||
|
||||
<RecurringInfoSection recurringInfo={recurringInfo} />
|
||||
|
||||
<PaymentSection booking={booking} />
|
||||
|
||||
<SlotsSection booking={booking} />
|
||||
|
||||
<CustomQuestionsSection
|
||||
customResponses={customResponses}
|
||||
bookingFields={booking.eventType?.bookingFields}
|
||||
/>
|
||||
|
||||
<DescriptionSection booking={booking} />
|
||||
</div>
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
|
||||
<Button color="secondary" StartIcon="x" onClick={onClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
usePortal={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Section title={t("who")}>
|
||||
<div className="space-y-4">
|
||||
{booking.user && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={getPlaceholderAvatar(null, booking.user.name)}
|
||||
alt={booking.user.name || ""}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{booking.user.name}</p>
|
||||
<Badge variant="blue" size="sm">
|
||||
{t("host")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-default truncate text-sm">{booking.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.attendees.map((attendee, idx) => (
|
||||
<div key={idx} className="flex items-center gap-4">
|
||||
<Avatar size="md" imageSrc={getPlaceholderAvatar(null, attendee.name)} alt={attendee.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{attendee.name}</p>
|
||||
<p className="text-default truncate text-sm">{attendee.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
|
||||
location: booking.location,
|
||||
videoCallUrl: meta?.videoCallUrl,
|
||||
t,
|
||||
bookingStatus: booking.status,
|
||||
});
|
||||
|
||||
if (!locationToDisplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLocationURL) {
|
||||
return (
|
||||
<Section title={t("where")}>
|
||||
<p className="text-default text-sm">{locationToDisplay}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("where")}>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<span className="text-default flex-shrink-0">{provider?.label}:</span>
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-600 hover:underline">
|
||||
{locationToDisplay}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecurringInfoSection({
|
||||
recurringInfo,
|
||||
}: {
|
||||
recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!recurringInfo || recurringInfo.count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("recurring_event")}>
|
||||
<p className="text-default text-sm">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: recurringInfo.recurringEvent,
|
||||
recurringCount: recurringInfo.count,
|
||||
})}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.paid || !Array.isArray(booking.payment) || booking.payment.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payment = booking.payment[0];
|
||||
const formattedPrice = formatPrice(payment.amount, payment.currency);
|
||||
|
||||
return (
|
||||
<Section title={t("payment")}>
|
||||
<p className="text-default text-sm">{formattedPrice}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotsSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (
|
||||
!booking.eventType?.seatsShowAvailabilityCount ||
|
||||
!booking.eventType?.seatsPerTimeSlot ||
|
||||
booking.eventType.seatsPerTimeSlot <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSeats = booking.eventType.seatsPerTimeSlot;
|
||||
const takenSeats = booking.attendees.length;
|
||||
|
||||
return (
|
||||
<Section title={t("slots")}>
|
||||
<p className="text-default text-sm">{t("slots_taken", { takenSeats, totalSeats })}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomQuestionsSection({
|
||||
customResponses,
|
||||
bookingFields: rawBookingFields,
|
||||
}: {
|
||||
customResponses: [string, unknown][];
|
||||
bookingFields?: unknown;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Parse and memoize booking fields
|
||||
const bookingFields = useMemo(() => {
|
||||
if (!rawBookingFields) return undefined;
|
||||
const parsed = eventTypeBookingFields.safeParse(rawBookingFields);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}, [rawBookingFields]);
|
||||
|
||||
// Filter out responses with falsy answers or empty arrays
|
||||
const validResponses = useMemo(
|
||||
(): [string, unknown][] =>
|
||||
customResponses.filter(([, answer]) => {
|
||||
if (!answer) return false;
|
||||
if (Array.isArray(answer) && answer.length === 0) return false;
|
||||
return true;
|
||||
}),
|
||||
[customResponses]
|
||||
);
|
||||
|
||||
// Memoize field label lookups
|
||||
const fieldLabels = useMemo(() => {
|
||||
if (!bookingFields) return new Map<string, string>();
|
||||
|
||||
return new Map<string, string>(
|
||||
bookingFields.map(
|
||||
(field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
|
||||
)
|
||||
);
|
||||
}, [bookingFields, t]);
|
||||
|
||||
if (validResponses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{validResponses.map(([fieldName, answer], idx) => {
|
||||
const fieldNameStr = String(fieldName);
|
||||
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
|
||||
return (
|
||||
<Section key={idx} title={title}>
|
||||
<p className="text-default text-sm">{String(answer)}</p>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("description")}>
|
||||
<p className="text-default whitespace-pre-wrap text-sm">{booking.description}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("space-y-1", className)}>
|
||||
<h3 className="text-subtle text-xs font-semibold">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
useDataTable,
|
||||
useFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
ColumnFilterType,
|
||||
} from "@calcom/features/data-table";
|
||||
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
|
||||
import { BookingsCalendarView } from "./BookingsCalendarView";
|
||||
|
||||
type BookingsCalendarViewProps = {
|
||||
type BookingsCalendarProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
isPending?: boolean;
|
||||
onOpenDetails: (bookingId: number) => void;
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
setCurrentWeekStart: (
|
||||
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
|
||||
) => Promise<URLSearchParams>;
|
||||
bookings: BookingOutput[];
|
||||
};
|
||||
|
||||
const COLUMN_IDS_TO_HIDE = ["dateRange"];
|
||||
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
|
||||
const { rows } = table.getRowModel();
|
||||
export function BookingsCalendar({
|
||||
table,
|
||||
isPending = false,
|
||||
onOpenDetails,
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
bookings,
|
||||
}: BookingsCalendarProps) {
|
||||
const { updateFilter } = useDataTable();
|
||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const bookings = useMemo(() => {
|
||||
return rows
|
||||
.filter((row) => row.original.type === "data")
|
||||
.map((row) => (row.original.type === "data" ? row.original.booking : null))
|
||||
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
|
||||
}, [rows]);
|
||||
|
||||
const handleWeekStartChange = useCallback(
|
||||
(newWeekStart: dayjs.Dayjs) => {
|
||||
setCurrentWeekStart(newWeekStart);
|
||||
|
||||
// Always set the date range to match the current week exactly
|
||||
const startDate = newWeekStart.toDate();
|
||||
const endDate = newWeekStart.add(6, "day").toDate();
|
||||
|
||||
if (!dateRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
|
||||
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
|
||||
|
||||
const needsStartUpdate = !rangeStart || startDate < rangeStart;
|
||||
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
|
||||
|
||||
if (!needsStartUpdate && !needsEndUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStartDate = needsStartUpdate ? startDate : rangeStart;
|
||||
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
|
||||
|
||||
updateFilter("dateRange", {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
data: {
|
||||
startDate: newStartDate.toISOString(),
|
||||
endDate: newEndDate.toISOString(),
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
preset: CUSTOM_PRESET.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[dateRange, updateFilter, setCurrentWeekStart]
|
||||
[updateFilter, setCurrentWeekStart]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -103,6 +76,8 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={handleWeekStartChange}
|
||||
isPending={isPending}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { useBookingCursor } from "../hooks/useBookingCursor";
|
||||
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsCalendar } from "./BookingsCalendar";
|
||||
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
interface BookingsCalendarContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data: RowData[];
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function BookingsCalendarContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending = false,
|
||||
}: BookingsCalendarContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
|
||||
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const onOpenDetails = useCallback(
|
||||
(bookingId: number) => {
|
||||
setSelectedBookingId(bookingId);
|
||||
},
|
||||
[setSelectedBookingId]
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return buildFilterColumns({ t, permissions, status });
|
||||
}, [t, permissions, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data and filter by current week
|
||||
const bookings = useMemo(() => {
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
|
||||
return data
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking)
|
||||
.filter((booking) => {
|
||||
const bookingStart = dayjs(booking.startTime);
|
||||
return (
|
||||
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
|
||||
bookingStart.isBefore(weekEnd.endOf("day"))
|
||||
);
|
||||
});
|
||||
}, [data, currentWeekStart]);
|
||||
|
||||
const selectedBooking = useMemo(() => {
|
||||
if (!selectedBookingId) return null;
|
||||
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
|
||||
}, [selectedBookingId, bookings]);
|
||||
|
||||
const bookingNavigation = useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BookingsCalendar
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
onOpenDetails={onOpenDetails}
|
||||
currentWeekStart={currentWeekStart}
|
||||
setCurrentWeekStart={setCurrentWeekStart}
|
||||
bookings={bookings}
|
||||
/>
|
||||
|
||||
<BookingDetailsSheet
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBookingId(null)}
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
onPrevious={bookingNavigation.onPrevious}
|
||||
hasPrevious={bookingNavigation.hasPrevious}
|
||||
onNext={bookingNavigation.onNext}
|
||||
hasNext={bookingNavigation.hasNext}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,16 @@ type BookingsCalendarViewProps = {
|
||||
bookings: BookingOutput[];
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
|
||||
isPending?: boolean;
|
||||
onOpenDetails: (bookingId: number) => void;
|
||||
};
|
||||
|
||||
export function BookingsCalendarView({
|
||||
bookings,
|
||||
currentWeekStart,
|
||||
onWeekStartChange,
|
||||
isPending = false,
|
||||
onOpenDetails,
|
||||
}: BookingsCalendarViewProps) {
|
||||
const { t } = useLocale();
|
||||
const { timezone } = useTimePreferences();
|
||||
@@ -44,8 +48,10 @@ export function BookingsCalendarView({
|
||||
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
|
||||
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
|
||||
|
||||
// Intentionally only runs on mount to trigger the initial currentWeekStart
|
||||
useEffect(() => {
|
||||
onWeekStartChange(currentWeekStart);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const events = useMemo<CalendarEvent[]>(() => {
|
||||
@@ -78,6 +84,7 @@ export function BookingsCalendarView({
|
||||
options: {
|
||||
status: booking.status,
|
||||
...(eventTypeColor && { color: eventTypeColor }),
|
||||
bookingId: booking.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -109,6 +116,7 @@ export function BookingsCalendarView({
|
||||
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">{weekRange}</h2>
|
||||
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
|
||||
@@ -141,7 +149,12 @@ export function BookingsCalendarView({
|
||||
showBackgroundPattern={false}
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
onEventClick={(_event) => {}}
|
||||
onEventClick={(event) => {
|
||||
const bookingId = event.options?.bookingId;
|
||||
if (bookingId) {
|
||||
onOpenDetails(bookingId);
|
||||
}
|
||||
}}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
import type { Row, Table as ReactTable } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
|
||||
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
@@ -23,22 +25,49 @@ type BookingsListViewProps = {
|
||||
table: ReactTable<RowData>;
|
||||
isPending: boolean;
|
||||
totalRowCount?: number;
|
||||
onOpenDetails: (bookingId: number) => void;
|
||||
};
|
||||
|
||||
export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) {
|
||||
export function BookingsList({
|
||||
status,
|
||||
table,
|
||||
isPending,
|
||||
totalRowCount,
|
||||
onOpenDetails,
|
||||
}: BookingsListViewProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: Row<RowData>) => {
|
||||
if (!isSeparatorRow(row.original)) {
|
||||
onOpenDetails(row.original.booking.id);
|
||||
}
|
||||
},
|
||||
[onOpenDetails]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableWrapper
|
||||
className="mb-6"
|
||||
table={table}
|
||||
testId={`${status}-bookings`}
|
||||
bodyTestId="bookings"
|
||||
headerClassName="hidden"
|
||||
rowTestId={(row) => {
|
||||
if (isSeparatorRow(row.original)) return undefined;
|
||||
return "booking-item";
|
||||
}}
|
||||
rowDataAttributes={(row) => {
|
||||
if (isSeparatorRow(row.original)) return undefined;
|
||||
return {
|
||||
"data-today": String(row.original.isToday),
|
||||
};
|
||||
}}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
variant="compact"
|
||||
variant="default"
|
||||
paginationMode="standard"
|
||||
onRowMouseclick={handleRowClick}
|
||||
hideSeparatorsOnSort={true}
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { buildListDisplayColumns } from "../columns/listColumns";
|
||||
import { useBookingCursor } from "../hooks/useBookingCursor";
|
||||
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsList } from "./BookingsList";
|
||||
|
||||
interface BookingsListContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data: RowData[];
|
||||
isPending: boolean;
|
||||
totalRowCount?: number;
|
||||
}
|
||||
|
||||
export function BookingsListContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending,
|
||||
totalRowCount,
|
||||
}: BookingsListContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
|
||||
|
||||
// Filter out separator rows and extract bookings
|
||||
const bookings = useMemo(() => {
|
||||
return data
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [data]);
|
||||
|
||||
const selectedBooking = useMemo(() => {
|
||||
if (!selectedBookingId) return null;
|
||||
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
|
||||
}, [selectedBookingId, bookings]);
|
||||
|
||||
const bookingNavigation = useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
});
|
||||
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [pendingRejection, setPendingRejection] = useState<{
|
||||
bookingId: number;
|
||||
recurringEventId?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const confirmMutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === "REJECTED") {
|
||||
setRejectionDialogIsOpen(false);
|
||||
setRejectionReason("");
|
||||
setPendingRejection(null);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAccept = useCallback(
|
||||
(bookingId: number, recurringEventId?: string | null) => {
|
||||
confirmMutation.mutate({
|
||||
bookingId,
|
||||
confirmed: true,
|
||||
reason: "",
|
||||
...(recurringEventId && { recurringEventId }),
|
||||
});
|
||||
},
|
||||
[confirmMutation]
|
||||
);
|
||||
|
||||
const handleReject = useCallback((bookingId: number, recurringEventId?: string | null) => {
|
||||
setPendingRejection({ bookingId, recurringEventId });
|
||||
setRejectionDialogIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRejection = useCallback(() => {
|
||||
if (!pendingRejection) return;
|
||||
|
||||
confirmMutation.mutate({
|
||||
bookingId: pendingRejection.bookingId,
|
||||
confirmed: false,
|
||||
reason: rejectionReason,
|
||||
...(pendingRejection.recurringEventId && { recurringEventId: pendingRejection.recurringEventId }),
|
||||
});
|
||||
}, [pendingRejection, rejectionReason, confirmMutation]);
|
||||
|
||||
const onOpenDetails = useCallback(
|
||||
(bookingId: number) => {
|
||||
setSelectedBookingId(bookingId);
|
||||
},
|
||||
[setSelectedBookingId]
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const filterCols = buildFilterColumns({ t, permissions, status });
|
||||
const listCols = buildListDisplayColumns({
|
||||
t,
|
||||
user,
|
||||
pendingActionHandlers: {
|
||||
onAccept: handleAccept,
|
||||
onReject: handleReject,
|
||||
isLoading: confirmMutation.isPending,
|
||||
},
|
||||
});
|
||||
return [...filterCols, ...listCols];
|
||||
}, [t, permissions, status, user, handleAccept, handleReject, confirmMutation.isPending]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
columnPinning: {
|
||||
right: ["actions"],
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
const handleRejectionDialogChange = useCallback((open: boolean) => {
|
||||
setRejectionDialogIsOpen(open);
|
||||
if (!open) {
|
||||
setRejectionReason("");
|
||||
setPendingRejection(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={handleRejectionDialogChange}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={confirmMutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={handleConfirmRejection}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<BookingsList
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
|
||||
<BookingDetailsSheet
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBookingId(null)}
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
onPrevious={bookingNavigation.onPrevious}
|
||||
hasPrevious={bookingNavigation.hasPrevious}
|
||||
onNext={bookingNavigation.onNext}
|
||||
hasNext={bookingNavigation.hasNext}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { useJoinableLocation } from "./useJoinableLocation";
|
||||
|
||||
interface JoinMeetingButtonProps {
|
||||
location: string | null;
|
||||
metadata?: unknown;
|
||||
bookingStatus: BookingStatus;
|
||||
size?: "sm" | "base" | "lg";
|
||||
color?: "primary" | "secondary" | "minimal" | "destructive";
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function JoinMeetingButton({
|
||||
location,
|
||||
metadata,
|
||||
bookingStatus,
|
||||
size = "base",
|
||||
color = "secondary",
|
||||
className,
|
||||
onClick,
|
||||
}: JoinMeetingButtonProps) {
|
||||
const { t } = useLocale();
|
||||
const { isJoinable, locationToDisplay, provider } = useJoinableLocation({
|
||||
location,
|
||||
metadata,
|
||||
bookingStatus,
|
||||
t,
|
||||
});
|
||||
|
||||
if (!isJoinable || !locationToDisplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
size={size}
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames("flex items-center gap-2", className)}
|
||||
onClick={handleClick}>
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useBookingLocation } from "@calcom/features/bookings/hooks";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
interface UseJoinableLocationParams {
|
||||
location: string | null;
|
||||
metadata?: unknown;
|
||||
bookingStatus: BookingStatus;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to determine if a booking location is joinable (i.e., has a clickable URL).
|
||||
* This hook is used to show/hide the join meeting button and related UI elements.
|
||||
*
|
||||
* @returns An object containing:
|
||||
* - `isJoinable`: Whether the location is joinable (has a valid URL)
|
||||
* - `locationToDisplay`: The location URL or text to display
|
||||
* - `provider`: Provider information (label, iconUrl, etc.)
|
||||
* - `isLocationURL`: Whether the location is a URL
|
||||
*/
|
||||
export function useJoinableLocation({ location, metadata, bookingStatus, t }: UseJoinableLocationParams) {
|
||||
const bookingMetadata = useMemo(() => {
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(metadata ?? null);
|
||||
return parsedMetadata.success ? parsedMetadata.data : null;
|
||||
}, [metadata]);
|
||||
|
||||
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
|
||||
location,
|
||||
videoCallUrl: bookingMetadata?.videoCallUrl,
|
||||
t,
|
||||
bookingStatus,
|
||||
});
|
||||
|
||||
const isJoinable = isLocationURL && !!locationToDisplay;
|
||||
|
||||
return {
|
||||
isJoinable,
|
||||
locationToDisplay,
|
||||
provider,
|
||||
isLocationURL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
export function useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
}: {
|
||||
bookings: BookingOutput[];
|
||||
selectedBookingId: number | null;
|
||||
setSelectedBookingId: (bookingId: number | null) => void;
|
||||
}) {
|
||||
const currentIndex = useMemo(
|
||||
() => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId),
|
||||
[bookings, selectedBookingId]
|
||||
);
|
||||
|
||||
const onPrevious = useCallback(() => {
|
||||
if (currentIndex >= 1) {
|
||||
setSelectedBookingId(bookings[currentIndex - 1].id);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
if (currentIndex >= 0 && currentIndex < bookings.length - 1) {
|
||||
setSelectedBookingId(bookings[currentIndex + 1].id);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
|
||||
return {
|
||||
onPrevious,
|
||||
onNext,
|
||||
hasPrevious: currentIndex > 0,
|
||||
hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQueryState } from "nuqs";
|
||||
|
||||
export function useSelectedBookingId() {
|
||||
return useQueryState("selectedId", {
|
||||
defaultValue: null,
|
||||
parse: (value) => (value ? parseInt(value, 10) : null),
|
||||
serialize: (value) => (value ? String(value) : ""),
|
||||
clearOnDefault: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Builds a booking link with query parameters
|
||||
* @param bookingUid - The unique identifier for the booking
|
||||
* @param allRemainingBookings - Whether to include all remaining bookings
|
||||
* @param email - Optional email of the attendee
|
||||
* @returns The formatted booking link with query parameters
|
||||
*/
|
||||
export function buildBookingLink({
|
||||
bookingUid,
|
||||
allRemainingBookings,
|
||||
email,
|
||||
}: {
|
||||
bookingUid: string;
|
||||
allRemainingBookings: boolean;
|
||||
email?: string | null;
|
||||
}): string {
|
||||
const urlSearchParams = new URLSearchParams({
|
||||
allRemainingBookings: allRemainingBookings.toString(),
|
||||
});
|
||||
if (email) {
|
||||
urlSearchParams.set("email", email);
|
||||
}
|
||||
return `/booking/${bookingUid}?${urlSearchParams.toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createParser } from "nuqs";
|
||||
|
||||
export const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: "list" | "calendar") => value,
|
||||
});
|
||||
|
||||
export type BookingView = "list" | "calendar";
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import type { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
@@ -18,8 +19,6 @@ export type RowData =
|
||||
isToday: boolean;
|
||||
recurringInfo?: RecurringInfo;
|
||||
}
|
||||
| {
|
||||
type: "today" | "next";
|
||||
};
|
||||
| SeparatorRow;
|
||||
|
||||
export type BookingListingStatus = (typeof validStatuses)[number];
|
||||
|
||||
@@ -26,7 +26,7 @@ import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/li
|
||||
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
|
||||
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { SMS_REMINDER_NUMBER_FIELD, SystemField, TITLE_FIELD } from "@calcom/lib/bookings/SystemField";
|
||||
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
@@ -776,15 +776,9 @@ export default function Success(props: PageProps) {
|
||||
// We show notes in additional notes section
|
||||
// We show rescheduleReason at the top
|
||||
|
||||
const isSystemField = SystemField.safeParse(field.name);
|
||||
// SMS_REMINDER_NUMBER_FIELD is a system field but doesn't have a dedicated place in the UI. So, it would be shown through the following responses list
|
||||
// TITLE is also an identifier for booking question "What is this meeting about?"
|
||||
if (
|
||||
isSystemField.success &&
|
||||
field.name !== SMS_REMINDER_NUMBER_FIELD &&
|
||||
field.name !== TITLE_FIELD
|
||||
)
|
||||
if (!shouldShowFieldInCustomResponses(field.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = field.label || t(field.defaultLabel);
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
type SystemFilterSegment,
|
||||
useDataTable,
|
||||
ColumnFilterType,
|
||||
@@ -27,22 +25,26 @@ import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
import type { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import { viewParser } from "~/bookings/lib/viewParser";
|
||||
|
||||
import { BookingsCalendar } from "../components/BookingsCalendar";
|
||||
import { BookingsList } from "../components/BookingsList";
|
||||
import type { RowData, BookingOutput } from "../types";
|
||||
|
||||
const BookingsListContainer = dynamic(() =>
|
||||
import("../components/BookingsListContainer").then((mod) => ({ default: mod.BookingsListContainer }))
|
||||
);
|
||||
const BookingsCalendarContainer = dynamic(() =>
|
||||
import("../components/BookingsCalendarContainer").then((mod) => ({
|
||||
default: mod.BookingsCalendarContainer,
|
||||
}))
|
||||
);
|
||||
|
||||
type BookingsProps = {
|
||||
status: (typeof validStatuses)[number];
|
||||
userId?: number;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
isCalendarViewEnabled: boolean;
|
||||
};
|
||||
|
||||
function useSystemSegments(userId?: number) {
|
||||
@@ -84,18 +86,9 @@ export default function Bookings(props: BookingsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: "list" | "calendar") => value,
|
||||
});
|
||||
|
||||
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
|
||||
const [_view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
const [view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
// Force view to be "list" if calendar view is disabled
|
||||
const view = isCalendarViewEnabled ? _view : "list";
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const searchParams = useSearchParams();
|
||||
@@ -148,159 +141,47 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
|
||||
|
||||
const { limit, offset } = useDataTable();
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery({
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
status,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
// Only apply pagination for list view, calendar view needs all bookings
|
||||
const shouldPaginate = view === "list";
|
||||
const queryLimit = shouldPaginate ? limit : 100; // Use max limit for calendar view
|
||||
const queryOffset = shouldPaginate ? offset : 0; // Reset offset for calendar view
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
limit: queryLimit,
|
||||
offset: queryOffset,
|
||||
filters: {
|
||||
status,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
|
||||
id: "teamId",
|
||||
header: t("team"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "dateRange",
|
||||
header: t("date_range"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "customView",
|
||||
cell: (props) => {
|
||||
if (props.row.original.type === "data") {
|
||||
const { booking, recurringInfo, isToday } = props.row.original;
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
isToday={isToday}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
} else if (props.row.original.type === "today") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("today")}
|
||||
</p>
|
||||
);
|
||||
} else if (props.row.original.type === "next") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("next")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t, permissions.canReadOthersBookings]);
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
);
|
||||
|
||||
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
|
||||
|
||||
const flatData = useMemo<RowData[]>(() => {
|
||||
const groupedBookings = useMemo(() => {
|
||||
if (!query.data?.bookings) {
|
||||
return { today: [], currentMonth: [], monthBuckets: {} };
|
||||
}
|
||||
|
||||
const now = dayjs().tz(user?.timeZone);
|
||||
const today = now.format("YYYY-MM-DD");
|
||||
const currentMonthStart = now.startOf("month");
|
||||
const currentMonthEnd = now.endOf("month");
|
||||
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
@@ -315,80 +196,86 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
|
||||
return false;
|
||||
}
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
} else if (status === "upcoming") {
|
||||
return (
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
query.data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
const todayBookings: RowData[] = [];
|
||||
const currentMonthBookings: RowData[] = [];
|
||||
const monthBuckets: Record<string, RowData[]> = {}; // Key format: "YYYY-MM"
|
||||
|
||||
query.data.bookings.filter(filterBookings).forEach((booking) => {
|
||||
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
|
||||
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
|
||||
const monthKey = bookingDate.format("YYYY-MM");
|
||||
|
||||
const rowData: RowData = {
|
||||
type: "data",
|
||||
booking,
|
||||
isToday: bookingDateStr === today,
|
||||
recurringInfo: query.data?.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
),
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
};
|
||||
|
||||
if (bookingDateStr === today) {
|
||||
todayBookings.push(rowData);
|
||||
} else if (bookingDate.isAfter(currentMonthStart) && bookingDate.isBefore(currentMonthEnd)) {
|
||||
currentMonthBookings.push(rowData);
|
||||
} else if (bookingDate.isAfter(currentMonthEnd)) {
|
||||
if (!monthBuckets[monthKey]) {
|
||||
monthBuckets[monthKey] = [];
|
||||
}
|
||||
monthBuckets[monthKey].push(rowData);
|
||||
} else if (bookingDate.isBefore(currentMonthStart)) {
|
||||
// Handle bookings from months before the current month
|
||||
if (!monthBuckets[monthKey]) {
|
||||
monthBuckets[monthKey] = [];
|
||||
}
|
||||
monthBuckets[monthKey].push(rowData);
|
||||
}
|
||||
});
|
||||
|
||||
return { today: todayBookings, currentMonth: currentMonthBookings, monthBuckets };
|
||||
}, [query.data, status, user?.timeZone]);
|
||||
|
||||
const flatData = useMemo<RowData[]>(() => {
|
||||
return [...groupedBookings.today, ...groupedBookings.currentMonth, ...Object.values(groupedBookings.monthBuckets).flat()];
|
||||
}, [groupedBookings]);
|
||||
|
||||
const bookingsToday = useMemo<RowData[]>(() => {
|
||||
return (
|
||||
query.data?.bookings
|
||||
.filter(
|
||||
(booking: BookingOutput) =>
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
)
|
||||
.map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: query.data?.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
),
|
||||
isToday: true,
|
||||
})) ?? []
|
||||
);
|
||||
}, [query.data, user?.timeZone]);
|
||||
return groupedBookings.today;
|
||||
}, [groupedBookings]);
|
||||
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
if (status !== "upcoming") {
|
||||
return flatData;
|
||||
}
|
||||
|
||||
const merged: RowData[] = [];
|
||||
if (bookingsToday.length > 0) {
|
||||
merged.push({ type: "today" as const }, ...bookingsToday);
|
||||
|
||||
// Add Today section
|
||||
if (groupedBookings.today.length > 0) {
|
||||
merged.push({ type: "separator", label: t("today") }, ...groupedBookings.today);
|
||||
}
|
||||
if (flatData.length > 0) {
|
||||
merged.push({ type: "next" as const }, ...flatData);
|
||||
|
||||
// Add Current Month section (rest of this month, excluding today)
|
||||
if (groupedBookings.currentMonth.length > 0) {
|
||||
merged.push({ type: "separator", label: t("this_month") }, ...groupedBookings.currentMonth);
|
||||
}
|
||||
|
||||
// Add individual month sections
|
||||
const sortedMonthKeys = Object.keys(groupedBookings.monthBuckets).sort();
|
||||
sortedMonthKeys.forEach((monthKey) => {
|
||||
const bookings = groupedBookings.monthBuckets[monthKey];
|
||||
if (bookings.length > 0) {
|
||||
const monthLabel = dayjs(monthKey, "YYYY-MM").format("MMMM YYYY");
|
||||
merged.push({ type: "separator", label: monthLabel }, ...bookings);
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}, [bookingsToday, flatData, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: finalData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
eventTypeId: false,
|
||||
teamId: false,
|
||||
userId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
}, [groupedBookings, status, t, flatData]);
|
||||
|
||||
const isPending = query.isPending;
|
||||
const totalRowCount = query.data?.totalCount;
|
||||
@@ -406,37 +293,27 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
|
||||
<main className="w-full">
|
||||
<div className="flex w-full flex-col">
|
||||
{query.status === "error" ? (
|
||||
<>
|
||||
<div className="grid w-full items-center gap-2 pb-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
</>
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
) : (
|
||||
<>
|
||||
{!!bookingsToday.length && status === "upcoming" && (
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
)}
|
||||
{view === "list" ? (
|
||||
<BookingsList
|
||||
<BookingsListContainer
|
||||
status={status}
|
||||
table={table}
|
||||
permissions={permissions}
|
||||
data={finalData}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
/>
|
||||
) : (
|
||||
<BookingsCalendar status={status} table={table} />
|
||||
<BookingsCalendarContainer
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={finalData}
|
||||
isPending={isPending}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, Select } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { InviteRole } from "../store/onboarding-store";
|
||||
|
||||
type BaseInviteFormData = {
|
||||
email: string;
|
||||
role: InviteRole;
|
||||
};
|
||||
|
||||
type InviteFormDataWithOptionalTeam = BaseInviteFormData & {
|
||||
team?: string;
|
||||
};
|
||||
|
||||
type InviteFormDataWithRequiredTeam = BaseInviteFormData & {
|
||||
team: string;
|
||||
};
|
||||
|
||||
type InviteFormData = InviteFormDataWithOptionalTeam | InviteFormDataWithRequiredTeam;
|
||||
|
||||
type EmailInviteFormProps = {
|
||||
fields: Array<{ id: string }>;
|
||||
append: (value: InviteFormData) => void;
|
||||
remove: (index: number) => void;
|
||||
defaultRole: InviteRole;
|
||||
showTeamSelect?: boolean;
|
||||
teams?: Array<{ value: string; label: string }>;
|
||||
emailPlaceholder?: string;
|
||||
};
|
||||
|
||||
export function EmailInviteForm({
|
||||
fields,
|
||||
append,
|
||||
remove,
|
||||
defaultRole,
|
||||
showTeamSelect = false,
|
||||
teams = [],
|
||||
emailPlaceholder,
|
||||
}: EmailInviteFormProps) {
|
||||
const { t } = useLocale();
|
||||
const { register, watch, setValue } = useFormContext();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{showTeamSelect ? (
|
||||
<div className="grid grid-cols-2">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.team">
|
||||
{t("team")}
|
||||
</Label>
|
||||
</div>
|
||||
) : (
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
showTeamSelect ? "flex flex-col gap-2" : "scroll-bar flex max-h-72 flex-col gap-1 overflow-y-auto"
|
||||
}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5 p-0.5">
|
||||
<div className={showTeamSelect ? "grid flex-1 items-start gap-2 md:grid-cols-2" : "flex-1"}>
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...register(`invites.${index}.email`)}
|
||||
placeholder={emailPlaceholder || `rick@cal.com`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
{showTeamSelect && (
|
||||
<Select
|
||||
size="sm"
|
||||
options={teams}
|
||||
value={teams.find((t) => t.value === watch(`invites.${index}.team`))}
|
||||
onChange={(option) => {
|
||||
if (option) {
|
||||
setValue(`invites.${index}.team`, option.value);
|
||||
}
|
||||
}}
|
||||
placeholder={t("select_team")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className={showTeamSelect ? "mt-2 w-fit" : "w-fit"}
|
||||
onClick={() =>
|
||||
append(
|
||||
showTeamSelect ? { email: "", team: "", role: defaultRole } : { email: "", role: defaultRole }
|
||||
)
|
||||
}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_4178_176214)">
|
||||
<path
|
||||
d="M8.31875 15.36C4.26 15.36 0.9575 12.0588 0.9575 8.00001C0.9575 3.94126 4.26 0.640015 8.31875 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.2525 4.07376 8.31875 4.07376C6.15375 4.07376 4.39125 5.83501 4.39125 8.00001C4.39125 10.165 6.15375 11.9263 8.31875 11.9263C9.88 11.9263 11.1138 11.1288 11.695 9.77001H7.99875V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31875 15.36Z"
|
||||
fill="#6B7280"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4178_176214">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
type InviteOptionsProps = {
|
||||
onInviteViaEmail: () => void;
|
||||
onUploadCSV?: () => void;
|
||||
onCopyInviteLink?: () => void;
|
||||
onConnectGoogleWorkspace?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
};
|
||||
|
||||
export const InviteOptions = ({
|
||||
onInviteViaEmail,
|
||||
onUploadCSV,
|
||||
onCopyInviteLink,
|
||||
onConnectGoogleWorkspace,
|
||||
isSubmitting = false,
|
||||
}: InviteOptionsProps) => {
|
||||
const { t } = useLocale();
|
||||
const flags = useFlags();
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-1 flex-col gap-6 ">
|
||||
{googleWorkspaceEnabled && onConnectGoogleWorkspace && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full rounded-[10px]"
|
||||
CustomStartIcon={<GoogleIcon />}
|
||||
onClick={onConnectGoogleWorkspace}
|
||||
disabled={isSubmitting}>
|
||||
{t("connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
<span className="text-subtle text-sm font-medium">{t("or")}</span>
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-1 flex-col gap-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={onInviteViaEmail}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="mail" className="h-4 w-4" />
|
||||
<span>{t("invite_via_email")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{onUploadCSV && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={onUploadCSV}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="upload" className="h-4 w-4" />
|
||||
<span>{t("upload_csv_file")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCopyInviteLink && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={onCopyInviteLink}
|
||||
disabled>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="link" className="h-4 w-4" />
|
||||
<span>{t("copy_invite_link")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
@@ -38,4 +38,3 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import classNames from "classnames";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Children, type ReactNode } from "react";
|
||||
|
||||
@@ -9,11 +10,12 @@ import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
type OnboardingLayoutProps = {
|
||||
userEmail: string;
|
||||
currentStep: 1 | 2 | 3;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
|
||||
export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children }: OnboardingLayoutProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const handleSignOut = () => {
|
||||
@@ -42,17 +44,31 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
|
||||
|
||||
{/* Footer with progress dots and sign out */}
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4 px-10 py-8">
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="relative flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full ${
|
||||
step <= currentStep ? "bg-emphasis" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
{step <= currentStep && <div className="bg-emphasis absolute h-1 w-1 rounded-full" />}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => {
|
||||
const isCurrentStep = step === currentStep;
|
||||
const isCompleted = step < currentStep;
|
||||
return (
|
||||
<div key={step} className="relative flex shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={classNames("absolute rounded-full transition-all", {
|
||||
"h-2 w-2": !isCurrentStep,
|
||||
"h-3 w-3": isCurrentStep,
|
||||
"bg-emphasis": isCompleted || isCurrentStep,
|
||||
"bg-muted": !isCompleted && !isCurrentStep,
|
||||
})}
|
||||
/>
|
||||
{(isCompleted || isCurrentStep) && (
|
||||
<div
|
||||
className={classNames("bg-emphasis absolute rounded-full", {
|
||||
"h-1 w-1": !isCurrentStep,
|
||||
"h-1.5 w-1.5": isCurrentStep,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button onClick={handleSignOut} color="minimal" className="text-subtle h-7">
|
||||
{t("sign_out")}
|
||||
@@ -61,4 +77,3 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { InfoBadge } from "@calcom/ui/components/badge";
|
||||
|
||||
import type { InviteRole } from "../store/onboarding-store";
|
||||
|
||||
type RoleSelectorProps = {
|
||||
value: InviteRole;
|
||||
onValueChange: (value: InviteRole) => void;
|
||||
showInfoBadge?: boolean;
|
||||
};
|
||||
|
||||
export const RoleSelector = ({ value, onValueChange, showInfoBadge = false }: RoleSelectorProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={value}
|
||||
onValueChange={(val) => val && onValueChange(val as InviteRole)}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
{showInfoBadge && <InfoBadge content={t("onboarding_modify_roles_later")} />}
|
||||
</div>
|
||||
{!showInfoBadge && (
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import classNames from "classnames";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -95,7 +97,11 @@ export const OnboardingBrowserView = ({
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">
|
||||
{name || t("your_name")}
|
||||
</h2>
|
||||
<p className="text-default text-sm leading-normal">
|
||||
<p
|
||||
className={classNames("text-sm leading-normal", {
|
||||
"text-default": bio,
|
||||
"text-subtle italic": !bio,
|
||||
})}>
|
||||
{bio || t("onboarding_browser_view_default_bio")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,91 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||
import { weekdayDates } from "@calcom/features/calendars/weeklyview/utils";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
|
||||
import { useOnboardingCalendarEvents } from "../hooks/useOnboardingCalendarEvents";
|
||||
|
||||
export const OnboardingCalendarBrowserView = () => {
|
||||
const { t } = useLocale();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
return weekdayDates(0, new Date(), 6);
|
||||
}, []);
|
||||
|
||||
const calendarIntegrations: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{
|
||||
name: t("google_calendar"),
|
||||
description: t("onboarding_calendar_browser_view_google_description"),
|
||||
icon: "calendar",
|
||||
},
|
||||
{
|
||||
name: t("outlook_calendar"),
|
||||
description: t("onboarding_calendar_browser_view_outlook_description"),
|
||||
icon: "mail",
|
||||
},
|
||||
{
|
||||
name: t("apple_calendar"),
|
||||
description: t("onboarding_calendar_browser_view_apple_description"),
|
||||
icon: "calendar-days",
|
||||
},
|
||||
];
|
||||
const { events } = useOnboardingCalendarEvents({ startDate, endDate });
|
||||
|
||||
// Memoize calendar props to prevent unnecessary re-initializations
|
||||
const calendarProps = useMemo(
|
||||
() => ({
|
||||
timezone: CURRENT_TIMEZONE,
|
||||
startDate,
|
||||
endDate,
|
||||
events: events || [],
|
||||
gridCellsPerHour: 4,
|
||||
hoverEventDuration: 0,
|
||||
showBackgroundPattern: false,
|
||||
showBorder: false,
|
||||
borderColor: "subtle" as const,
|
||||
hideHeader: true,
|
||||
eventsDisabled: true,
|
||||
sortEvents: true,
|
||||
isPending: false, // Explicitly set to false to prevent loading spinner
|
||||
loading: false, // Also set loading to false just in case
|
||||
onEventClick: () => {},
|
||||
onEmptyCellClick: () => {},
|
||||
onDateChange: () => {},
|
||||
showTimezone: true,
|
||||
}),
|
||||
[startDate, endDate, events]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
|
||||
{/* Browser header */}
|
||||
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex shrink-0 items-center gap-4 opacity-50">
|
||||
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/settings/calendars</p>
|
||||
</div>
|
||||
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
|
||||
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<span className="text-default text-sm font-semibold">
|
||||
{dayjs(startDate).format("MMM D")}
|
||||
<span className="mx-1">–</span>
|
||||
{dayjs(endDate).format("MMM D, YYYY")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="bg-muted h-full pl-11 pt-11">
|
||||
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
|
||||
{/* Header */}
|
||||
<div className="border-subtle flex flex-col gap-4 border-b p-4">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">
|
||||
{t("connect_your_calendar")}
|
||||
</h2>
|
||||
<p className="text-subtle text-sm leading-normal">
|
||||
{t("onboarding_calendar_browser_view_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Integrations List */}
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
{calendarIntegrations.map((integration, index) => (
|
||||
<div key={integration.name} className="opacity-30">
|
||||
{index > 0 && <div className="border-subtle h-px border-t" />}
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="bg-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
||||
<Icon name={integration.icon} className="text-emphasis h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="text-default text-sm font-semibold leading-none">{integration.name}</h3>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-emphasis flex h-6 items-center justify-center rounded-md px-2">
|
||||
<span className="text-emphasis text-xs font-medium leading-none">{t("connected")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Calendar View */}
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<Calendar {...calendarProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import classNames from "classnames";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
type OnboardingOrganizationBrowserViewProps = {
|
||||
avatar?: string | null;
|
||||
name?: string;
|
||||
bio?: string;
|
||||
slug?: string;
|
||||
bannerUrl?: string | null;
|
||||
};
|
||||
|
||||
export const OnboardingOrganizationBrowserView = ({
|
||||
avatar,
|
||||
name,
|
||||
bio,
|
||||
slug,
|
||||
bannerUrl,
|
||||
}: OnboardingOrganizationBrowserViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
const displayUrl = `${webappUrl}/${slug || ""}`;
|
||||
|
||||
const events: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{
|
||||
title: t("onboarding_browser_view_demo"),
|
||||
description: t("onboarding_browser_view_demo_description"),
|
||||
duration: 15,
|
||||
icon: "bell",
|
||||
},
|
||||
{
|
||||
title: t("onboarding_browser_view_quick_meeting"),
|
||||
description: t("onboarding_browser_view_quick_meeting_description"),
|
||||
duration: 15,
|
||||
icon: "bell",
|
||||
},
|
||||
{
|
||||
title: t("onboarding_browser_view_longer_meeting"),
|
||||
description: t("onboarding_browser_view_longer_meeting_description"),
|
||||
duration: 30,
|
||||
icon: "clock",
|
||||
},
|
||||
{
|
||||
title: t("in_person_meeting"),
|
||||
description: t("onboarding_browser_view_in_person_description"),
|
||||
duration: 120,
|
||||
icon: "map-pin",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
|
||||
{/* Browser header */}
|
||||
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex shrink-0 items-center gap-4 opacity-50">
|
||||
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-sm font-medium leading-tight">{displayUrl}</p>
|
||||
</div>
|
||||
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="bg-muted h-full pl-11 pt-11">
|
||||
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
|
||||
{/* Organization Profile Header with Banner */}
|
||||
<div className="border-subtle flex flex-col border-b">
|
||||
<div className="relative">
|
||||
{/* Banner Image */}
|
||||
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
|
||||
{bannerUrl ? (
|
||||
<img src={bannerUrl} alt={name || ""} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="bg-emphasis h-full w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organization Avatar - Overlaying the banner */}
|
||||
<div className="absolute -bottom-8 left-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar || undefined}
|
||||
alt={name || ""}
|
||||
className="border-4 border-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization Info */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">
|
||||
{name || t("organization_name")}
|
||||
</h2>
|
||||
<p
|
||||
className={classNames("text-sm leading-normal", {
|
||||
"text-default": bio,
|
||||
"text-subtle italic": !bio,
|
||||
})}>
|
||||
{bio || t("onboarding_browser_view_default_bio")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="flex flex-col overflow-y-hidden">
|
||||
{events.map((event, index) => (
|
||||
<div key={event.title} className="border opacity-30 first:border-t-0">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="text-default text-sm font-semibold leading-none">{event.title}</h3>
|
||||
<div className="bg-emphasis flex h-4 items-center justify-center gap-1 rounded-md px-1">
|
||||
<Icon name={event.icon} className="text-emphasis h-3 w-3" />
|
||||
<span className="text-emphasis text-xs font-medium leading-none">
|
||||
{event.duration} {t("minute_timeUnit")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
|
||||
</div>
|
||||
<Button color="secondary" size="sm" EndIcon="arrow-right">
|
||||
{t("book_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
type OnboardingTeamsBrowserViewProps = {
|
||||
teams: Array<{ name: string }>;
|
||||
organizationLogo?: string | null;
|
||||
organizationName?: string;
|
||||
organizationBanner?: string | null;
|
||||
};
|
||||
|
||||
export const OnboardingTeamsBrowserView = ({
|
||||
teams,
|
||||
organizationLogo,
|
||||
organizationName,
|
||||
organizationBanner,
|
||||
}: OnboardingTeamsBrowserViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
|
||||
// Show placeholder if no teams or all teams are empty
|
||||
const hasValidTeams = teams.some((team) => team.name && team.name.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
|
||||
{/* Browser header */}
|
||||
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex shrink-0 items-center gap-4 opacity-50">
|
||||
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/teams</p>
|
||||
</div>
|
||||
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-muted h-full pl-11 pt-11">
|
||||
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
|
||||
{/* Teams Header with Banner */}
|
||||
<div className="border-subtle flex flex-col border-b">
|
||||
<div className="relative">
|
||||
{/* Banner Image */}
|
||||
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
|
||||
{organizationBanner ? (
|
||||
<img
|
||||
src={organizationBanner}
|
||||
alt={organizationName || ""}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-emphasis h-full w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organization Logo - Overlaying the banner */}
|
||||
{organizationLogo && (
|
||||
<div className="absolute -bottom-8 left-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={organizationLogo}
|
||||
alt={organizationName || ""}
|
||||
className="border-4 border-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Teams Info */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
|
||||
<p className="text-subtle text-sm leading-normal">
|
||||
{hasValidTeams
|
||||
? t("onboarding_teams_browser_view_subtitle")
|
||||
: t("onboarding_teams_browser_view_placeholder_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams List */}
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
{hasValidTeams ? (
|
||||
teams
|
||||
.filter((team) => team.name && team.name.trim().length > 0)
|
||||
.map((team, index) => (
|
||||
<div key={index} className="">
|
||||
{index > 0 && <div className="border-subtle h-px border-t" />}
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={team.name}
|
||||
className="border-2 border-white"
|
||||
fallback={
|
||||
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
|
||||
<span className="text-sm font-semibold">{team.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="text-subtle text-sm font-semibold leading-none">{team.name}</h3>
|
||||
<p className="text-muted text-xs font-medium leading-tight">
|
||||
{t("onboarding_teams_browser_view_team_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
|
||||
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Icon name="users" className="text-subtle h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="text-default text-sm font-semibold leading-tight">
|
||||
{t("onboarding_teams_browser_view_no_teams_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-xs font-medium leading-tight">
|
||||
{t("onboarding_teams_browser_view_no_teams_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -106,7 +106,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={1}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title="Select plan"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
type UseOnboardingCalendarEventsProps = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
const emptyAvailabilityData = {
|
||||
busy: [],
|
||||
timeZone: "",
|
||||
dateRanges: [],
|
||||
oooExcludedDateRanges: [],
|
||||
workingHours: [],
|
||||
dateOverrides: [],
|
||||
currentSeats: null,
|
||||
datesOutOfOffice: {},
|
||||
} as GetUserAvailabilityResult;
|
||||
|
||||
export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardingCalendarEventsProps) => {
|
||||
const { data: session } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Watch for calendar installations to refetch events
|
||||
const { data: connectedCalendars } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
|
||||
refetchInterval: 5000, // Poll every 5 seconds to detect new calendar installations
|
||||
});
|
||||
|
||||
const { data: busyEvents } = trpc.viewer.availability.user.useQuery(
|
||||
{
|
||||
username: session?.user?.username || "",
|
||||
dateFrom: dayjs(startDate).startOf("day").utc().format(),
|
||||
dateTo: dayjs(endDate).endOf("day").utc().format(),
|
||||
withSource: true,
|
||||
},
|
||||
{
|
||||
enabled: !!session?.user?.username,
|
||||
// Don't show loading state - return empty array immediately
|
||||
placeholderData: emptyAvailabilityData,
|
||||
}
|
||||
);
|
||||
|
||||
// Refetch availability when calendars change
|
||||
useEffect(() => {
|
||||
if (connectedCalendars?.connectedCalendars) {
|
||||
utils.viewer.availability.user.invalidate();
|
||||
}
|
||||
}, [connectedCalendars?.connectedCalendars?.length, utils.viewer.availability.user]);
|
||||
|
||||
// Format events similar to Troubleshooter
|
||||
// Always return an array, never undefined
|
||||
const events = useMemo((): CalendarEvent[] => {
|
||||
if (!busyEvents?.busy) return [];
|
||||
|
||||
return busyEvents.busy.map((event, idx) => {
|
||||
return {
|
||||
id: idx,
|
||||
title: event.title ?? `Busy`,
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end),
|
||||
options: {
|
||||
color: event.source ? undefined : undefined,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [busyEvents]);
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading: false, // Never show loading state
|
||||
};
|
||||
};
|
||||
@@ -4,9 +4,12 @@ import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/us
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { sessionStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const ORG_MODAL_STORAGE_KEY = "showNewOrgModal";
|
||||
|
||||
const DEFAULT_EVENT_TYPES = [
|
||||
{
|
||||
title: "15min_meeting",
|
||||
@@ -56,9 +59,19 @@ export const useSubmitPersonalOnboarding = () => {
|
||||
}
|
||||
|
||||
await utils.viewer.me.get.refetch();
|
||||
// Set flag to show welcome modal after redirect
|
||||
setShowWelcomeToCalcomModalFlag();
|
||||
router.push("/event-types?welcomeToCalcomModal=true");
|
||||
// Check if org modal flag is set - if so, don't show personal modal
|
||||
// Organization onboarding takes precedence
|
||||
const hasOrgModalFlag = sessionStorage.getItem(ORG_MODAL_STORAGE_KEY) === "true";
|
||||
|
||||
if (!hasOrgModalFlag) {
|
||||
// Only set personal modal flag if org modal flag is not set
|
||||
setShowWelcomeToCalcomModalFlag();
|
||||
router.push("/event-types?welcomeToCalcomModal=true");
|
||||
} else {
|
||||
// Org modal flag exists - redirect to event-types without personal modal query param
|
||||
// The org modal will show via sessionStorage check
|
||||
router.push("/event-types?newOrganizationModal=true");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
|
||||
@@ -1,77 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ColorPicker, Label } from "@calcom/ui/components/form";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationBrandViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
const BrandColorPicker = ({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="border-default bg-default flex h-7 w-32 items-center gap-2 rounded-lg border px-2 py-1.5">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className="h-4 w-4 shrink-0 rounded-full border border-gray-200"
|
||||
style={{ backgroundColor: value }}
|
||||
aria-label={t("onboarding_pick_color_aria")}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content align="start" sideOffset={5}>
|
||||
<HexColorPicker color={value} onChange={onChange} className="!h-32 !w-32" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<input
|
||||
type="text"
|
||||
value={value.replace("#", "")}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value.startsWith("#") ? e.target.value : `#${e.target.value}`;
|
||||
onChange(newValue);
|
||||
}}
|
||||
className="text-emphasis grow border-none bg-transparent text-sm font-medium leading-4 outline-none"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { organizationBrand, setOrganizationBrand, organizationDetails } = useOnboardingStore();
|
||||
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
|
||||
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
const [_logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [_bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setBrandColor(organizationBrand.color);
|
||||
setLogoPreview(organizationBrand.logo);
|
||||
setBannerPreview(organizationBrand.banner);
|
||||
setBrandColor(organizationBrand.color);
|
||||
}, [organizationBrand]);
|
||||
|
||||
const handleLogoChange = (file: File | null) => {
|
||||
@@ -106,129 +66,116 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
// Save brand color to store
|
||||
setOrganizationBrand({ color: brandColor });
|
||||
router.push("/onboarding/organization/teams");
|
||||
const handleColorChange = (color: string) => {
|
||||
setBrandColor(color);
|
||||
setOrganizationBrand({ color });
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip brand setup and go to teams
|
||||
const handleContinue = () => {
|
||||
// Save to store (already saved on change, but ensure it's persisted)
|
||||
setOrganizationBrand({
|
||||
logo: logoPreview,
|
||||
banner: bannerPreview,
|
||||
color: brandColor,
|
||||
});
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_brand_title")}
|
||||
subtitle={t("onboarding_org_brand_subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("ill_do_this_later")}
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/organization/details")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-4">
|
||||
{/* Left side - Form */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Brand Color */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
|
||||
{t("onboarding_primary_color_label")}
|
||||
</p>
|
||||
<BrandColorPicker
|
||||
value={brandColor}
|
||||
onChange={(value) => {
|
||||
setBrandColor(value);
|
||||
setOrganizationBrand({ color: value });
|
||||
}}
|
||||
t={t}
|
||||
<div className="relative flex">
|
||||
{/* Scrollable content container */}
|
||||
<div className="relative h-full w-full gap-6">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Banner Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("onboarding_banner_label")}</p>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_logo_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_banner_label")}
|
||||
</p>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_banner_size_hint")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_banner_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
{/* Logo Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
{/* Brand Color */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("light_brand_color")}
|
||||
</Label>
|
||||
<ColorPicker defaultValue={brandColor} onChange={handleColorChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,7 +183,13 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={logoPreview}
|
||||
name={organizationDetails.name}
|
||||
bio={organizationDetails.bio}
|
||||
slug={organizationDetails.link}
|
||||
bannerUrl={bannerPreview}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
|
||||
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
|
||||
|
||||
@@ -77,60 +77,60 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_details_title")}
|
||||
subtitle={t("onboarding_org_details_subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Organization Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("organization_name")}
|
||||
</Label>
|
||||
<TextField
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("organization_name")}
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex">
|
||||
{/* Scrollable content container */}
|
||||
<div className="relative h-full w-full gap-6 px-2 py-2">
|
||||
{/* Top fade overlay */}
|
||||
|
||||
{/* Organization Link */}
|
||||
<ValidatedOrganizationSlug
|
||||
value={organizationLink}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Organization Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("organization_name")}
|
||||
</Label>
|
||||
<TextField
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("organization_name")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_org_bio_label")}
|
||||
</Label>
|
||||
<TextArea
|
||||
value={organizationBio}
|
||||
onChange={(e) => setOrganizationBio(e.target.value)}
|
||||
placeholder={t("onboarding_org_bio_placeholder")}
|
||||
rows={4}
|
||||
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Organization Link */}
|
||||
<ValidatedOrganizationSlug
|
||||
value={organizationLink}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
{/* Organization Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_org_bio_label")}
|
||||
</Label>
|
||||
<TextArea
|
||||
value={organizationBio}
|
||||
onChange={(e) => setOrganizationBio(e.target.value)}
|
||||
placeholder={t("onboarding_org_bio_placeholder")}
|
||||
rows={4}
|
||||
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +138,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={null}
|
||||
name={organizationName}
|
||||
bio={organizationBio}
|
||||
slug={organizationLink}
|
||||
bannerUrl={null}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useOnboardingStore, type Invite } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationCSVUploadModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const OrganizationCSVUploadModal = ({ isOpen, onClose }: OrganizationCSVUploadModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const store = useOnboardingStore();
|
||||
const { setInvites, teams } = store;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
showToast(t("please_upload_csv_file"), "error");
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const headers = ["email", "team", "role"];
|
||||
const exampleRow = ["example@email.com", "team-name", "MEMBER"];
|
||||
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "organization_invite_template.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast(t("template_downloaded"), "success");
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
showToast(t("please_select_file"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const text = await selectedFile.text();
|
||||
const lines = text.split("\n").filter((line) => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const emailIndex = headers.indexOf("email");
|
||||
const teamIndex = headers.indexOf("team");
|
||||
const roleIndex = headers.indexOf("role");
|
||||
|
||||
if (emailIndex === -1) {
|
||||
showToast(t("csv_missing_email_column"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredTeams = teams.filter((team) => team.name && team.name.trim().length > 0);
|
||||
const teamNames = filteredTeams.map((team) => team.name.toLowerCase());
|
||||
|
||||
const parsedInvites = lines
|
||||
.slice(1)
|
||||
.map((line) => {
|
||||
const values = line.split(",").map((v) => v.trim());
|
||||
const email = values[emailIndex]?.trim();
|
||||
const team = teamIndex !== -1 ? values[teamIndex]?.trim() : "";
|
||||
const role =
|
||||
roleIndex !== -1 ? (values[roleIndex]?.trim().toUpperCase() as "MEMBER" | "ADMIN") : "MEMBER";
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (teamIndex !== -1 && team && !teamNames.includes(team.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
team: team || (filteredTeams.length > 0 ? filteredTeams[0].name.toLowerCase() : ""),
|
||||
role: role === "ADMIN" ? "ADMIN" : "MEMBER",
|
||||
};
|
||||
})
|
||||
.filter((invite): invite is Invite => invite !== null);
|
||||
|
||||
if (parsedInvites.length === 0) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setInvites(parsedInvites);
|
||||
|
||||
showToast(t("csv_uploaded_successfully", { count: parsedInvites.length }), "success");
|
||||
onClose();
|
||||
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
} catch (error) {
|
||||
console.error("Error uploading CSV:", error);
|
||||
showToast(t("error_uploading_csv"), "error");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent size="default" className="!p-0">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="h-10 w-auto" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto flex items-center justify-center" style={{ width: 320, height: 180 }}>
|
||||
<div
|
||||
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
|
||||
style={{ width: 200, height: 150 }}>
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon name="upload" className="text-emphasis opacity-70" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-2 text-center">
|
||||
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("upload_csv_file")}</h2>
|
||||
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-3">
|
||||
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
|
||||
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
|
||||
</div>
|
||||
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
|
||||
{t("download_template")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
|
||||
<p className="text-subtle text-xs">
|
||||
{selectedFile ? selectedFile.name : t("upload_csv_description")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon={selectedFile ? "file-text" : "upload"}
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
{t("choose_file")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
|
||||
<Button color="minimal" onClick={handleClose} disabled={isUploading}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
loading={isUploading}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
|
||||
import { EmailInviteForm } from "../../../components/EmailInviteForm";
|
||||
import { InviteOptions } from "../../../components/InviteOptions";
|
||||
import { OnboardingCard } from "../../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../../components/OnboardingLayout";
|
||||
import { RoleSelector } from "../../../components/RoleSelector";
|
||||
import { OnboardingOrganizationBrowserView } from "../../../components/onboarding-organization-browser-view";
|
||||
import { useSubmitOnboarding } from "../../../hooks/useSubmitOnboarding";
|
||||
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
|
||||
import { OrganizationCSVUploadModal } from "../csv-upload-modal";
|
||||
|
||||
type OrganizationInviteEmailViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
team: string;
|
||||
role: "MEMBER" | "ADMIN";
|
||||
}[];
|
||||
};
|
||||
|
||||
export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEmailViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const flags = useFlags();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const usersEmailDomain = userEmail.split("@")[1];
|
||||
const {
|
||||
invites: storedInvites,
|
||||
inviteRole,
|
||||
setInvites,
|
||||
setInviteRole,
|
||||
organizationDetails,
|
||||
organizationBrand,
|
||||
} = store;
|
||||
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
|
||||
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
|
||||
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.string().email(t("invalid_email_address")),
|
||||
team: z.string().min(1, t("onboarding_team_required")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields,
|
||||
append: appendField,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
// Wrapper to ensure team is always provided when appending
|
||||
const append = (value: { email: string; team?: string; role: InviteRole }) => {
|
||||
appendField({
|
||||
email: value.email,
|
||||
team: value.team || "",
|
||||
role: value.role,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContinue = async (data: FormValues) => {
|
||||
setInvites(data.invites);
|
||||
await submitOnboarding(store, userEmail, data.invites);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/onboarding/organization/invite");
|
||||
};
|
||||
|
||||
const handleGoogleWorkspaceConnect = () => {
|
||||
console.log("Connect Google Workspace");
|
||||
};
|
||||
|
||||
const handleUploadCSV = () => {
|
||||
setIsCSVModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyInviteLink = () => {
|
||||
console.log("Copy invite link - disabled");
|
||||
};
|
||||
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
const email = form.watch(`invites.${index}.email`);
|
||||
const team = form.watch(`invites.${index}.team`);
|
||||
return email && email.trim().length > 0 && team && team.trim().length > 0;
|
||||
});
|
||||
|
||||
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
|
||||
const teams =
|
||||
filteredTeams.length > 0
|
||||
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_invite_title")}
|
||||
subtitle={t("onboarding_org_invite_subtitle_email")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidInvites || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={form.handleSubmit(handleContinue)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<Form form={form} handleSubmit={handleContinue} className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<EmailInviteForm
|
||||
fields={fields}
|
||||
append={append}
|
||||
remove={remove}
|
||||
defaultRole={inviteRole}
|
||||
showTeamSelect
|
||||
teams={teams}
|
||||
emailPlaceholder={`dave@${usersEmailDomain}`}
|
||||
/>
|
||||
|
||||
<RoleSelector
|
||||
value={inviteRole}
|
||||
showInfoBadge={true}
|
||||
onValueChange={(value) => {
|
||||
setInviteRole(value);
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={organizationBrand.logo}
|
||||
name={organizationDetails.name}
|
||||
bio={organizationDetails.bio}
|
||||
slug={organizationDetails.link}
|
||||
bannerUrl={organizationBrand.banner}
|
||||
/>
|
||||
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,307 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { InviteOptions } from "../../components/InviteOptions";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { OrganizationCSVUploadModal } from "./csv-upload-modal";
|
||||
|
||||
type OrganizationInviteViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
team: string;
|
||||
role: "MEMBER" | "ADMIN";
|
||||
}[];
|
||||
};
|
||||
|
||||
export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
// We are using email mode for now until we implement the other invite methods, csv, workspace - invite link
|
||||
const isEmailMode = true;
|
||||
const flags = useFlags();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const usersEmailDomain = userEmail.split("@")[1];
|
||||
const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
|
||||
const { setInvites, organizationDetails, organizationBrand } = store;
|
||||
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
|
||||
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.string().email(t("invalid_email_address")),
|
||||
team: z.string().min(1, t("onboarding_team_required")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
|
||||
// Save invites to store before submitting
|
||||
setInvites(invitesData);
|
||||
await submitOnboarding(store, userEmail, invitesData);
|
||||
};
|
||||
|
||||
const handleContinue = async (data?: FormValues) => {
|
||||
if (data) {
|
||||
await handleSubmitOnboarding(data.invites);
|
||||
} else {
|
||||
await handleSubmitOnboarding([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
// Complete onboarding without invites
|
||||
await handleSubmitOnboarding([]);
|
||||
const handleGoogleWorkspaceConnect = () => {
|
||||
console.log("Connect Google Workspace");
|
||||
};
|
||||
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push("/onboarding/organization/invite?mode=email");
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
const email = form.watch(`invites.${index}.email`);
|
||||
const team = form.watch(`invites.${index}.team`);
|
||||
return email && email.trim().length > 0 && team && team.trim().length > 0;
|
||||
});
|
||||
const handleUploadCSV = () => {
|
||||
setIsCSVModalOpen(true);
|
||||
};
|
||||
|
||||
// Get teams from store, filter out empty teams
|
||||
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
|
||||
const teams =
|
||||
filteredTeams.length > 0
|
||||
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
|
||||
: [];
|
||||
const handleCopyInviteLink = () => {
|
||||
console.log("Copy invite link - disabled");
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setInvites([]);
|
||||
await submitOnboarding(store, userEmail, []);
|
||||
};
|
||||
|
||||
const handleInvite = async () => {
|
||||
await submitOnboarding(store, userEmail, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="grid w-full max-w-[1200px] grid-cols-1 gap-6 lg:grid-cols-[1fr_auto]">
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">
|
||||
{t("onboarding_org_invite_title")}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{isEmailMode
|
||||
? t("onboarding_org_invite_subtitle_email")
|
||||
: t("onboarding_org_invite_subtitle_full")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
{!isEmailMode ? (
|
||||
// Coming soon
|
||||
// Initial invite options view
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Button color="secondary" className="w-full justify-center" StartIcon="mail">
|
||||
{t("onboarding_connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-subtle h-px flex-1" />
|
||||
<span className="text-subtle text-xs">{t("onboarding_or_divider")}</span>
|
||||
<div className="bg-subtle h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="flex-1 justify-center"
|
||||
onClick={handleInviteViaEmail}>
|
||||
{t("invite_via_email")}
|
||||
</Button>
|
||||
<Button color="secondary" className="flex-1 justify-center" StartIcon="upload">
|
||||
{t("onboarding_upload_csv")}
|
||||
</Button>
|
||||
<Button color="secondary" className="flex-1 justify-center" StartIcon="link">
|
||||
{t("onboarding_copy_invite_link")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
|
||||
options={[
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Email invite form
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email and Team inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.team">
|
||||
{t("team")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`invites.${index}.email`)}
|
||||
placeholder={`dave@${usersEmailDomain}`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
options={teams}
|
||||
value={teams.find((t) => t.value === form.watch(`invites.${index}.team`))}
|
||||
onChange={(option) => {
|
||||
if (option) {
|
||||
form.setValue(`invites.${index}.team`, option.value);
|
||||
}
|
||||
}}
|
||||
placeholder={t("select_team")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => append({ email: "", team: "", role: "MEMBER" })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
type={isEmailMode ? "submit" : "button"}
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={(isEmailMode && !hasValidInvites) || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={form.handleSubmit(handleContinue)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_invite_title")}
|
||||
subtitle={t("onboarding_org_invite_subtitle_full")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/organization/teams")}
|
||||
disabled={isSubmitting}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}>
|
||||
{t("ill_do_this_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip button */}
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
</div>
|
||||
}>
|
||||
<InviteOptions
|
||||
onInviteViaEmail={handleInviteViaEmail}
|
||||
onUploadCSV={handleUploadCSV}
|
||||
onCopyInviteLink={handleCopyInviteLink}
|
||||
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={organizationBrand.logo}
|
||||
name={organizationDetails.name}
|
||||
bio={organizationDetails.bio}
|
||||
slug={organizationDetails.link}
|
||||
bannerUrl={organizationBrand.banner}
|
||||
/>
|
||||
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,8 +10,9 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationTeamsViewProps = {
|
||||
@@ -25,7 +26,7 @@ type FormValues = {
|
||||
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teams: storedTeams, setTeams } = useOnboardingStore();
|
||||
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
|
||||
|
||||
const formSchema = z.object({
|
||||
teams: z.array(
|
||||
@@ -64,28 +65,38 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_teams_title")}
|
||||
subtitle={t("onboarding_org_teams_subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
form="teams-form"
|
||||
color="primary"
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
onClick={() => router.push("/onboarding/organization/brand")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="teams-form"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<Form id="teams-form" form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
<div className="w-full ">
|
||||
<div className="flex w-full flex-col gap-8 ">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
|
||||
@@ -132,6 +143,14 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
</div>
|
||||
</Form>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingTeamsBrowserView
|
||||
teams={form.watch("teams")}
|
||||
organizationLogo={organizationBrand.logo}
|
||||
organizationName={organizationDetails.name}
|
||||
organizationBanner={organizationBrand.banner}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingCalendarBrowserView } from "../../components/onboarding-calendar-browser-view";
|
||||
import { useSubmitPersonalOnboarding } from "../../hooks/useSubmitPersonalOnboarding";
|
||||
import { InstallableAppCard } from "../_components/InstallableAppCard";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { useAppInstallation } from "../_components/useAppInstallation";
|
||||
|
||||
type PersonalCalendarViewProps = {
|
||||
@@ -36,7 +36,7 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("connect_your_calendar")}
|
||||
|
||||
@@ -111,7 +111,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("add_your_details")}
|
||||
@@ -139,7 +139,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
<form
|
||||
id="personal-settings-form"
|
||||
onSubmit={handleContinue}
|
||||
className="flex w-full flex-col gap-6 px-5">
|
||||
className="flex w-full flex-col gap-6 px-1">
|
||||
{/* Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_picture")}</Label>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("create_your_team")}
|
||||
@@ -106,7 +106,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-5">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Team Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
|
||||
|
||||
@@ -7,13 +7,13 @@ import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { InfoBadge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, Label, TextField, ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
|
||||
import { EmailInviteForm } from "../../../components/EmailInviteForm";
|
||||
import { OnboardingCard } from "../../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../../components/OnboardingLayout";
|
||||
import { RoleSelector } from "../../../components/RoleSelector";
|
||||
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
|
||||
import { useCreateTeam } from "../../../hooks/useCreateTeam";
|
||||
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
|
||||
@@ -85,7 +85,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
@@ -107,74 +107,27 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-5">
|
||||
<div className="flex w-full flex-col gap-4 px-1">
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
<EmailInviteForm
|
||||
fields={fields}
|
||||
append={append}
|
||||
remove={remove}
|
||||
defaultRole={inviteRole}
|
||||
emailPlaceholder="rick@cal.com"
|
||||
/>
|
||||
|
||||
<div className="scroll-bar flex max-h-72 flex-col gap-2 overflow-y-auto">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`invites.${index}.email`)}
|
||||
placeholder={`rick@cal.com`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
onClick={() => append({ email: "", role: inviteRole })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setInviteRole(value as InviteRole);
|
||||
// Update all invites with the new role
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value as InviteRole);
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
<InfoBadge content={t("onboarding_modify_roles_later")} />
|
||||
</div>
|
||||
</div>
|
||||
<RoleSelector
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => {
|
||||
setInviteRole(value);
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value);
|
||||
});
|
||||
}}
|
||||
showInfoBadge
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ import React from "react";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { InviteOptions } from "../../components/InviteOptions";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingInviteBrowserView } from "../../components/onboarding-invite-browser-view";
|
||||
@@ -63,7 +63,7 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
@@ -97,63 +97,13 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-6 px-5">
|
||||
{/* Google Workspace Connect - Only show if feature flag is enabled */}
|
||||
{googleWorkspaceEnabled && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full rounded-[10px]"
|
||||
onClick={handleGoogleWorkspaceConnect}
|
||||
disabled={isSubmitting}>
|
||||
{t("connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
{/* Divider with "or" */}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
<span className="text-subtle text-sm font-medium">{t("or")}</span>
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Invite options */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleInviteViaEmail}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="mail" className="h-4 w-4" />
|
||||
<span>{t("invite_via_email")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleUploadCSV}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="upload" className="h-4 w-4" />
|
||||
<span>{t("upload_csv_file")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleCopyInviteLink}
|
||||
disabled>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="link" className="h-4 w-4" />
|
||||
<span>{t("copy_invite_link")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<InviteOptions
|
||||
onInviteViaEmail={handleInviteViaEmail}
|
||||
onUploadCSV={handleUploadCSV}
|
||||
onCopyInviteLink={handleCopyInviteLink}
|
||||
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.8.12",
|
||||
"version": "5.8.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -10,10 +10,11 @@ import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { TraceContext } from "@calcom/lib/tracing";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
async function handler(req: NextApiRequest & { userId?: number; traceContext: TraceContext }) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1") {
|
||||
@@ -52,8 +53,10 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
userId: session?.user?.id || -1,
|
||||
hostname: req.headers.host || "",
|
||||
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||
traceContext: req.traceContext,
|
||||
},
|
||||
});
|
||||
|
||||
// const booking = await createBookingThroughFactory();
|
||||
return booking;
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import type { createUsersFixture } from "playwright/fixtures/users";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { gotoBookingPage, saveEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
const normalizePhone = (s: string) => s.replace(/[^+\d]/g, "");
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Phone Location Auto-fill Feature", () => {
|
||||
test("should auto-fill untouched phone fields when phone location is selected", async ({ page, users }) => {
|
||||
await createUserWithPhoneFields({ users, page });
|
||||
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Verify custom phone fields start empty or country prefix (e.g., "+1")
|
||||
const v1 = await page.locator('[name="phone-1"]').inputValue();
|
||||
const v2 = await page.locator('[name="phone-2"]').inputValue();
|
||||
expect(v1 === "" || /^\+\d{1,3}$/.test(v1)).toBeTruthy();
|
||||
expect(v2 === "" || /^\+\d{1,3}$/.test(v2)).toBeTruthy();
|
||||
|
||||
// Select phone location and enter phone number
|
||||
const phoneNumber = "+14155551234";
|
||||
await selectPhoneLocation(page);
|
||||
await fillPhoneLocationInput(page, phoneNumber);
|
||||
|
||||
// Verify both custom phone fields are auto-filled (normalized)
|
||||
await expect
|
||||
.poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue()))
|
||||
.toBe(normalizePhone(phoneNumber));
|
||||
await expect
|
||||
.poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue()))
|
||||
.toBe(normalizePhone(phoneNumber));
|
||||
|
||||
// Skip booking confirmation to keep this test fast and focused on autofill behavior
|
||||
});
|
||||
|
||||
test("should NOT sync changes from custom phone fields back to location or other fields", async ({ page, users }) => {
|
||||
await createUserWithPhoneFields({ users, page });
|
||||
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Select phone location and enter phone number
|
||||
const locationPhoneNumber = "+14155551234";
|
||||
await selectPhoneLocation(page);
|
||||
await fillPhoneLocationInput(page, locationPhoneNumber);
|
||||
|
||||
// Verify both custom phone fields are auto-filled
|
||||
await expect
|
||||
.poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue()))
|
||||
.toBe(normalizePhone(locationPhoneNumber));
|
||||
await expect
|
||||
.poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue()))
|
||||
.toBe(normalizePhone(locationPhoneNumber));
|
||||
|
||||
// Now manually change phone-2 to a different number
|
||||
const differentPhoneNumber = "+14155559999";
|
||||
const phone2Input = page.locator('[name="phone-2"]');
|
||||
await phone2Input.clear();
|
||||
await phone2Input.fill(differentPhoneNumber);
|
||||
await phone2Input.blur(); // Trigger blur event
|
||||
|
||||
// Verify phone-1 is still the original location phone (NOT changed to phone-2's value)
|
||||
const phone1Value = await page.locator('[name="phone-1"]').inputValue();
|
||||
expect(normalizePhone(phone1Value)).toBe(normalizePhone(locationPhoneNumber));
|
||||
|
||||
// Verify location field is still the original value (NOT changed to phone-2's value)
|
||||
const locationValue = await page.locator(`[data-fob-field-name="location"] input`).inputValue();
|
||||
expect(normalizePhone(locationValue)).toBe(normalizePhone(locationPhoneNumber));
|
||||
|
||||
// Verify phone-2 has the new value
|
||||
const phone2Value = await page.locator('[name="phone-2"]').inputValue();
|
||||
expect(normalizePhone(phone2Value)).toBe(normalizePhone(differentPhoneNumber));
|
||||
});
|
||||
});
|
||||
|
||||
// Helper Functions
|
||||
|
||||
async function createUserWithPhoneFields({
|
||||
users,
|
||||
page,
|
||||
}: {
|
||||
users: ReturnType<typeof createUsersFixture>;
|
||||
page: Page;
|
||||
}) {
|
||||
try {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
|
||||
// Go to first event type
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
await $eventTypes.first().click();
|
||||
|
||||
// Enable Attendee Phone Number location
|
||||
await selectAttendeePhoneNumber(page);
|
||||
|
||||
// Add two custom phone fields
|
||||
await page.getByTestId("vertical-tab-event_advanced_tab_title").click();
|
||||
|
||||
await addPhoneQuestion(page, "phone-1", "Phone Number 1", true);
|
||||
await addPhoneQuestion(page, "phone-2", "Phone Number 2", true);
|
||||
|
||||
// Save once at the end
|
||||
await saveEventType(page);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error("Failed to create user with phone fields:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function addPhoneQuestion(page: Page, name: string, label: string, required: boolean) {
|
||||
await page.click('[data-testid="add-field"]');
|
||||
// Wait for modal to open by ensuring field-type control is present
|
||||
await page.waitForSelector("[id=test-field-type]");
|
||||
|
||||
// Select Phone type
|
||||
await page.locator("[id=test-field-type]").click();
|
||||
await page.waitForSelector('[data-testid="select-option-phone"]');
|
||||
await page.locator('[data-testid="select-option-phone"]').click();
|
||||
|
||||
// Fill name
|
||||
await page.fill('[name="name"]', name);
|
||||
|
||||
// Fill label
|
||||
await page.fill('[name="label"]', label);
|
||||
|
||||
// Set required if needed
|
||||
if (required) {
|
||||
// Try to find and check the required checkbox, but don't fail if it doesn't exist
|
||||
try {
|
||||
await page.waitForSelector('input[name="required"]', { timeout: 500 });
|
||||
const requiredCheckbox = page.locator('input[name="required"]').first();
|
||||
await requiredCheckbox.check();
|
||||
} catch {
|
||||
// Checkbox not found or not needed
|
||||
}
|
||||
}
|
||||
|
||||
// Click save button for the field
|
||||
await page.click('[data-testid="field-add-save"]');
|
||||
// Wait for the modal to close
|
||||
await page.locator('[data-testid="field-add-save"]').waitFor({ state: "detached" });
|
||||
}
|
||||
|
||||
async function selectAttendeePhoneNumber(page: Page) {
|
||||
await page.getByTestId("location-select").click();
|
||||
await page.getByTestId("location-select-item-phone").click();
|
||||
}
|
||||
|
||||
async function selectPhoneLocation(page: Page) {
|
||||
// When "Attendee Phone Number" is the location, the booking form
|
||||
// shows a phone input directly - no radio button selection needed.
|
||||
// Just wait for location field to be ready
|
||||
await page.waitForSelector('[data-fob-field-name="location"]');
|
||||
}
|
||||
|
||||
async function fillPhoneLocationInput(page: Page, phoneNumber: string) {
|
||||
// The location field has a phone input when "Attendee Phone Number" is selected
|
||||
await page.waitForSelector(`[data-fob-field-name="location"] input`);
|
||||
const locationInput = page.locator(`[data-fob-field-name="location"] input`);
|
||||
|
||||
// Ensure the field is empty first
|
||||
await locationInput.clear();
|
||||
// Wait for mask/prefix to settle (empty string or just country prefix)
|
||||
await expect.poll(async () => locationInput.inputValue()).toMatch(/^(?:|\+\d{1,3})$/);
|
||||
|
||||
// If the mask auto-inserts a country prefix, avoid duplicating it
|
||||
const prefill = await locationInput.inputValue();
|
||||
let toType = phoneNumber;
|
||||
if (/^\+\d{1,3}$/.test(prefill) && phoneNumber.startsWith(prefill)) {
|
||||
toType = phoneNumber.slice(prefill.length);
|
||||
}
|
||||
|
||||
// Type the phone number with a small delay to play nicely with masking
|
||||
await locationInput.pressSequentially(toType, { delay: 20 });
|
||||
|
||||
// Trigger blur to ensure the auto-fill effect runs
|
||||
await page.locator('[name="name"]').click();
|
||||
}
|
||||
|
||||
// removed local gotoBookingPage/selectFirstAvailableTimeSlot/saveEventType in favor of shared helpers
|
||||
@@ -10,6 +10,8 @@ import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { setupManagedEvent } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Bookings", () => {
|
||||
@@ -58,16 +60,14 @@ test.describe("Bookings", () => {
|
||||
const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => {
|
||||
test("Cannot choose date range presets", async ({ page, users }) => {
|
||||
const firstUser = await users.create();
|
||||
await firstUser.apiLogin();
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
@@ -91,7 +91,7 @@ test.describe("Bookings", () => {
|
||||
test.describe("Past bookings", () => {
|
||||
test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => {
|
||||
const firstUser = await users.create();
|
||||
const secondUser = await users.create();
|
||||
const _secondUser = await users.create();
|
||||
|
||||
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
|
||||
title: "Booking as organizer",
|
||||
@@ -112,15 +112,14 @@ test.describe("Bookings", () => {
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await page.locator('[data-testid="mark-no-show"]').click();
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
|
||||
// Wait for the dropdown to close before clicking again
|
||||
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -142,7 +141,7 @@ test.describe("Bookings", () => {
|
||||
});
|
||||
test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => {
|
||||
const firstUser = await users.create();
|
||||
const secondUser = await users.create();
|
||||
const _secondUser = await users.create();
|
||||
|
||||
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
|
||||
title: "Booking as organizer",
|
||||
@@ -158,23 +157,33 @@ test.describe("Bookings", () => {
|
||||
{ name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
|
||||
await firstUser.apiLogin();
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
|
||||
// Click the ellipsis button to open the all attendees dropdown
|
||||
const moreGuests = firstPastBooking.locator('[data-testid="more-guests"]');
|
||||
await moreGuests.click();
|
||||
const firstGuestInMore = page.getByRole("menuitemcheckbox").nth(0);
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: false });
|
||||
await firstGuestInMore.click();
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: true });
|
||||
const updateNoShow = firstPastBooking.locator('[data-testid="update-no-show"]');
|
||||
await updateNoShow.click();
|
||||
|
||||
// Find all the mark-no-show buttons in the dropdown (one for each attendee)
|
||||
const noShowButtons = page.locator('[data-testid="mark-no-show"]');
|
||||
|
||||
// Verify the 3rd attendee (index 2) has mark-no-show button (not already marked)
|
||||
const thirdAttendeeNoShowButton = noShowButtons.nth(2);
|
||||
await expect(thirdAttendeeNoShowButton).toBeVisible();
|
||||
|
||||
// Click the 3rd attendee's mark-no-show button
|
||||
await thirdAttendeeNoShowButton.click();
|
||||
|
||||
// Open dropdown again to verify the 3rd attendee now shows unmark-no-show
|
||||
await moreGuests.click();
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: true });
|
||||
const unmarkNoShowButtons = page.locator('[data-testid="unmark-no-show"]');
|
||||
|
||||
// The 3rd attendee should now have the unmark button visible
|
||||
await expect(unmarkNoShowButtons.first()).toBeVisible();
|
||||
});
|
||||
test("Team admin/owner can mark first attendee as no-show", async ({
|
||||
page,
|
||||
@@ -199,18 +208,16 @@ test.describe("Bookings", () => {
|
||||
});
|
||||
const booking = await bookingFixture.self();
|
||||
await adminUser.apiLogin();
|
||||
const { webhookReceiver, teamId } = await webhooks.createTeamReceiver();
|
||||
const { webhookReceiver } = await webhooks.createTeamReceiver();
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await page.locator('[data-testid="mark-no-show"]').click();
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
// Wait for the dropdown to close before clicking again
|
||||
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -232,7 +239,7 @@ test.describe("Bookings", () => {
|
||||
webhookReceiver.close();
|
||||
});
|
||||
|
||||
test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => {
|
||||
test("Can choose date range presets", async ({ page, users }) => {
|
||||
const firstUser = await users.create();
|
||||
await firstUser.apiLogin();
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
@@ -374,22 +381,15 @@ test.describe("Bookings", () => {
|
||||
|
||||
//verify with the booking titles
|
||||
const firstUpcomingBooking = bookingListItems.nth(0);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)
|
||||
).toBeVisible();
|
||||
await expect(firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)).toBeVisible();
|
||||
|
||||
const secondUpcomingBooking = bookingListItems.nth(1);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secondUpcomingBooking.locator(`text=${thirdUserAttendeeIndividualBooking!.title}`)
|
||||
).toBeVisible();
|
||||
|
||||
const thirdUpcomingBooking = bookingListItems.nth(2);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)
|
||||
).toBeVisible();
|
||||
await expect(thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Does not show booking from another user from collective event type when a member is filtered", async ({
|
||||
@@ -414,7 +414,7 @@ test.describe("Bookings", () => {
|
||||
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const eventType = await owner.getFirstTeamEvent(team.id);
|
||||
const { id: eventTypeId, title: teamEventTitle, slug: teamEventSlug } = eventType;
|
||||
const { id: eventTypeId, title: _teamEventTitle, slug: _teamEventSlug } = eventType;
|
||||
|
||||
// remove myself from host of this event type
|
||||
await prisma.host.delete({
|
||||
|
||||
@@ -212,7 +212,9 @@ test.describe("Event Types tests", () => {
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator("text=+19199999999")).toBeVisible();
|
||||
await expect(page.locator("text=+19199999999")).toHaveCount(2);
|
||||
await expect(page.locator("text=+19199999999").first()).toBeVisible();
|
||||
await expect(page.locator("text=+19199999999").nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Can add Organzer Phone Number location and book with it", async ({ page }) => {
|
||||
@@ -274,7 +276,6 @@ test.describe("Event Types tests", () => {
|
||||
});
|
||||
|
||||
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip("Can remove location from multiple locations that are saved", async ({ page }) => {
|
||||
await gotoFirstEventType(page);
|
||||
|
||||
@@ -419,7 +420,6 @@ test.describe("Event Types tests", () => {
|
||||
});
|
||||
test("should enable timezone lock in event advanced settings and verify disabled timezone selector on booking page", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
await gotoFirstEventType(page);
|
||||
await expect(page.locator("[data-testid=event-title]")).toBeVisible();
|
||||
|
||||
@@ -189,6 +189,7 @@ const createTeamAndAddUser = async (
|
||||
orgAutoAcceptEmail: user.email.split("@")[1],
|
||||
isOrganizationVerified: !!isOrgVerified,
|
||||
isOrganizationConfigured: isDnsSetup,
|
||||
orgAutoJoinOnSignup: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,9 +57,7 @@ test.describe("Locale-specific pages must not 404", () => {
|
||||
await user.apiLogin();
|
||||
const response = await page.goto(`/${locale}/bookings/upcoming`);
|
||||
expect(response?.status()).not.toBe(404);
|
||||
await expect(
|
||||
page.locator("text=See upcoming and past events booked through your event type links.")
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Bookings" })).toBeVisible();
|
||||
});
|
||||
|
||||
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe("Organization", () => {
|
||||
|
||||
// This test is already covered by booking.e2e.ts where existing user is invited and his booking links are tested.
|
||||
// We can re-test here when we want to test some more scenarios.
|
||||
|
||||
|
||||
test("existing user invited to an organization", () => {});
|
||||
|
||||
test("nonexisting user invited to a Team inside organization", async ({
|
||||
@@ -222,15 +222,6 @@ test.describe("Organization", () => {
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
orgSlug: org.slug,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
@@ -272,7 +263,7 @@ test.describe("Organization", () => {
|
||||
});
|
||||
|
||||
// Such a user has user.username changed directly in addition to having the new username in the profile.username
|
||||
test("existing user migrated to an organization", async ({ users, page, emails }) => {
|
||||
test("existing user migrated to an organization", async ({ users, page, emails: _emails }) => {
|
||||
const orgOwner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
@@ -313,7 +304,6 @@ test.describe("Organization", () => {
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
|
||||
|
||||
await page.locator('input[name="username"]').fill(existingUser.username!);
|
||||
await page
|
||||
.locator('input[name="email"]')
|
||||
@@ -346,23 +336,7 @@ test.describe("Organization", () => {
|
||||
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
|
||||
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
|
||||
await inviteAnEmail(page, invitedUserEmail, true);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
orgSlug: org.slug,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
@@ -594,7 +568,7 @@ async function expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId,
|
||||
email,
|
||||
role,
|
||||
role: _role,
|
||||
username,
|
||||
isMemberShipAccepted,
|
||||
}: {
|
||||
|
||||
@@ -41,7 +41,9 @@ test.describe("Reschedule Tests", async () => {
|
||||
await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it");
|
||||
|
||||
await page.locator('button[data-testid="send_request"]').click();
|
||||
await expect(page.locator('[id="modal-title"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="reschedule-dialog"]')).toBeHidden();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const updatedBooking = await booking.self();
|
||||
|
||||
@@ -58,7 +60,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
const _booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: dayjs().subtract(2, "day").toDate(),
|
||||
endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(),
|
||||
@@ -130,8 +132,9 @@ test.describe("Reschedule Tests", async () => {
|
||||
await user.apiLogin();
|
||||
await page.goto("/bookings/cancelled");
|
||||
|
||||
const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1);
|
||||
await expect(requestRescheduleSentElement).toBeVisible();
|
||||
await page.locator('[data-testid="booking-item"]').nth(0).click();
|
||||
|
||||
await expect(page.locator('[data-testid="request_reschedule_sent"]')).toBeVisible();
|
||||
await booking.delete();
|
||||
});
|
||||
|
||||
@@ -196,7 +199,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const payment = await payments.create(booking.id);
|
||||
const _payment = await payments.create(booking.id);
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -226,7 +229,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
paid: true,
|
||||
});
|
||||
|
||||
const payment = await payments.create(booking.id);
|
||||
const _payment = await payments.create(booking.id);
|
||||
await page.goto(`/reschedule/${booking?.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -330,7 +333,6 @@ test.describe("Reschedule Tests", async () => {
|
||||
test("Should load Valid Cal video url after rescheduling Opt in events", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
browser,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
@@ -339,11 +341,13 @@ test.describe("Reschedule Tests", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
|
||||
const confirmBooking = async (bookingId: number) => {
|
||||
const confirmBooking = async (_bookingId: number) => {
|
||||
const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser);
|
||||
await authedPage.goto("/bookings/upcoming");
|
||||
await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", {
|
||||
action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(),
|
||||
action: async () => {
|
||||
await authedPage.locator('[data-testid="booking-item"] [data-testid="confirm"]').nth(0).click();
|
||||
},
|
||||
});
|
||||
await authedContext.close();
|
||||
};
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
"manage_this_team": "Manage this team",
|
||||
"team_info": "Team Info",
|
||||
"join_meeting": "Join Meeting",
|
||||
"join_platform": "Join {{platform}}",
|
||||
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
|
||||
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
|
||||
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
|
||||
@@ -457,6 +458,7 @@
|
||||
"bookerlayout_error_unknown_layout": "The layout you selected is not a valid layout.",
|
||||
"bookerlayout_override_global_settings": "You can manage this for all your event types in Settings -> <0>Appearance</0> or <1>Override</1> for this event only.",
|
||||
"unexpected_error_try_again": "An unexpected error occurred. Try again.",
|
||||
"duplicate_event_slug_conflict": "An event type with this URL already exists. Please try a different URL.",
|
||||
"sunday_time_error": "Invalid time on Sunday",
|
||||
"monday_time_error": "Invalid time on Monday",
|
||||
"tuesday_time_error": "Invalid time on Tuesday",
|
||||
@@ -567,6 +569,7 @@
|
||||
"booking_paid": "Booking Paid",
|
||||
"ooo_created": "Out of Office Created",
|
||||
"booking_no_show_updated": "Booking No-Show Updated",
|
||||
"delegation_credential_error": "Delegation Credential Error",
|
||||
"event_triggers": "Event Triggers",
|
||||
"subscriber_url": "Subscriber URL",
|
||||
"create_new_webhook": "Create a new webhook",
|
||||
@@ -1620,12 +1623,14 @@
|
||||
"example_1": "Example 1",
|
||||
"example_2": "Example 2",
|
||||
"booking_question_identifier": "Booking Question Identifier",
|
||||
"form_field_identifier": "Form Field Identifier",
|
||||
"company_size": "Company size",
|
||||
"what_help_needed": "What do you need help with?",
|
||||
"variable_format": "Variable format",
|
||||
"webhook_subscriber_url_reserved": "Webhook subscriber url is already defined",
|
||||
"custom_input_as_variable_info": "Ignore all special characters of the additional input label (use only letters and numbers), use uppercase for all letters and replace whitespaces with underscores.",
|
||||
"using_booking_questions_as_variables": "How do I use booking questions as variables?",
|
||||
"using_form_responses_as_variables": "How do I use form responses as variables?",
|
||||
"download_desktop_app": "Download desktop app",
|
||||
"set_ping_link": "Set Ping link",
|
||||
"rate_limit_exceeded": "Rate limit exceeded",
|
||||
@@ -1707,7 +1712,10 @@
|
||||
"error_updating_password": "Error updating password",
|
||||
"two_factor_auth": "Two factor authentication",
|
||||
"recurring_event_tab_description": "Set up a repeating schedule",
|
||||
"today": "today",
|
||||
"today": "Today",
|
||||
"this_month": "This month",
|
||||
"time": "Time",
|
||||
"event": "Event",
|
||||
"appearance": "Appearance",
|
||||
"my_account": "My account",
|
||||
"general": "General",
|
||||
@@ -1921,6 +1929,7 @@
|
||||
"show_attendees": "Share attendee information between guests",
|
||||
"show_available_seats_count": "Show the number of available seats",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"how_form_responses_as_variables": "How to use form responses as variables?",
|
||||
"format": "Format",
|
||||
"questions": "Questions",
|
||||
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
|
||||
@@ -1937,6 +1946,7 @@
|
||||
"billing_portal": "Billing portal",
|
||||
"billing_help_cta": "Contact support",
|
||||
"ignore_special_characters_booking_questions": "Ignore special characters in your booking question identifier. Use only letters and numbers",
|
||||
"ignore_special_characters_form_responses": "Ignore special characters in your form field identifier. Use only letters and numbers",
|
||||
"retry": "Retry",
|
||||
"fetching_calendars_error": "There was a problem fetching your calendars. Please <1>try again</1> or reach out to customer support.",
|
||||
"calendar_connection_fail": "Calendar connection failed",
|
||||
@@ -2852,7 +2862,9 @@
|
||||
"call_to_confirm_booking": "Call to confirm booking",
|
||||
"cal_ai_phone_call_action_description": "2 hrs before event starts",
|
||||
"cal_ai_agent_configuration": "Cal.ai Agent Configuration",
|
||||
"select_event_type_to_schedule_calls": "Please select an event type to schedule calls",
|
||||
"choose_at_least_one_event_type_test_call": "Please choose at least one event type to make a test call.",
|
||||
"choose_event_type_in_agent_setup": "Please choose an event type in the agent setup.",
|
||||
"mark_as_no_show": "Mark as no-show",
|
||||
"unmark_as_no_show": "Unmark no-show",
|
||||
"account_unlinked_success": "Account unlinked successfully",
|
||||
@@ -2919,6 +2931,8 @@
|
||||
"delete_org_eventtypes": "Delete individual event types",
|
||||
"lock_org_users_eventtypes": "Lock individual event type creation",
|
||||
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
|
||||
"org_auto_join_title": "Automatically add new members to the organization if they sign up to Cal.com with the \"{{emailDomain}}\" email domain",
|
||||
"org_auto_join_description": "New members are added after they verify their email.",
|
||||
"add_to_event_type": "Add to event type",
|
||||
"create_account_password": "Create account password",
|
||||
"create_account_with_saml": "Create Account with SAML",
|
||||
@@ -3245,6 +3259,8 @@
|
||||
"lock_attribute_for_assignment": "Lock for assignment",
|
||||
"lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync",
|
||||
"attribute_edited_successfully": "Attribute edited successfully",
|
||||
"group_options": "Group Options",
|
||||
"group_options_description": "When a group option is assigned to a user, they behave as if all options within that group are assigned to them.",
|
||||
"new_group_option": "New group option",
|
||||
"attribute_weight_enabled": "Weights enabled",
|
||||
"attribute_weight_enabled_description": "By enabling weights, it would be possible to assign higher priority to certain attributes per user. The higher the weight, the higher the priority.",
|
||||
@@ -3864,7 +3880,10 @@
|
||||
"please_select_event_type_first": "Please select an event type first",
|
||||
"edit_configuration": "Edit Configuration",
|
||||
"select_event_type_for_inbound_calls": "Inbound calls can book only one event type. Select the event type where meetings will be scheduled when callers reach your agent.",
|
||||
"select_event_type_for_outbound_calls": "Select the event type that should be used to schedule meetings.",
|
||||
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
|
||||
"no_event_types_available_for_test_call": "No event types available for a test call",
|
||||
"create_block_entry": "Create Block Entry",
|
||||
"blocklist_entry_details": "Blocklist Entry Details",
|
||||
"blocklist_entry_not_found": "Blocklist entry not found",
|
||||
"blocklist_entry_created": "Blocklist entry created successfully",
|
||||
@@ -3872,7 +3891,7 @@
|
||||
"history": "History",
|
||||
"no_audit_history": "No audit history available",
|
||||
"remove_value_from_blocklist": "Remove {{value}} from blocklist?",
|
||||
"remove_value_from_blocklist_description":"The action is irreversible",
|
||||
"remove_value_from_blocklist_description": "The action is irreversible",
|
||||
"delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.",
|
||||
"created_by": "Created By",
|
||||
"blocked_by": "Blocked By",
|
||||
@@ -3956,7 +3975,44 @@
|
||||
"team_brand_subtitle": "Customize your team's branding with a logo and color",
|
||||
"team_details": "Team Details",
|
||||
"team_invite": "Team Invite",
|
||||
"slots": "Slots",
|
||||
"slots_taken": "{{takenSeats}}/{{totalSeats}} slots taken",
|
||||
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
|
||||
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
|
||||
"repeats_num_times": "Repeats {{count}} times",
|
||||
"view_booking_details": "View Booking Details",
|
||||
"recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences",
|
||||
"team_bio": "Team Bio",
|
||||
"team_bio_placeholder": "Tell us about your team...",
|
||||
"onboarding_invite_subtitle": "Connect your Google workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Team.",
|
||||
"upload_csv_subtitle": "Upload a CSV file with team member emails to bulk invite",
|
||||
"need_template": "Need a template?",
|
||||
"download_csv_template_description": "Download our CSV template to ensure your file is formatted correctly",
|
||||
"download_template": "Download template",
|
||||
"upload_your_file": "Upload your file",
|
||||
"upload_csv_description": "Select a CSV file with email addresses and optional roles",
|
||||
"choose_file": "Choose file",
|
||||
"please_upload_csv_file": "Please upload a CSV file",
|
||||
"template_downloaded": "Template downloaded successfully",
|
||||
"please_select_file": "Please select a file to upload",
|
||||
"csv_file_empty": "CSV file is empty or invalid",
|
||||
"csv_missing_email_column": "CSV file must contain an 'email' column",
|
||||
"csv_uploaded_successfully": "Successfully uploaded {{count}} invites",
|
||||
"error_uploading_csv": "Error uploading CSV file",
|
||||
"onboarding_browser_view_demo": "Demo",
|
||||
"onboarding_browser_view_demo_description": "Schedule a demo call with us",
|
||||
"onboarding_browser_view_quick_meeting": "Quick meeting",
|
||||
"onboarding_browser_view_quick_meeting_description": "A quick chat",
|
||||
"onboarding_browser_view_longer_meeting": "Longer meeting",
|
||||
"onboarding_browser_view_longer_meeting_description": "A longer chat",
|
||||
"onboarding_browser_view_in_person_description": "Meet in person",
|
||||
"onboarding_browser_view_ask_question": "Ask a question",
|
||||
"onboarding_browser_view_ask_question_description": "Ask a question",
|
||||
"onboarding_browser_view_default_bio": "Add your bio here...",
|
||||
"book_now": "Book now",
|
||||
"view": "View",
|
||||
"booking_response": "Booking Response",
|
||||
"list_view": "List view",
|
||||
"calendar_view": "Calendar view",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/auth/error?error=Invalid%20JWT%3A%20Please%20try%20again",
|
||||
@@ -76,8 +76,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) {
|
||||
const userExists = await prisma.user.findFirst({ select: { id: true } });
|
||||
if (!userExists) {
|
||||
// Proceed to new onboarding to create first admin user
|
||||
return {
|
||||
redirect: {
|
||||
|
||||
@@ -9,31 +9,31 @@ import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
|
||||
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
|
||||
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
const Params = z.object({
|
||||
username: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
provider: z.string({ required_error: "File is not named sso/[provider]" }),
|
||||
});
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
// get query params and typecast them to string
|
||||
// (would be even better to assert them instead of typecasting)
|
||||
const providerParam = asStringOrNull(context.query.provider);
|
||||
const emailParam = asStringOrNull(context.query.email);
|
||||
const usernameParam = asStringOrNull(context.query.username);
|
||||
export const getServerSideProps = async ({ req, query }: GetServerSidePropsContext) => {
|
||||
|
||||
const {
|
||||
provider: providerParam,
|
||||
email: emailParam,
|
||||
username: usernameParam,
|
||||
} = Params.parse(query);
|
||||
|
||||
const successDestination = await OnboardingPathService.getGettingStartedPathWithParams(
|
||||
prisma,
|
||||
usernameParam ? { username: usernameParam } : undefined
|
||||
);
|
||||
|
||||
if (!providerParam) {
|
||||
throw new Error(`File is not named sso/[provider]`);
|
||||
}
|
||||
|
||||
const { req } = context;
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
const { currentOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
if (session) {
|
||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||
|
||||
@@ -23,7 +23,7 @@ const mockFindFirstEventType = (
|
||||
return eventType;
|
||||
};
|
||||
|
||||
vi.mock("@calcom/emails/email-manager", () => {
|
||||
vi.mock("@calcom/emails/integration-email-service", () => {
|
||||
return {
|
||||
sendSlugReplacementEmail: () => ({}),
|
||||
};
|
||||
@@ -419,7 +419,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
timeZone: _timeZone,
|
||||
parentId: _parentId,
|
||||
userId: _userId,
|
||||
|
||||
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user