Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f738b3bc0a | ||
|
|
0853b4ca35 | ||
|
|
8be05b1dc5 | ||
|
|
71515ad17c | ||
|
|
66234ffba9 | ||
|
|
66c5f810ea | ||
|
|
81d9d84b4a | ||
|
|
6ddc666827 | ||
|
|
01346abb85 | ||
|
|
3d9650c841 | ||
|
|
f8ede75dd8 | ||
|
|
435587f043 | ||
|
|
e5abe93940 | ||
|
|
c3a5158a53 | ||
|
|
a9e14b9816 | ||
|
|
e207f40611 | ||
|
|
4ca2076c72 | ||
|
|
959b803d84 | ||
|
|
7f6136004c | ||
|
|
bb79368b2d | ||
|
|
70825dd138 | ||
|
|
05c399db9c | ||
|
|
57776b829b | ||
|
|
7fd28c39f3 | ||
|
|
ff17aa79fc | ||
|
|
578be32936 | ||
|
|
b183239ff5 | ||
|
|
013e614397 | ||
|
|
6843ced3db | ||
|
|
7318015a6e | ||
|
|
fc4d0a6aa3 | ||
|
|
8e0cdf3671 | ||
|
|
fcd9480f9f | ||
|
|
09ddbe886f | ||
|
|
4ac8a51bc2 | ||
|
|
82951cd04e |
@@ -486,3 +486,6 @@ CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||
SALESFORCE_GRAPHQL_DELAY_MS=500
|
||||
SALESFORCE_GRAPHQL_MAX_DELAY_MS=2000
|
||||
SALESFORCE_GRAPHQL_MAX_RETRIES=3
|
||||
|
||||
# Force Node.js to use UTC as process timezone
|
||||
TZ=UTC
|
||||
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
days-before-close: -1
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: -1
|
||||
stale-pr-message: "This PR is being marked as stale due to inactivity."
|
||||
close-pr-message: "This PR is being closed due to inactivity. Please reopen if work is intended to be continued."
|
||||
days-before-pr-stale: 7
|
||||
days-before-pr-close: 14
|
||||
stale-pr-message: "This PR has been marked as stale due to inactivity. If you're still working on it or need any help, please let us know or update the PR to keep it active."
|
||||
close-pr-message: "This PR has been closed due to inactivity. Please feel free to reopen it if you'd like to continue the work."
|
||||
operations-per-run: 100
|
||||
|
||||
Vendored
Vendored
Vendored
@@ -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 } from "vitest";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -11,6 +11,30 @@ 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" } });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -17,6 +17,30 @@ const DefaultPagination = {
|
||||
};
|
||||
|
||||
describe("GET /api/bookings", async () => {
|
||||
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 proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
@@ -318,11 +342,6 @@ describe("GET /api/bookings", async () => {
|
||||
|
||||
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
|
||||
const testUserBooking = await prisma.booking.findFirstOrThrow({
|
||||
where: { userId: testUser.id },
|
||||
include: { attendees: true },
|
||||
});
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
|
||||
@@ -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 } from "vitest";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -12,6 +12,53 @@ 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 } from "vitest";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -8,6 +8,30 @@ 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" } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||
import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input";
|
||||
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
||||
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
@@ -21,6 +22,7 @@ import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingResponse } from "@calcom/platform-libraries";
|
||||
import { type RegularBookingCreateResult } from "@calcom/platform-libraries/bookings";
|
||||
import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
@@ -40,6 +42,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
let recEventTypeId: number;
|
||||
|
||||
let createdBooking: RegularBookingCreateResult;
|
||||
|
||||
@@ -81,6 +84,18 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
const recEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `rec-bookings-2024-04-15-event-type-${randomString()}-${describe.name}`,
|
||||
slug: `rec-bookings-2024-04-15-event-type-${randomString()}-${describe.name}`,
|
||||
length: 60,
|
||||
recurringEvent: { freq: 2, count: 4, interval: 1 },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recEventTypeId = recEventType.id;
|
||||
|
||||
eventTypeId = event.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -404,6 +419,94 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create recurring bookings", async () => {
|
||||
const bookingEventTypeId = recEventTypeId;
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const bookingMetadata = {
|
||||
timeFormat: "12",
|
||||
meetingType: "organizer-phone",
|
||||
};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
notes: "test",
|
||||
};
|
||||
|
||||
const body: CreateRecurringBookingInput_2024_04_15[] = [
|
||||
{
|
||||
start: "2040-06-21T09:30:00.000Z",
|
||||
end: "2040-06-21T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id",
|
||||
},
|
||||
{
|
||||
start: "2040-06-27T09:30:00.000Z",
|
||||
end: "2040-06-27T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id-2",
|
||||
},
|
||||
{
|
||||
start: "2040-06-04T09:30:00.000Z",
|
||||
end: "2040-06-04T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id-3",
|
||||
},
|
||||
{
|
||||
start: "2040-06-11T09:30:00.000Z",
|
||||
end: "2040-06-11T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id-4",
|
||||
},
|
||||
];
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings/recurring")
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<BookingResponse[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
responseBody.data.forEach((booking) => {
|
||||
expect(booking.userPrimaryEmail).toBeDefined();
|
||||
expect(booking.userPrimaryEmail).toEqual(userEmail);
|
||||
expect(booking.id).toBeDefined();
|
||||
expect(booking.uid).toBeDefined();
|
||||
expect(booking.eventTypeId).toEqual(bookingEventTypeId);
|
||||
expect(booking.user.timeZone).toEqual(bookingTimeZone);
|
||||
expect(booking.metadata).toEqual(bookingMetadata);
|
||||
expect(booking.responses).toEqual(bookingResponses);
|
||||
expect(booking.creationSource).toEqual("API_V2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -318,11 +318,9 @@ export class BookingsController_2024_04_15 {
|
||||
recurringEvent.recurringEventId = recurringEventId;
|
||||
}
|
||||
}
|
||||
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
bookingData: body.map((booking) => ({ ...booking, creationSource: CreationSource.API_V2 })),
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
@@ -331,6 +329,7 @@ export class BookingsController_2024_04_15 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.body.noEmail,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ type: [Object] })
|
||||
allRecurringDates?: Record<string, string>[];
|
||||
allRecurringDates?: { start: string; end: string | undefined }[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
|
||||
@@ -320,6 +320,9 @@ export class BookingsController_2024_08_13 {
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
|
||||
\nCancelling normal bookings:
|
||||
If the booking is not seated and not recurring, simply pass :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and optionally cancellationReason in the request body \`{"cancellationReason": "Will travel"}\`.
|
||||
|
||||
\nCancelling seated bookings:
|
||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||
@@ -343,7 +346,7 @@ export class BookingsController_2024_08_13 {
|
||||
],
|
||||
},
|
||||
description:
|
||||
"Accepts different types of cancel booking input: Cancel Booking (Option 1) or Cancel Seated Booking (Option 2)",
|
||||
"Accepts different types of cancel booking input: Cancel Booking (Option 1 which is for normal or recurring bookings) or Cancel Seated Booking (Option 2 which is for seated bookings)",
|
||||
})
|
||||
@ApiExtraModels(CancelBookingInput_2024_08_13, CancelSeatedBookingInput_2024_08_13)
|
||||
async cancelBooking(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -24,9 +25,10 @@ import { Module } from "@nestjs/common";
|
||||
CacheService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
CheckBookingLimitsService,
|
||||
HashedLinkService,
|
||||
LuckyUserService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
})
|
||||
export class RegularBookingModule {}
|
||||
export class RegularBookingModule { }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { HashedLinkService as BaseHashedLinkService } from "@calcom/platform-libraries/private-links";
|
||||
|
||||
@Injectable()
|
||||
export class HashedLinkService extends BaseHashedLinkService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repos
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
@@ -16,6 +17,7 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
|
||||
prismaWriteService: PrismaWriteService,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
hashedLinkService: HashedLinkService,
|
||||
luckyUserService: LuckyUserService,
|
||||
userRepository: PrismaUserRepository
|
||||
) {
|
||||
@@ -24,6 +26,7 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
checkBookingAndDurationLimitsService,
|
||||
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
|
||||
bookingRepository,
|
||||
hashedLinkService,
|
||||
luckyUserService,
|
||||
userRepository,
|
||||
});
|
||||
|
||||
+5
-4
@@ -31,6 +31,7 @@ import {
|
||||
Delete,
|
||||
Query,
|
||||
NotFoundException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
|
||||
@@ -111,7 +112,7 @@ export class OAuthClientUsersController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async getUserById(
|
||||
@Param("clientId") clientId: string,
|
||||
@Param("userId") userId: number
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetManagedUserOutput> {
|
||||
const user = await this.validateManagedUserOwnership(clientId, userId);
|
||||
|
||||
@@ -127,7 +128,7 @@ export class OAuthClientUsersController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async updateUser(
|
||||
@Param("clientId") clientId: string,
|
||||
@Param("userId") userId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Body() body: UpdateManagedUserInput,
|
||||
@GetOrgId() organizationId: number
|
||||
): Promise<GetManagedUserOutput> {
|
||||
@@ -153,7 +154,7 @@ export class OAuthClientUsersController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async deleteUser(
|
||||
@Param("clientId") clientId: string,
|
||||
@Param("userId") userId: number
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetManagedUserOutput> {
|
||||
const user = await this.validateManagedUserOwnership(clientId, userId);
|
||||
await this.userRepository.delete(userId);
|
||||
@@ -174,7 +175,7 @@ export class OAuthClientUsersController {
|
||||
})
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async forceRefresh(
|
||||
@Param("userId") userId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("clientId") oAuthClientId: string
|
||||
): Promise<KeysResponseDto> {
|
||||
this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { isFallbackRoute } from "@calcom/app-store/routing-forms/lib/isFallbackRoute";
|
||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { useHasPaidPlan } from "@calcom/features/billing/hooks/useHasPaidPlan";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import { CreateButtonWithTeamsList } from "@calcom/features/ee/teams/components/createButton/CreateButtonWithTeamsList";
|
||||
@@ -15,7 +16,6 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 { OnboardingView } from "~/onboarding/getting-started/onboarding-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("getting_started")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/getting-started"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
// Hello {username} || there. Not sure how to do this nicely with i18n just yet
|
||||
const userName = session.user.name || "there";
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <OnboardingView userName={userName} userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export default async function OnboardingLayout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isOnboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
|
||||
if (!isOnboardingV3Enabled) {
|
||||
redirect("/getting-started");
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
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 { OrganizationBrandView } from "~/onboarding/organization/brand/organization-brand-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("organization_brand")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/brand"
|
||||
);
|
||||
};
|
||||
|
||||
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 <OrganizationBrandView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { OrganizationDetailsView } from "~/onboarding/organization/details/organization-details-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("organization_details")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/details"
|
||||
);
|
||||
};
|
||||
|
||||
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 <OrganizationDetailsView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { OrganizationInviteView } from "~/onboarding/organization/invite/organization-invite-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/invite"
|
||||
);
|
||||
};
|
||||
|
||||
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 <OrganizationInviteView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
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 { OrganizationTeamsView } from "~/onboarding/organization/teams/organization-teams-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("organization_teams")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/teams"
|
||||
);
|
||||
};
|
||||
|
||||
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 <OrganizationTeamsView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { PersonalCalendarView } from "~/onboarding/personal/calendar/personal-calendar-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("connect_calendar")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/personal/calendar"
|
||||
);
|
||||
};
|
||||
|
||||
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 <PersonalCalendarView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("personal_profile")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/personal/profile"
|
||||
);
|
||||
};
|
||||
|
||||
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 <PersonalProfileView 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 { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("personal_settings")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/personal/settings"
|
||||
);
|
||||
};
|
||||
|
||||
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 || "";
|
||||
const userName = session.user.name || "";
|
||||
|
||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { PersonalVideoView } from "~/onboarding/personal/video/personal-video-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("connect_video")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/personal/video"
|
||||
);
|
||||
};
|
||||
|
||||
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 <PersonalVideoView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { TeamBrandView } from "~/onboarding/teams/brand/team-brand-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("team_brand")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/teams/brand"
|
||||
);
|
||||
};
|
||||
|
||||
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 <TeamBrandView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { TeamDetailsView } from "~/onboarding/teams/details/team-details-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("team_details")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/teams/details"
|
||||
);
|
||||
};
|
||||
|
||||
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 <TeamDetailsView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { TeamInviteView } from "~/onboarding/teams/invite/team-invite-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("team_invite")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/teams/invite"
|
||||
);
|
||||
};
|
||||
|
||||
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 <TeamInviteView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -3,9 +3,13 @@ import { ApiError } from "next/dist/server/api-utils";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
|
||||
type Handler<T extends NextResponse | Response = NextResponse> = (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<Params> }
|
||||
@@ -34,15 +38,18 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
|
||||
|
||||
return NextResponse.json({});
|
||||
} catch (error) {
|
||||
let serverError;
|
||||
let serverError: HttpError;
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
serverError = {
|
||||
if (error instanceof TRPCError) {
|
||||
const statusCode = getHTTPStatusCodeFromError(error);
|
||||
serverError = new HttpError({ statusCode, message: error.message });
|
||||
} else if (error instanceof ApiError) {
|
||||
serverError = new HttpError({
|
||||
message: error.message,
|
||||
statusCode: error.statusCode,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
serverError = getServerErrorFromUnknown(error);
|
||||
}
|
||||
|
||||
@@ -623,7 +623,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
|
||||
@@ -903,7 +903,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent preventCloseOnOutsideClick>
|
||||
<DialogContent preventCloseOnOutsideClick enableOverflow>
|
||||
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
|
||||
<RerouteDialogContentAndFooter
|
||||
booking={teamEventTypeBooking}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
@@ -119,15 +120,17 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<NuqsAdapter>
|
||||
<FeatureFlagsProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</NuqsAdapter>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</EventCollectionProvider>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SSRConfig } from "next-i18next/dist/types/types";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
import dynamic from "next/dynamic";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/pages";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
@@ -286,15 +287,17 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<FeatureFlagsProvider>
|
||||
{_isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<NuqsAdapter>
|
||||
<FeatureFlagsProvider>
|
||||
{_isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</NuqsAdapter>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
|
||||
@@ -8,12 +8,12 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
|
||||
import {
|
||||
shouldHideBrandingForTeamEvent,
|
||||
shouldHideBrandingForUserEvent,
|
||||
} from "@calcom/features/profile/lib/hideBranding";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
@@ -82,9 +82,8 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
// App Router doesn't have access to the current path directly, so we build it manually
|
||||
destination: `${redirectWithOriginAndSearchString.origin ?? ""}/d/${link}/${slug}${
|
||||
redirectWithOriginAndSearchString.searchString
|
||||
}`,
|
||||
destination: `${redirectWithOriginAndSearchString.origin ?? ""}/d/${link}/${slug}${redirectWithOriginAndSearchString.searchString
|
||||
}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
"email-verification"
|
||||
);
|
||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||
const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
|
||||
const token = z.string().optional().parse(ctx.query.token);
|
||||
const redirectUrlData = z
|
||||
@@ -62,6 +63,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
isSAMLLoginEnabled,
|
||||
prepopulateFormValues: undefined,
|
||||
emailVerificationEnabled,
|
||||
onboardingV3Enabled,
|
||||
};
|
||||
|
||||
if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || signupDisabled) {
|
||||
|
||||
@@ -577,7 +577,7 @@ export default function Success(props: PageProps) {
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0" data-testid="booking-title">
|
||||
<div className="col-span-2 mb-6 break-words last:mb-0" data-testid="booking-title">
|
||||
{isRoundRobin ? bookingInfo.title : eventName}
|
||||
</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
|
||||
@@ -74,11 +74,9 @@ function useSystemSegments(userId?: number) {
|
||||
export default function Bookings(props: BookingsProps) {
|
||||
const pathname = usePathname();
|
||||
const systemSegments = useSystemSegments(props.userId);
|
||||
if (!pathname) return null;
|
||||
return (
|
||||
<DataTableProvider
|
||||
useSegments={useSegments}
|
||||
systemSegments={systemSegments}
|
||||
tableIdentifier={pathname || undefined}>
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} systemSegments={systemSegments}>
|
||||
<BookingsContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
|
||||
@@ -144,15 +144,17 @@ const InfiniteTeamsTab: FC<InfiniteTeamsTabProps> = (props) => {
|
||||
debouncedSearchTerm={debouncedSearchTerm}
|
||||
/>
|
||||
)}
|
||||
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
{(query.data?.pages?.[0]?.eventTypes?.length ?? 0) > 0 && (
|
||||
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -176,7 +178,7 @@ const Item = ({
|
||||
const content = () => (
|
||||
<div>
|
||||
<span
|
||||
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
|
||||
className="text-default break-words font-semibold ltr:mr-1 rtl:ml-1"
|
||||
data-testid={`event-type-title-${type.id}`}>
|
||||
{type.title}
|
||||
</span>
|
||||
@@ -210,7 +212,7 @@ const Item = ({
|
||||
<Link href={`/event-types/${type.id}?tabName=setup`} title={type.title}>
|
||||
<div>
|
||||
<span
|
||||
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
|
||||
className="text-default break-words font-semibold ltr:mr-1 rtl:ml-1"
|
||||
data-testid={`event-type-title-${type.id}`}>
|
||||
{type.title}
|
||||
</span>
|
||||
@@ -998,8 +1000,9 @@ const EventTypesPage = ({ userEventGroupsData, user }: Props) => {
|
||||
*/
|
||||
const redirectUrl = localStorage.getItem("onBoardingRedirect");
|
||||
localStorage.removeItem("onBoardingRedirect");
|
||||
redirectUrl && router.push(redirectUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
if (redirectUrl) {
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState, useReducer } from "react";
|
||||
|
||||
import {
|
||||
@@ -62,8 +63,10 @@ function reducer(state: CallDetailsState, action: CallDetailsAction): CallDetail
|
||||
}
|
||||
|
||||
function CallHistoryTable(props: CallHistoryProps) {
|
||||
const pathname = usePathname();
|
||||
if (!pathname) return null;
|
||||
return (
|
||||
<DataTableProvider useSegments={useSegments} defaultPageSize={25}>
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} defaultPageSize={25}>
|
||||
<CallHistoryContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { DataTableProvider } from "@calcom/features/data-table/DataTableProvider";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import {
|
||||
@@ -13,9 +15,12 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function InsightsRoutingFormResponsesPage() {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (!pathname) return null;
|
||||
|
||||
return (
|
||||
<DataTableProvider useSegments={useSegments}>
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments}>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<div className="mb-4 space-y-4">
|
||||
<RoutingFormResponsesTable />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
import {
|
||||
@@ -39,8 +40,10 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
|
||||
export default function InsightsPage({ timeZone }: { timeZone: string }) {
|
||||
const pathname = usePathname();
|
||||
if (!pathname) return null;
|
||||
return (
|
||||
<DataTableProvider useSegments={useSegments} timeZone={timeZone}>
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} timeZone={timeZone}>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<InsightsPageContent />
|
||||
</InsightsOrgTeamsProvider>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useOnboardingStore } from "../store/onboarding-store";
|
||||
|
||||
export const OnboardingContinuationPrompt = () => {
|
||||
const router = useRouter();
|
||||
const { organizationDetails, resetOnboarding } = useOnboardingStore();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if there's existing organization data
|
||||
const hasExistingData = organizationDetails.name && organizationDetails.link;
|
||||
setIsVisible(!!hasExistingData);
|
||||
}, [organizationDetails]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
// Navigate to the next step in the flow (brand page)
|
||||
router.push("/onboarding/organization/brand");
|
||||
};
|
||||
|
||||
const handleStartOver = () => {
|
||||
resetOnboarding();
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in fixed bottom-4 right-4 z-50">
|
||||
<div className="bg-default border-muted shadow-default relative w-[360px] rounded-lg border p-4">
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-muted hover:text-emphasis absolute right-2 top-2 rounded-md p-1 transition-colors">
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="mb-3 pr-6">
|
||||
<h3 className="text-emphasis mb-1 text-base font-semibold">Continue onboarding?</h3>
|
||||
<p className="text-subtle text-sm">
|
||||
Would you like to carry on with onboarding for{" "}
|
||||
<span className="text-emphasis font-medium">{organizationDetails.name}</span> or start over?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button onClick={handleStartOver} color="secondary">
|
||||
Start over
|
||||
</Button>
|
||||
<Button onClick={handleContinue} color="primary">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
export function PlanIcon({ icon, variant = "single" }: { icon: IconName; variant?: "single" | "double" }) {
|
||||
const renderIconContainer = (index: number) => {
|
||||
// For double variant: 55px icon width + 24px gap = 79px between centers, so ±39.5px from center
|
||||
const leftPosition = variant === "double" ? `calc(50% + ${index === 0 ? -39.5 : 39.5}px)` : "50%";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-default absolute top-[10px] flex h-[55px] w-[55px] -translate-x-1/2 items-center justify-center overflow-clip rounded-full"
|
||||
style={{
|
||||
left: leftPosition,
|
||||
background: "linear-gradient(to bottom, var(--cal-bg, #ffffff), var(--cal-bg-muted, #f7f7f7))",
|
||||
boxShadow:
|
||||
"0px 2.818px 5.635px 0px var(--cal-border-subtle), 0px 0px 0px 0.704px var(--cal-border), 0px 0.704px 3.522px -2.818px rgba(0, 0, 0, 0.3)",
|
||||
}}>
|
||||
{/* Icon */}
|
||||
<div className="size-8 flex items-center justify-center">
|
||||
<Icon name={icon} size={24} strokeWidth={1.75} className="text-emphasis opacity-80" />
|
||||
</div>
|
||||
|
||||
{/* Inner highlight/shine effect */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-full"
|
||||
style={{
|
||||
boxShadow: "0px 0.704px 0.423px 0px inset var(--cal-bg-inverted)",
|
||||
opacity: 0.15,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-[76px] w-[160px] shrink-0 overflow-visible">
|
||||
{/* Outer ring - SVG with linear gradient */}
|
||||
<svg
|
||||
className="pointer-events-none absolute left-[calc(50%+0.627px)] top-[-40px] -translate-x-1/2"
|
||||
width="156"
|
||||
height="156"
|
||||
viewBox="0 0 156 156"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(180deg, transparent 0%, black 2%, black 50%, black 75%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(180deg, transparent 0%, black 2%, black 50%, black 75%, transparent 100%)",
|
||||
}}>
|
||||
<circle opacity="0.3" cx="78" cy="78" r="77.5" stroke="url(#paint0_linear_outer)" strokeWidth="0.5" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_outer"
|
||||
x1="78"
|
||||
y1="0"
|
||||
x2="78"
|
||||
y2="156"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="var(--cal-border-default, #D3D3D3)" />
|
||||
<stop offset="1" stopColor="var(--cal-border-emphasis, #B2B2B2)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* Middle ring - SVG with linear gradient */}
|
||||
<svg
|
||||
className="pointer-events-none absolute left-[calc(50%+0.628px)] top-[-20.01px] -translate-x-1/2"
|
||||
width="111"
|
||||
height="111"
|
||||
viewBox="0 0 111 111"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(180deg, transparent 0%, black 2%, black 50%, black 75%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(180deg, transparent 0%, black 2%, black 50%, black 75%, transparent 100%)",
|
||||
}}>
|
||||
<circle
|
||||
opacity="0.4"
|
||||
cx="55.461"
|
||||
cy="55.455"
|
||||
r="55.211"
|
||||
stroke="url(#paint0_linear_middle)"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_middle"
|
||||
x1="55.461"
|
||||
y1="-0.006"
|
||||
x2="55.461"
|
||||
y2="110.916"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="var(--cal-border-default, #D3D3D3)" />
|
||||
<stop offset="1" stopColor="var(--cal-border-emphasis, #B2B2B2)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* Main icon container(s) with gradient background */}
|
||||
{variant === "single" ? renderIconContainer(0) : [renderIconContainer(0), renderIconContainer(1)]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { type IconName } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { RadioAreaGroup } from "@calcom/ui/components/radio";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
|
||||
import { PlanIcon } from "../components/plan-icon";
|
||||
import { useOnboardingStore, type PlanType } from "../store/onboarding-store";
|
||||
|
||||
type OnboardingViewProps = {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { selectedPlan, setSelectedPlan } = useOnboardingStore();
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedPlan === "organization") {
|
||||
router.push("/onboarding/organization/details");
|
||||
} else if (selectedPlan === "team") {
|
||||
router.push("/onboarding/teams/details");
|
||||
} else if (selectedPlan === "personal") {
|
||||
router.push("/onboarding/personal/settings");
|
||||
}
|
||||
};
|
||||
|
||||
const planIconByType: Record<PlanType, IconName> = {
|
||||
personal: "user",
|
||||
team: "users",
|
||||
organization: "users",
|
||||
};
|
||||
|
||||
const allPlans = [
|
||||
{
|
||||
id: "personal" as PlanType,
|
||||
title: t("onboarding_plan_personal_title"),
|
||||
badge: t("onboarding_plan_personal_badge"),
|
||||
description: t("onboarding_plan_personal_description"),
|
||||
icon: planIconByType.personal,
|
||||
variant: "single" as const,
|
||||
},
|
||||
{
|
||||
id: "team" as PlanType,
|
||||
title: t("onboarding_plan_team_title"),
|
||||
badge: t("onboarding_plan_team_badge"),
|
||||
description: t("onboarding_plan_team_description"),
|
||||
icon: planIconByType.team,
|
||||
variant: "single" as const,
|
||||
},
|
||||
{
|
||||
id: "organization" as PlanType,
|
||||
title: t("onboarding_plan_organization_title"),
|
||||
badge: t("onboarding_plan_organization_badge"),
|
||||
description: t("onboarding_plan_organization_description"),
|
||||
icon: planIconByType.organization,
|
||||
variant: "double" as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show organization plan for company emails
|
||||
const plans = allPlans.filter((plan) => {
|
||||
if (plan.id === "organization") {
|
||||
return isCompanyEmail(userEmail);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip">
|
||||
<OnboardingContinuationPrompt />
|
||||
{/* 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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis font-mediumu text-sm 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="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* 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_welcome_message", { userName })}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_welcome_question")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan options */}
|
||||
<RadioAreaGroup.Group
|
||||
value={selectedPlan ?? undefined}
|
||||
onValueChange={(value) => setSelectedPlan(value as PlanType)}
|
||||
className="flex w-full flex-col gap-1 rounded-[10px]">
|
||||
{plans.map((plan) => {
|
||||
const isSelected = selectedPlan === plan.id;
|
||||
|
||||
return (
|
||||
<RadioAreaGroup.Item
|
||||
key={plan.id}
|
||||
value={plan.id}
|
||||
className={classNames(
|
||||
"bg-default relative flex items-center overflow-hidden rounded-[10px] border transition",
|
||||
isSelected ? "border-emphasis shadow-sm" : "border-subtle",
|
||||
"pr-12 [&>button]:left-auto [&>button]:right-6 [&>button]:mt-0 [&>button]:transform"
|
||||
)}
|
||||
classNames={{
|
||||
container: "flex w-full items-center gap-4 p-4 pl-5 pr-12 md:p-5 md:pr-14",
|
||||
}}>
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<PlanIcon icon={plan.icon} variant={plan.variant} />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-emphasis text-base font-semibold leading-5">{plan.title}</p>
|
||||
<Badge
|
||||
variant="gray"
|
||||
size="md"
|
||||
className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center">
|
||||
<span className="text-emphasis text-xs font-medium leading-3">
|
||||
{plan.badge}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="gray"
|
||||
size="md"
|
||||
className="h-4 w-fit rounded-md px-1 py-1 md:hidden">
|
||||
<span className="text-emphasis text-xs font-medium leading-3">{plan.badge}</span>
|
||||
</Badge>
|
||||
<p className="text-subtle max-w-full text-sm font-normal leading-tight">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioAreaGroup.Item>
|
||||
);
|
||||
})}
|
||||
</RadioAreaGroup.Group>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { OnboardingState } from "../store/onboarding-store";
|
||||
|
||||
export function useCreateTeam() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const createTeamMutation = trpc.viewer.teams.create.useMutation();
|
||||
|
||||
const createTeam = async (store: OnboardingState) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const { teamDetails, teamBrand } = store;
|
||||
|
||||
// Create the team
|
||||
const result = await createTeamMutation.mutateAsync({
|
||||
name: teamDetails.name,
|
||||
slug: teamDetails.slug,
|
||||
logo: teamBrand.logo,
|
||||
});
|
||||
|
||||
// If there's a checkout URL, redirect to Stripe payment
|
||||
if (result.url && !result.team) {
|
||||
window.location.href = result.url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.team) {
|
||||
router.push("/getting-started");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create team:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createTeam,
|
||||
isSubmitting,
|
||||
error: createTeamMutation.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { OnboardingState } from "../store/onboarding-store";
|
||||
|
||||
export const useSubmitOnboarding = () => {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const intentToCreateOrg = trpc.viewer.organizations.intentToCreateOrg.useMutation();
|
||||
|
||||
const submitOnboarding = async (store: OnboardingState, userEmail: string) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const {
|
||||
selectedPlan,
|
||||
organizationDetails,
|
||||
organizationBrand,
|
||||
teams,
|
||||
invites,
|
||||
inviteRole,
|
||||
resetOnboarding,
|
||||
} = store;
|
||||
|
||||
if (selectedPlan !== "organization") {
|
||||
throw new Error("Only organization plan is currently supported");
|
||||
}
|
||||
|
||||
// Prepare teams data
|
||||
const teamsData = teams
|
||||
.filter((team) => team.name.trim().length > 0)
|
||||
.map((team) => ({
|
||||
id: -1, // New team
|
||||
name: team.name,
|
||||
isBeingMigrated: false,
|
||||
slug: null,
|
||||
}));
|
||||
|
||||
// Prepare invites data
|
||||
const invitedMembersData = invites
|
||||
.filter((invite) => invite.email.trim().length > 0)
|
||||
.map((invite) => ({
|
||||
email: invite.email,
|
||||
teamName: invite.team,
|
||||
teamId: -1,
|
||||
role: inviteRole,
|
||||
}));
|
||||
|
||||
const result = await intentToCreateOrg.mutateAsync({
|
||||
name: organizationDetails.name,
|
||||
slug: organizationDetails.link,
|
||||
bio: organizationDetails.bio || null,
|
||||
logo: organizationBrand.logo,
|
||||
brandColor: organizationBrand.color,
|
||||
bannerUrl: organizationBrand.banner,
|
||||
orgOwnerEmail: userEmail,
|
||||
seats: null,
|
||||
pricePerSeat: null,
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
teams: teamsData,
|
||||
invitedMembers: invitedMembersData,
|
||||
});
|
||||
|
||||
// If there's a checkout URL, redirect to Stripe (billing enabled flow)
|
||||
if (result.checkoutUrl) {
|
||||
window.location.href = result.checkoutUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// No checkout URL means billing is disabled (self-hosted flow)
|
||||
// Organization has already been created by the backend
|
||||
showToast("Organization created successfully!", "success");
|
||||
// TODO: after this redirect we need to hard refresh the page to see org
|
||||
resetOnboarding();
|
||||
router.push("/getting-started");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage, "error");
|
||||
console.error("Organization creation error:", err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
submitOnboarding,
|
||||
isSubmitting,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
"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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
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 [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);
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setBrandColor(organizationBrand.color);
|
||||
setLogoPreview(organizationBrand.logo);
|
||||
setBannerPreview(organizationBrand.banner);
|
||||
}, [organizationBrand]);
|
||||
|
||||
const handleLogoChange = (file: File | null) => {
|
||||
setLogoFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setLogoPreview(base64);
|
||||
setOrganizationBrand({ logo: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setLogoPreview(null);
|
||||
setOrganizationBrand({ logo: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (file: File | null) => {
|
||||
setBannerFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setBannerPreview(base64);
|
||||
setOrganizationBrand({ banner: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setBannerPreview(null);
|
||||
setOrganizationBrand({ banner: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
// Save brand color to store
|
||||
setOrganizationBrand({ color: brandColor });
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip brand setup and go to teams
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 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="relative flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* 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_brand_title")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_brand_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Right side - Preview */}
|
||||
<div className="bg-muted border-muted flex hidden h-[328px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
|
||||
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-[74px]">
|
||||
{/* Banner preview */}
|
||||
<div className="bg-muted border-muted absolute left-1 top-1 h-[92px] w-[272px] overflow-hidden rounded-[4px] border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 p-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Logo preview */}
|
||||
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
|
||||
{organizationDetails.name || t("onboarding_preview_nameless")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
|
||||
{t("onboarding_preview_example_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm font-medium leading-5">
|
||||
{t("onboarding_preview_example_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[134, 104, 84, 104].map((width, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-1">
|
||||
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
|
||||
<div
|
||||
className="bg-subtle h-2.5 rounded-full"
|
||||
style={{ width: `${width}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("ill_do_this_later")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export async function checkSlugAvailability(slug: string): Promise<{
|
||||
available: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
if (!slug || slug.trim() === "") {
|
||||
return { available: false, message: "Slug is required" };
|
||||
}
|
||||
|
||||
// Check if slug is reserved
|
||||
if (RESERVED_SUBDOMAINS.includes(slug)) {
|
||||
return { available: false, message: "This slug is reserved" };
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return { available: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
const existingOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
parentId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingOrg) {
|
||||
return { available: false, message: "This slug is already taken" };
|
||||
}
|
||||
|
||||
return { available: true };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, 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 { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
|
||||
|
||||
type OrganizationDetailsViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
// Helper function to slugify organization name
|
||||
const slugify = (text: string): string => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "") // Remove special characters
|
||||
.replace(/[\s_-]+/g, "-") // Replace spaces, underscores with single dash
|
||||
.replace(/^-+|-+$/g, ""); // Remove leading/trailing dashes
|
||||
};
|
||||
|
||||
export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { organizationDetails, setOrganizationDetails } = useOnboardingStore();
|
||||
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [organizationLink, setOrganizationLink] = useState("");
|
||||
const [organizationBio, setOrganizationBio] = useState("");
|
||||
const [isSlugValid, setIsSlugValid] = useState(false);
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setOrganizationName(organizationDetails.name);
|
||||
setOrganizationLink(organizationDetails.link);
|
||||
setOrganizationBio(organizationDetails.bio);
|
||||
// If there's a pre-existing link, consider it manually set
|
||||
if (organizationDetails.link) {
|
||||
setIsSlugManuallyEdited(true);
|
||||
}
|
||||
}, [organizationDetails]);
|
||||
|
||||
// Auto-populate slug from organization name (unless manually edited)
|
||||
useEffect(() => {
|
||||
if (!isSlugManuallyEdited && organizationName) {
|
||||
const slugifiedName = slugify(organizationName);
|
||||
setOrganizationLink(slugifiedName);
|
||||
}
|
||||
}, [organizationName, isSlugManuallyEdited]);
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
setOrganizationLink(value);
|
||||
setIsSlugManuallyEdited(true);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!isSlugValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to store
|
||||
setOrganizationDetails({
|
||||
name: organizationName,
|
||||
link: organizationLink,
|
||||
bio: organizationBio,
|
||||
});
|
||||
router.push("/onboarding/organization/brand");
|
||||
};
|
||||
|
||||
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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 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="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* 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_details_title")}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_details_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { checkSlugAvailability } from "./action/check-slug-availability";
|
||||
|
||||
type ValidationState = "idle" | "checking" | "available" | "taken";
|
||||
|
||||
type ValidatedOrganizationSlugProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
};
|
||||
|
||||
export function ValidatedOrganizationSlug({
|
||||
value,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
}: ValidatedOrganizationSlugProps) {
|
||||
const [validationState, setValidationState] = useState<ValidationState>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const [_isPending, startTransition] = useTransition();
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const validateSlug = useCallback(
|
||||
(slug: string) => {
|
||||
if (!slug || slug.trim() === "") {
|
||||
setValidationState("idle");
|
||||
setErrorMessage("");
|
||||
onValidationChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationState("checking");
|
||||
|
||||
// Call server action
|
||||
checkSlugAvailability(slug).then((result) => {
|
||||
startTransition(() => {
|
||||
if (result.available) {
|
||||
setValidationState("available");
|
||||
setErrorMessage("");
|
||||
onValidationChange?.(true);
|
||||
} else {
|
||||
setValidationState("taken");
|
||||
setErrorMessage(result.message || "This slug is not available");
|
||||
onValidationChange?.(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
[onValidationChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear previous timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
validateSlug(value);
|
||||
}, 500);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, validateSlug]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">Organization link</Label>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="acme"
|
||||
addOnSuffix={
|
||||
<div className="flex items-center gap-2">
|
||||
{validationState === "checking" && (
|
||||
<Icon name="loader" className="text-subtle h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<span className="text-subtle text-sm">.cal.com</span>
|
||||
</div>
|
||||
}
|
||||
className={classNames(validationState === "taken" ? "border-error" : "")}
|
||||
/>
|
||||
{validationState === "taken" && <p className="text-error text-sm">{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"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 { 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 { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
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 store = useOnboardingStore();
|
||||
const usersEmailDomain = userEmail.split("@")[1];
|
||||
const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
|
||||
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
|
||||
|
||||
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, 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);
|
||||
};
|
||||
|
||||
const handleContinue = async (data?: FormValues) => {
|
||||
if (data) {
|
||||
await handleSubmitOnboarding(data.invites);
|
||||
} else {
|
||||
await handleSubmitOnboarding([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
// Complete onboarding without invites
|
||||
await handleSubmitOnboarding([]);
|
||||
};
|
||||
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push("/onboarding/organization/invite?mode=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;
|
||||
});
|
||||
|
||||
// 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 }))
|
||||
: [];
|
||||
|
||||
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="flex w-full max-w-[600px] 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 gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
<Label className="text-emphasis text-sm font-medium">{t("team")}</Label>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5 pt-2 md:pt-0">
|
||||
<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="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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationTeamsViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
teams: { name: string }[];
|
||||
};
|
||||
|
||||
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teams: storedTeams, setTeams } = useOnboardingStore();
|
||||
|
||||
const formSchema = z.object({
|
||||
teams: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1, t("team_name_required")),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
teams: storedTeams.length > 0 ? storedTeams : [{ name: "" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "teams",
|
||||
});
|
||||
|
||||
const handleContinue = (data: FormValues) => {
|
||||
// Save teams to store
|
||||
setTeams(data.teams);
|
||||
router.push("/onboarding/organization/invite");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip teams and go to invite
|
||||
router.push("/onboarding/organization/invite");
|
||||
};
|
||||
|
||||
const hasValidTeams = fields.some((_, index) => {
|
||||
const teamName = form.watch(`teams.${index}.name`);
|
||||
return teamName && teamName.trim().length > 0;
|
||||
});
|
||||
|
||||
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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 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="flex w-full max-w-[600px] 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_teams_title")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_teams_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
{/* Team fields */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex w-full items-end gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`teams.${index}.name`)}
|
||||
placeholder={t("team")}
|
||||
className="h-7 w-full rounded-[10px] text-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({ name: "" })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="absolute left-[368px] rounded-[10px]"
|
||||
onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button type="submit" color="primary" className="rounded-[10px]" disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/InstallAppButtonWithoutPlanCheck";
|
||||
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
type AppData = {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string;
|
||||
type: App["type"];
|
||||
userCredentialIds: number[];
|
||||
};
|
||||
|
||||
type InstallableAppCardProps = {
|
||||
app: AppData;
|
||||
isInstalling: boolean;
|
||||
onInstallClick: (appSlug: string) => void;
|
||||
installOptions: UseAddAppMutationOptions;
|
||||
};
|
||||
|
||||
export const InstallableAppCard = ({
|
||||
app,
|
||||
isInstalling,
|
||||
onInstallClick,
|
||||
installOptions,
|
||||
}: InstallableAppCardProps) => {
|
||||
const { t } = useLocale();
|
||||
const isInstalled = app.userCredentialIds && app.userCredentialIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-subtle bg-default relative flex flex-col items-start gap-4 rounded-xl border p-5">
|
||||
{isInstalled && (
|
||||
<span className="bg-success text-success absolute right-2 top-2 rounded-md px-2 py-1 text-xs font-medium">
|
||||
{t("connected")}
|
||||
</span>
|
||||
)}
|
||||
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
|
||||
<p
|
||||
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
|
||||
title={app.name}>
|
||||
{app.name}
|
||||
</p>
|
||||
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">{app.description}</p>
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
type={app.type}
|
||||
options={installOptions}
|
||||
render={(buttonProps) => (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
color="secondary"
|
||||
disabled={isInstalled}
|
||||
type="button"
|
||||
loading={isInstalling || buttonProps?.loading}
|
||||
className="mt-auto w-full items-center justify-center rounded-[10px]"
|
||||
onClick={(event) => {
|
||||
// Save cookie to return to this page after OAuth
|
||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||
onInstallClick(app.slug);
|
||||
buttonProps?.onClick?.(event);
|
||||
}}>
|
||||
{isInstalled ? t("connected") : t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type OnboardingCardProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }: OnboardingCardProps) => {
|
||||
return (
|
||||
<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">{title}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex w-full flex-col gap-4 px-5 py-5">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
type OnboardingLayoutProps = {
|
||||
userEmail: string;
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
|
||||
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">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`bg-${step <= currentStep ? "emphasis" : "subtle"} ${
|
||||
step === currentStep ? "h-1.5 w-1.5" : "h-1 w-1"
|
||||
} 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="flex w-full max-w-[600px] flex-col gap-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
type SkipButtonProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SkipButton = ({ onClick, disabled }: SkipButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
type InstallSuccessCallback = (appSlug: string) => void;
|
||||
|
||||
export const useAppInstallation = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [installingAppSlug, setInstallingAppSlug] = useState<string | null>(null);
|
||||
|
||||
const createInstallHandlers = (appSlug: string, onSuccess?: InstallSuccessCallback) => {
|
||||
return {
|
||||
onSuccess: () => {
|
||||
setInstallingAppSlug(null);
|
||||
utils.viewer.apps.integrations.invalidate();
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
|
||||
// Call custom success callback if provided
|
||||
onSuccess?.(appSlug);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setInstallingAppSlug(null);
|
||||
if (error instanceof Error) {
|
||||
showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
installingAppSlug,
|
||||
setInstallingAppSlug,
|
||||
createInstallHandlers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { InstallableAppCard } from "../_components/InstallableAppCard";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { SkipButton } from "../_components/SkipButton";
|
||||
import { useAppInstallation } from "../_components/useAppInstallation";
|
||||
|
||||
type PersonalCalendarViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
|
||||
|
||||
const queryIntegrations = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "calendar",
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
sortByInstalledFirst: true,
|
||||
});
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/onboarding/personal/video");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push("/onboarding/personal/video");
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingCard
|
||||
title={t("connect_your_calendar")}
|
||||
subtitle={t("connect_calendar_to_prevent_conflicts")}
|
||||
isLoading={queryIntegrations.isPending}
|
||||
footer={
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
}>
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryIntegrations.data?.items?.map((app) => (
|
||||
<InstallableAppCard
|
||||
key={app.slug}
|
||||
app={app}
|
||||
isInstalling={installingAppSlug === app.slug}
|
||||
onInstallClick={setInstallingAppSlug}
|
||||
installOptions={createInstallHandlers(app.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
<SkipButton onClick={handleSkip} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { UserAvatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type PersonalProfileViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
export const PersonalProfileView = ({ userEmail }: PersonalProfileViewProps) => {
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { personalDetails, setPersonalDetails } = useOnboardingStore();
|
||||
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
// Update imageSrc when user loads
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setImageSrc(personalDetails.avatar || user.avatar || "");
|
||||
}
|
||||
}, [user, personalDetails.avatar]);
|
||||
|
||||
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||
defaultValues: { bio: personalDetails.bio || user?.bio || "" },
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Avatar mutation
|
||||
const avatarMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
setImageSrc(data.avatarUrl ?? "");
|
||||
setPersonalDetails({ avatar: data.avatarUrl ?? null });
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("problem_saving_user_profile"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
// Profile mutation
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (data: { bio: string }) => {
|
||||
const { bio } = data;
|
||||
|
||||
// Save to store
|
||||
setPersonalDetails({
|
||||
bio,
|
||||
});
|
||||
|
||||
// Save to backend
|
||||
await mutation.mutateAsync({
|
||||
bio,
|
||||
});
|
||||
|
||||
router.push("/onboarding/personal/calendar");
|
||||
});
|
||||
|
||||
async function updateProfileHandler(newAvatar: string) {
|
||||
avatarMutation.mutate({
|
||||
avatarUrl: newAvatar,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null; // or a loading spinner
|
||||
}
|
||||
|
||||
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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 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="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* 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("complete_your_profile")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("personal_profile_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
<form onSubmit={onSubmit} className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-6 rounded-xl">
|
||||
{/* Avatar */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("profile_photo")}
|
||||
</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-4 rtl:justify-end">
|
||||
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("add_profile_photo")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
if (avatarRef.current) {
|
||||
avatarRef.current.value = newAvatar;
|
||||
}
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current?.dispatchEvent(ev2);
|
||||
updateProfileHandler(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<UsernameAvailabilityField />
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-1 text-sm font-medium leading-4">
|
||||
{t("about")}
|
||||
</Label>
|
||||
<Editor
|
||||
getText={() => md.render(getValues("bio") || user?.bio || "")}
|
||||
setText={(value: string) => setValue("bio", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("few_sentences_about_yourself")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={onSubmit}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type PersonalSettingsViewProps = {
|
||||
userEmail: string;
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { personalDetails, setPersonalDetails } = useOnboardingStore();
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setName(personalDetails.name || userName || "");
|
||||
if (personalDetails.timezone) {
|
||||
setSelectedTimeZone(personalDetails.timezone);
|
||||
}
|
||||
}, [personalDetails, userName, setSelectedTimeZone]);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("name_required"))
|
||||
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
|
||||
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
|
||||
}),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: personalDetails.name || userName || "",
|
||||
},
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleContinue = form.handleSubmit(async (data) => {
|
||||
// Save to store
|
||||
setPersonalDetails({
|
||||
name: data.name,
|
||||
timezone: selectedTimeZone,
|
||||
});
|
||||
|
||||
// Save to backend
|
||||
await mutation.mutateAsync({
|
||||
name: data.name,
|
||||
timeZone: selectedTimeZone,
|
||||
});
|
||||
|
||||
router.push("/onboarding/personal/profile");
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start rounded-xl">
|
||||
<OnboardingContinuationPrompt />
|
||||
{/* 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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 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="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start ">
|
||||
{/* 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("welcome_to_cal_com")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("personal_settings_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start">
|
||||
<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">
|
||||
{/* Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<TextField
|
||||
label={t("full_name")}
|
||||
{...form.register("name")}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
form.setValue("name", e.target.value);
|
||||
}}
|
||||
placeholder="John Doe"
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">
|
||||
{t("timezone")}
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
className="rounded-[10px] text-sm"
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("current_time")}{" "}
|
||||
{dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending || !form.formState.isValid}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { InstallableAppCard } from "../_components/InstallableAppCard";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { SkipButton } from "../_components/SkipButton";
|
||||
import { useAppInstallation } from "../_components/useAppInstallation";
|
||||
|
||||
type PersonalVideoViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
const DEFAULT_EVENT_TYPES = [
|
||||
{
|
||||
title: "15min_meeting",
|
||||
slug: "15min",
|
||||
length: 15,
|
||||
},
|
||||
{
|
||||
title: "30min_meeting",
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
},
|
||||
{
|
||||
title: "secret_meeting",
|
||||
slug: "secret",
|
||||
length: 15,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => {
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const telemetry = useTelemetry();
|
||||
const utils = trpc.useUtils();
|
||||
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
|
||||
|
||||
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
sortByInstalledFirst: true,
|
||||
});
|
||||
|
||||
const setDefaultConferencingApp = trpc.viewer.apps.setDefaultConferencingApp.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
|
||||
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
try {
|
||||
// Create default event types if user has none
|
||||
if (eventTypes?.length === 0) {
|
||||
await Promise.all(
|
||||
DEFAULT_EVENT_TYPES.map(async (event) => {
|
||||
return createEventType.mutateAsync({
|
||||
title: t(event.title),
|
||||
slug: event.slug,
|
||||
length: event.length,
|
||||
hidden: event.hidden,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await utils.viewer.me.get.refetch();
|
||||
router.push("/event-types");
|
||||
},
|
||||
});
|
||||
|
||||
const handleContinue = async () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user has a default conferencing app
|
||||
const result = userMetadata.safeParse(user.metadata);
|
||||
const metadata = result?.success ? result.data : null;
|
||||
const hasDefaultConferencingApp = !!metadata?.defaultConferencingApp?.appSlug;
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={4}>
|
||||
<OnboardingCard
|
||||
title={t("connect_video_app")}
|
||||
subtitle={t("video_app_connection_subtitle")}
|
||||
isLoading={isPending}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("finish_and_start")}
|
||||
</Button>
|
||||
}>
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryConnectedVideoApps?.items
|
||||
.filter((app) => app.slug !== "daily-video")
|
||||
.map((app) => {
|
||||
const shouldAutoSetDefault =
|
||||
!hasDefaultConferencingApp && app.appData?.location?.linkType === "dynamic";
|
||||
|
||||
return (
|
||||
<InstallableAppCard
|
||||
key={app.slug}
|
||||
app={app}
|
||||
isInstalling={installingAppSlug === app.slug}
|
||||
onInstallClick={setInstallingAppSlug}
|
||||
installOptions={createInstallHandlers(app.slug, (appSlug) => {
|
||||
// Auto-set as default if it's the first connected video app
|
||||
if (shouldAutoSetDefault) {
|
||||
setDefaultConferencingApp.mutate({ slug: appSlug });
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
<SkipButton onClick={handleSkip} disabled={mutation.isPending} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type PlanType = "personal" | "team" | "organization";
|
||||
export type InviteRole = "MEMBER" | "ADMIN";
|
||||
|
||||
export interface OrganizationDetails {
|
||||
name: string;
|
||||
link: string;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
export interface OrganizationBrand {
|
||||
color: string;
|
||||
logo: string | null; // base64 or URL
|
||||
banner: string | null; // base64 or URL
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
email: string;
|
||||
team: string;
|
||||
role: InviteRole;
|
||||
}
|
||||
|
||||
export interface TeamDetails {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface TeamBrand {
|
||||
color: string;
|
||||
logo: string | null; // base64 or URL
|
||||
}
|
||||
|
||||
export interface PersonalDetails {
|
||||
name: string;
|
||||
username: string;
|
||||
timezone: string;
|
||||
bio: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
selectedPlan: PlanType | null;
|
||||
|
||||
organizationDetails: OrganizationDetails;
|
||||
|
||||
organizationBrand: OrganizationBrand;
|
||||
|
||||
teams: Team[];
|
||||
|
||||
invites: Invite[];
|
||||
inviteRole: InviteRole;
|
||||
|
||||
// Team-specific state
|
||||
teamDetails: TeamDetails;
|
||||
teamBrand: TeamBrand;
|
||||
teamInvites: Invite[];
|
||||
|
||||
// Personal user state
|
||||
personalDetails: PersonalDetails;
|
||||
|
||||
// Actions
|
||||
setSelectedPlan: (plan: PlanType) => void;
|
||||
setOrganizationDetails: (details: Partial<OrganizationDetails>) => void;
|
||||
setOrganizationBrand: (brand: Partial<OrganizationBrand>) => void;
|
||||
setTeams: (teams: Team[]) => void;
|
||||
setInvites: (invites: Invite[]) => void;
|
||||
setInviteRole: (role: InviteRole) => void;
|
||||
|
||||
// Team actions
|
||||
setTeamDetails: (details: Partial<TeamDetails>) => void;
|
||||
setTeamBrand: (brand: Partial<TeamBrand>) => void;
|
||||
setTeamInvites: (invites: Invite[]) => void;
|
||||
|
||||
// Personal actions
|
||||
setPersonalDetails: (details: Partial<PersonalDetails>) => void;
|
||||
|
||||
// Reset
|
||||
resetOnboarding: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
selectedPlan: "personal" as PlanType,
|
||||
organizationDetails: {
|
||||
name: "",
|
||||
link: "",
|
||||
bio: "",
|
||||
},
|
||||
organizationBrand: {
|
||||
color: "#000000",
|
||||
logo: null,
|
||||
banner: null,
|
||||
},
|
||||
teams: [],
|
||||
invites: [],
|
||||
inviteRole: "MEMBER" as InviteRole,
|
||||
teamDetails: {
|
||||
name: "",
|
||||
slug: "",
|
||||
},
|
||||
teamBrand: {
|
||||
color: "#000000",
|
||||
logo: null,
|
||||
},
|
||||
teamInvites: [],
|
||||
personalDetails: {
|
||||
name: "",
|
||||
username: "",
|
||||
timezone: "",
|
||||
bio: "",
|
||||
avatar: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const useOnboardingStore = create<OnboardingState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
setSelectedPlan: (plan) => set({ selectedPlan: plan }),
|
||||
|
||||
setOrganizationDetails: (details) =>
|
||||
set((state) => ({
|
||||
organizationDetails: { ...state.organizationDetails, ...details },
|
||||
})),
|
||||
|
||||
setOrganizationBrand: (brand) =>
|
||||
set((state) => ({
|
||||
organizationBrand: { ...state.organizationBrand, ...brand },
|
||||
})),
|
||||
|
||||
setTeams: (teams) => set({ teams }),
|
||||
|
||||
setInvites: (invites) => set({ invites }),
|
||||
|
||||
setInviteRole: (role) => set({ inviteRole: role }),
|
||||
|
||||
setTeamDetails: (details) =>
|
||||
set((state) => ({
|
||||
teamDetails: { ...state.teamDetails, ...details },
|
||||
})),
|
||||
|
||||
setTeamBrand: (brand) =>
|
||||
set((state) => ({
|
||||
teamBrand: { ...state.teamBrand, ...brand },
|
||||
})),
|
||||
|
||||
setTeamInvites: (invites) => set({ teamInvites: invites }),
|
||||
|
||||
setPersonalDetails: (details) =>
|
||||
set((state) => ({
|
||||
personalDetails: { ...state.personalDetails, ...details },
|
||||
})),
|
||||
|
||||
resetOnboarding: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: "cal-onboarding-storage", // localStorage key
|
||||
// Optional: Only persist certain fields
|
||||
partialize: (state) => ({
|
||||
selectedPlan: state.selectedPlan,
|
||||
organizationDetails: state.organizationDetails,
|
||||
organizationBrand: state.organizationBrand,
|
||||
teams: state.teams,
|
||||
invites: state.invites,
|
||||
inviteRole: state.inviteRole,
|
||||
teamDetails: state.teamDetails,
|
||||
teamBrand: state.teamBrand,
|
||||
teamInvites: state.teamInvites,
|
||||
personalDetails: state.personalDetails,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,258 @@
|
||||
"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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type TeamBrandViewProps = {
|
||||
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 TeamBrandView = ({ userEmail }: TeamBrandViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teamBrand, setTeamBrand, teamDetails } = useOnboardingStore();
|
||||
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
const [_logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBrandColor(teamBrand.color);
|
||||
setLogoPreview(teamBrand.logo);
|
||||
}, [teamBrand]);
|
||||
|
||||
const handleLogoChange = (file: File | null) => {
|
||||
setLogoFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setLogoPreview(base64);
|
||||
setTeamBrand({ logo: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setLogoPreview(null);
|
||||
setTeamBrand({ logo: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
setTeamBrand({ color: brandColor });
|
||||
router.push("/onboarding/teams/invite");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push("/onboarding/teams/invite");
|
||||
};
|
||||
|
||||
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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 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="relative flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* 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("customize_team_brand")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("team_brand_subtitle")}</p>
|
||||
</div>
|
||||
</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);
|
||||
setTeamBrand({ color: value });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Right side - Preview */}
|
||||
<div className="bg-muted border-muted flex hidden h-[260px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
|
||||
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-4">
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 p-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Logo preview */}
|
||||
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
|
||||
{teamDetails.name || t("onboarding_preview_nameless")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
|
||||
{t("onboarding_preview_example_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm font-medium leading-5">
|
||||
{t("onboarding_preview_example_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[134, 104, 84, 104].map((width, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-1">
|
||||
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
|
||||
<div
|
||||
className="bg-subtle h-2.5 rounded-full"
|
||||
style={{ width: `${width}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("ill_do_this_later")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export async function checkTeamSlugAvailability(slug: string): Promise<{
|
||||
available: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
if (!slug || slug.trim() === "") {
|
||||
return { available: false, message: "Slug is required" };
|
||||
}
|
||||
|
||||
// Check if slug is reserved
|
||||
if (RESERVED_SUBDOMAINS.includes(slug)) {
|
||||
return { available: false, message: "This slug is reserved" };
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return { available: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
// Check if slug already exists (teams have parentId, organizations don't)
|
||||
const existingTeam = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
parentId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeam) {
|
||||
return { available: false, message: "This slug is already taken" };
|
||||
}
|
||||
|
||||
return { available: true };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedTeamSlug } from "./validated-team-slug";
|
||||
|
||||
type TeamDetailsViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teamDetails, setTeamDetails } = useOnboardingStore();
|
||||
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [teamSlug, setTeamSlug] = useState("");
|
||||
const [isSlugValid, setIsSlugValid] = useState(false);
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTeamName(teamDetails.name);
|
||||
setTeamSlug(teamDetails.slug);
|
||||
if (teamDetails.slug) {
|
||||
setIsSlugManuallyEdited(true);
|
||||
}
|
||||
}, [teamDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSlugManuallyEdited && teamName) {
|
||||
const slugifiedName = slugify(teamName);
|
||||
setTeamSlug(slugifiedName);
|
||||
}
|
||||
}, [teamName, isSlugManuallyEdited]);
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
setTeamSlug(value);
|
||||
setIsSlugManuallyEdited(true);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!isSlugValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamDetails({
|
||||
name: teamName,
|
||||
slug: teamSlug,
|
||||
});
|
||||
router.push("/onboarding/teams/brand");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
<OnboardingContinuationPrompt />
|
||||
{/* 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.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 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="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* 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("create_your_team")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("team_onboarding_details_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
{/* Team Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_name")}</Label>
|
||||
<TextField
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Slug */}
|
||||
<ValidatedTeamSlug
|
||||
value={teamSlug}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !teamName || !teamSlug}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { checkTeamSlugAvailability } from "./action/check-team-slug-availability";
|
||||
|
||||
type ValidationState = "idle" | "checking" | "available" | "taken";
|
||||
|
||||
type ValidatedTeamSlugProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
};
|
||||
|
||||
export function ValidatedTeamSlug({ value, onChange, onValidationChange }: ValidatedTeamSlugProps) {
|
||||
const { t } = useLocale();
|
||||
const [validationState, setValidationState] = useState<ValidationState>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const [_isPending, startTransition] = useTransition();
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const validateSlug = useCallback(
|
||||
(slug: string) => {
|
||||
if (!slug || slug.trim() === "") {
|
||||
setValidationState("idle");
|
||||
setErrorMessage("");
|
||||
onValidationChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationState("checking");
|
||||
|
||||
const checkAvailability = async () => {
|
||||
const result = await checkTeamSlugAvailability(slug);
|
||||
|
||||
if (result.available) {
|
||||
setValidationState("available");
|
||||
setErrorMessage("");
|
||||
onValidationChange?.(true);
|
||||
} else {
|
||||
setValidationState("taken");
|
||||
setErrorMessage(result.message || "This slug is not available");
|
||||
onValidationChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
startTransition(() => {
|
||||
checkAvailability();
|
||||
});
|
||||
},
|
||||
[onValidationChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
validateSlug(value);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, validateSlug]);
|
||||
|
||||
const urlPrefix = `${WEBAPP_URL}/team/`;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_url")}</Label>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="acme"
|
||||
addOnLeading={urlPrefix}
|
||||
addOnSuffix={
|
||||
validationState === "checking" ? (
|
||||
<Icon name="loader" className="text-subtle h-3 w-3 animate-spin" />
|
||||
) : undefined
|
||||
}
|
||||
className={classNames(validationState === "taken" ? "border-error" : "")}
|
||||
/>
|
||||
{validationState === "taken" && <p className="text-error text-sm">{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
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 { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { useCreateTeam } from "../../hooks/useCreateTeam";
|
||||
import { useOnboardingStore, type InviteRole } from "../../store/onboarding-store";
|
||||
|
||||
type TeamInviteViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
role: InviteRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const { teamInvites, setTeamInvites, teamDetails } = store;
|
||||
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
|
||||
const { createTeam, isSubmitting } = useCreateTeam();
|
||||
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.string().email(t("invalid_email_address")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites:
|
||||
teamInvites.length > 0
|
||||
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
|
||||
: [{ email: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
const handleContinue = async (data: FormValues) => {
|
||||
const invitesWithTeam = data.invites.map((invite) => ({
|
||||
email: invite.email,
|
||||
team: teamDetails.name,
|
||||
role: invite.role,
|
||||
}));
|
||||
|
||||
setTeamInvites(invitesWithTeam);
|
||||
|
||||
// Create the team (will handle checkout redirect if needed)
|
||||
await createTeam(store);
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setTeamInvites([]);
|
||||
|
||||
// Create the team without invites (will handle checkout redirect if needed)
|
||||
await createTeam(store);
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
const email = form.watch(`invites.${index}.email`);
|
||||
return email && email.trim().length > 0;
|
||||
});
|
||||
|
||||
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="flex w-full max-w-[600px] 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("invite_team_members")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("team_invite_subtitle")}</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">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* 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") },
|
||||
]}
|
||||
/>
|
||||
</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="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidInvites || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={form.handleSubmit(handleContinue)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip button */}
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -184,6 +184,7 @@ export default function Signup({
|
||||
orgAutoAcceptEmail,
|
||||
redirectUrl,
|
||||
emailVerificationEnabled,
|
||||
onboardingV3Enabled,
|
||||
}: SignupProps) {
|
||||
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
|
||||
const [isSamlSignup, setIsSamlSignup] = useState(false);
|
||||
@@ -262,7 +263,8 @@ export default function Signup({
|
||||
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
|
||||
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : "getting-started";
|
||||
const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
|
||||
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
|
||||
const gettingStartedWithPlatform = "settings/platform/new";
|
||||
|
||||
const constructCallBackIfUrlPresent = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.8.3",
|
||||
"version": "5.8.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -117,7 +117,7 @@
|
||||
"next-seo": "^6.0.0",
|
||||
"next-themes": "^0.2.0",
|
||||
"nodemailer": "^6.7.8",
|
||||
"nuqs": "^1.20.0",
|
||||
"nuqs": "^2.7.2",
|
||||
"openid-client": "6.5.0",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.164.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { submitAndWaitForResponse } from "playwright/lib/testUtils";
|
||||
|
||||
// Helper function to get text within a specific table column
|
||||
export const getByTableColumnText = (page: Page, columnId: string, text: string) =>
|
||||
@@ -142,11 +143,14 @@ export async function deleteSegment(page: Page, segmentName: string) {
|
||||
|
||||
await page.getByTestId("filter-segment-select-submenu-content").getByText("Delete").click();
|
||||
|
||||
await page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ hasText: "Delete Segment" })
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
await submitAndWaitForResponse(page, "/api/trpc/filterSegments/delete?batch=1", {
|
||||
action: () =>
|
||||
page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ hasText: "Delete Segment" })
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click(),
|
||||
});
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("Filter segment deleted")).toBeVisible();
|
||||
@@ -156,7 +160,7 @@ export async function deleteSegment(page: Page, segmentName: string) {
|
||||
* List all available segments
|
||||
*/
|
||||
export async function listSegments(page: Page): Promise<string[]> {
|
||||
await page.getByTestId("filter-segment-select").click();
|
||||
await page.getByTestId("filter-segment-select").nth(0).click();
|
||||
|
||||
const menuItems = page.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]');
|
||||
const count = await menuItems.count();
|
||||
|
||||
@@ -78,6 +78,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
|
||||
await test.step("Can delete a filter segment", async () => {
|
||||
await deleteSegment(page, "Admin Users");
|
||||
await page.reload();
|
||||
|
||||
const segments = await listSegments(page);
|
||||
expect(segments.includes(segmentName)).toBe(false);
|
||||
@@ -109,7 +110,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
|
||||
await page.goto(`/settings/organizations/${org.slug}/members`);
|
||||
|
||||
const dataTable = page.getByTestId("user-list-data-table");
|
||||
const dataTable = page.getByTestId('user-list-data-table').nth(0);
|
||||
await expect(dataTable).toBeVisible();
|
||||
|
||||
await applySelectFilter(page, "role", "admin");
|
||||
|
||||
@@ -21,7 +21,7 @@ test.describe("Signup Main Page Test", async () => {
|
||||
await preventFlakyTest(page);
|
||||
});
|
||||
|
||||
test("Continue with email button must exist / work", async ({ page }) => {
|
||||
test("Continue with Email button must exist / work", async ({ page }) => {
|
||||
const button = page.getByTestId("continue-with-email-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
|
||||
@@ -89,7 +89,7 @@ test.describe("Team", () => {
|
||||
const context = await browser.newContext();
|
||||
const inviteLinkPage = await context.newPage();
|
||||
await inviteLinkPage.goto(inviteLink);
|
||||
await inviteLinkPage.waitForLoadState("domcontentloaded");
|
||||
await inviteLinkPage.waitForTimeout(3000);
|
||||
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await expect(inviteLinkPage.locator('[data-testid="field-error"]')).toHaveCount(2);
|
||||
|
||||
@@ -1178,7 +1178,7 @@
|
||||
"signin_with_google": "Sign in with Google",
|
||||
"signin_with_saml": "Sign in with SAML",
|
||||
"signin_with_saml_oidc": "Sign in with SAML/OIDC",
|
||||
"continue_with_email": "Continue with email",
|
||||
"continue_with_email": "Continue with Email",
|
||||
"continue_with_google": "Continue with Google",
|
||||
"last_used": "Last used",
|
||||
"you_will_need_to_generate": "You will need to generate an access token from your old scheduling tool.",
|
||||
@@ -1729,6 +1729,16 @@
|
||||
"set_availability_getting_started_subtitle_2": "Customize it later in the availability page.",
|
||||
"connect_calendar_first": "Connect a calendar first",
|
||||
"complete_profile": "Complete your profile",
|
||||
"complete_your_profile": "Complete your profile",
|
||||
"personal_profile_subtitle": "Add details to personalize your scheduling experience",
|
||||
"personal_settings_subtitle": "Let's set up your personal account",
|
||||
"profile_photo": "Profile photo",
|
||||
"connect_calendar_to_prevent_conflicts": "Connect your calendar to prevent double bookings and conflicts",
|
||||
"connect_video_app": "Connect your video app",
|
||||
"video_app_connection_subtitle": "Connect your preferred video conferencing app",
|
||||
"welcome_to_cal_com": "Welcome to Cal.com",
|
||||
"name_required": "Name is required",
|
||||
"connected": "Connected",
|
||||
"connect_calendars_from_app_store": "You can add more calendars from the app store",
|
||||
"connect_conference_apps": "Connect conference apps",
|
||||
"connect_calendar_apps": "Connect calendar apps",
|
||||
@@ -3846,5 +3856,46 @@
|
||||
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
|
||||
"what_would_you_like_to_block": "What would you like to block?",
|
||||
"domain_name": "Domain Name",
|
||||
"onboarding_org_details_title": "Add your Organization's details",
|
||||
"onboarding_org_details_subtitle": "Customize your booking pages with your Organization's name and bio",
|
||||
"onboarding_org_bio_label": "Organization bio",
|
||||
"onboarding_org_bio_placeholder": "Acme helps thousands of companies expand globally with unmatched speed and flexibility.",
|
||||
"onboarding_org_teams_title": "Add your Organization's teams",
|
||||
"onboarding_org_teams_subtitle": "Start scheduling together by adding your teams",
|
||||
"onboarding_org_brand_title": "Add your Organization's brand",
|
||||
"onboarding_org_brand_subtitle": "Customize your booking pages with your logo and a custom banner",
|
||||
"onboarding_primary_color_label": "Primary color",
|
||||
"onboarding_banner_label": "Banner",
|
||||
"onboarding_logo_size_hint": "Recommended size 64x64px (max 10mb)",
|
||||
"onboarding_banner_size_hint": "Recommended size 1500x150px (max 10mb)",
|
||||
"onboarding_preview_nameless": "Nameless",
|
||||
"onboarding_preview_example_title": "Enterprise sales",
|
||||
"onboarding_preview_example_description": "Jump on a call with one of our sales reps to learn more",
|
||||
"onboarding_logo_preview_alt": "Logo preview",
|
||||
"onboarding_banner_preview_alt": "Banner preview",
|
||||
"onboarding_pick_color_aria": "Pick color",
|
||||
"onboarding_org_invite_title": "Invite your teammates",
|
||||
"onboarding_org_invite_subtitle_email": "Enter your teammates email address and set their team to add them to your Organization.",
|
||||
"onboarding_org_invite_subtitle_full": "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 Organization.",
|
||||
"onboarding_connect_google_workspace": "Connect Google Workspace",
|
||||
"onboarding_upload_csv": "Upload CSV file",
|
||||
"onboarding_copy_invite_link": "Copy invite link",
|
||||
"onboarding_invite_all_as": "Invite all as",
|
||||
"onboarding_admins": "Admins",
|
||||
"onboarding_modify_roles_later": "You can modify roles later",
|
||||
"onboarding_skip_for_now": "Skip for now",
|
||||
"onboarding_or_divider": "or",
|
||||
"onboarding_team_required": "Team is required",
|
||||
"onboarding_welcome_message": "Hey {{userName}}",
|
||||
"onboarding_welcome_question": "To personalize your experience, what do you plan to use Cal.com for?",
|
||||
"onboarding_plan_personal_title": "For personal use",
|
||||
"onboarding_plan_personal_badge": "Free",
|
||||
"onboarding_plan_personal_description": "Good for individuals who are just starting out and simply want the essentials.",
|
||||
"onboarding_plan_team_title": "With my team",
|
||||
"onboarding_plan_team_description": "Highly recommended for small teams who seek to upgrade their time and perform better as a unit.",
|
||||
"onboarding_plan_organization_title": "For my organization",
|
||||
"onboarding_plan_team_badge": "$15/user/mo",
|
||||
"onboarding_plan_organization_badge": "$37/user/mo",
|
||||
"onboarding_plan_organization_description": "Robust scheduling for larger teams looking to have more control, privacy, and security.",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export function getBasicMockRequestDataForBooking() {
|
||||
user: "teampro",
|
||||
metadata: {},
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +34,9 @@ type CommonPropsMockRequestData = {
|
||||
attendeePhoneNumber?: string;
|
||||
smsReminderNumber?: string;
|
||||
};
|
||||
_isDryRun?: boolean;
|
||||
hashedLink?: string;
|
||||
hasHashedBookingLink?: boolean;
|
||||
};
|
||||
|
||||
export function getMockRequestDataForBooking({
|
||||
|
||||
@@ -8518,7 +8518,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n \n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling normal bookings:\n If the booking is not seated and not recurring simply pass :bookingUid in the request URL `/bookings/:bookingUid/cancel` and optionally cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}`.\n\n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n \n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -8568,7 +8568,7 @@
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"description": "Accepts different types of cancel booking input: Cancel Booking (Option 1) or Cancel Seated Booking (Option 2)",
|
||||
"description": "Accepts different types of cancel booking input: Cancel Booking (Option 1 which is for normal or recurring bookings) or Cancel Seated Booking (Option 2 which is for seated bookings)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -21516,7 +21516,7 @@
|
||||
"type": "string",
|
||||
"description": "Action to perform",
|
||||
"example": "email_host",
|
||||
"enum": ["email_attendee", "email_address"]
|
||||
"enum": ["email_attendee", "email_address", "sms_attendee", "sms_number"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "stepNumber", "recipient", "template", "sender", "message", "action"]
|
||||
@@ -22537,7 +22537,7 @@
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address",
|
||||
"description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address,sms_attendee,sms_number",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -22545,6 +22545,12 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/WorkflowEmailAttendeeStepDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/WorkflowPhoneAttendeeStepDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/WorkflowPhoneNumberStepDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23136,7 +23142,7 @@
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address",
|
||||
"description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address,sms_attendee,sms_number",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -23144,6 +23150,12 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateEmailAttendeeWorkflowStepDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdatePhoneAttendeeWorkflowStepDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdatePhoneNumberWorkflowStepDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27029,7 +27041,7 @@
|
||||
},
|
||||
"cancelSubsequentBookings": {
|
||||
"type": "boolean",
|
||||
"description": "For recurring non-seated booking - if true, cancel booking with the bookingUid of the individual recurrence and all recurrences that come after it."
|
||||
"description": "For recurring non-seated booking only - if true, cancel booking with the bookingUid of the individual recurrence and all recurrences that come after it."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export default {
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
process.env.SKIP_WARNINGS === "1" ? "eslint --fix" : "eslint --fix --max-warnings=0",
|
||||
],
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": (files) =>
|
||||
process.env.SKIP_WARNINGS === "1"
|
||||
? `eslint --fix --flag v10_config_lookup_from_file ${files.join(" ")}`
|
||||
: `eslint --fix --flag v10_config_lookup_from_file --max-warnings=0 ${files.join(" ")}`,
|
||||
"*.json": ["prettier --write"],
|
||||
"packages/prisma/schema.prisma": ["prisma format"],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useApp from "@calcom/lib/hooks/useApp";
|
||||
import useApp from "@calcom/features/apps/hooks/useApp";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
@@ -636,6 +636,24 @@ Cal("on", {
|
||||
callback: bookingSuccessfulV2Callback,
|
||||
});
|
||||
|
||||
const availabilityLoadedCallback = (e: EmbedEvent<"availabilityLoaded">) => {
|
||||
const data = e.detail.data;
|
||||
console.log("availabilityLoaded", {
|
||||
eventId: data.eventId,
|
||||
eventSlug: data.eventSlug,
|
||||
});
|
||||
|
||||
Cal("off", {
|
||||
action: "availabilityLoaded",
|
||||
callback: availabilityLoadedCallback,
|
||||
});
|
||||
};
|
||||
|
||||
Cal("on", {
|
||||
action: "availabilityLoaded",
|
||||
callback: availabilityLoadedCallback,
|
||||
});
|
||||
|
||||
if (only === "all" || only === "ns:skeletonDemo") {
|
||||
Cal("init", "skeletonDemo", {
|
||||
debug: true,
|
||||
|
||||
@@ -37,6 +37,10 @@ export type EventDataMap = {
|
||||
};
|
||||
};
|
||||
linkReady: Record<string, never>;
|
||||
availabilityLoaded: {
|
||||
eventId: number;
|
||||
eventSlug: string;
|
||||
};
|
||||
__connectInitiated: Record<string, never>;
|
||||
__connectCompleted: Record<string, never>;
|
||||
bookingSuccessfulV2: BaseBookingEventPayload & {
|
||||
|
||||
@@ -50,6 +50,24 @@ function App() {
|
||||
callback,
|
||||
});
|
||||
|
||||
const availabilityLoadedCallback = (e: EmbedEvent<"availabilityLoaded">) => {
|
||||
const data = e.detail.data;
|
||||
console.log("availabilityLoaded", {
|
||||
eventId: data.eventId,
|
||||
eventSlug: data.eventSlug,
|
||||
});
|
||||
|
||||
api("off", {
|
||||
action: "availabilityLoaded",
|
||||
callback: availabilityLoadedCallback,
|
||||
});
|
||||
};
|
||||
|
||||
api("on", {
|
||||
action: "availabilityLoaded",
|
||||
callback: availabilityLoadedCallback,
|
||||
});
|
||||
|
||||
// Also, validates the type of e.detail.data as TS runs on this file
|
||||
const bookingSuccessfulV2Callback = (e: EmbedEvent<"bookingSuccessfulV2">) => {
|
||||
const data = e.detail.data;
|
||||
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { IS_SELF_HOSTED } from "../constants";
|
||||
import hasKeyInMetadata from "../hasKeyInMetadata";
|
||||
|
||||
export function useHasPaidPlan() {
|
||||
if (IS_SELF_HOSTED) return { isPending: false, hasPaidPlan: true };
|
||||
|
||||
@@ -12,7 +12,9 @@ interface EventTitleProps {
|
||||
export const EventTitle = ({ children, as, className }: EventTitleProps) => {
|
||||
const El = as || "h1";
|
||||
return (
|
||||
<El data-testid="event-title" className={classNames("text-text text-xl font-semibold", className)}>
|
||||
<El
|
||||
data-testid="event-title"
|
||||
className={classNames("text-text break-words text-xl font-semibold", className)}>
|
||||
{children}
|
||||
</El>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { moduleLoader as checkBookingAndDurationLimitsModuleLoader } from "@calc
|
||||
import { moduleLoader as luckyUserServiceModuleLoader } from "@calcom/features/di/modules/LuckyUser";
|
||||
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
|
||||
import { DI_TOKENS } from "@calcom/features/di/tokens";
|
||||
import { moduleLoader as hashedLinkServiceModuleLoader } from "@calcom/features/hashedLink/di/HashedLinkService.module";
|
||||
import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module";
|
||||
|
||||
const thisModule = createModule();
|
||||
@@ -24,6 +25,7 @@ const loadModule = bindModuleToClassOnToken({
|
||||
bookingRepository: bookingRepositoryModuleLoader,
|
||||
luckyUserService: luckyUserServiceModuleLoader,
|
||||
userRepository: userRepositoryModuleLoader,
|
||||
hashedLinkService: hashedLinkServiceModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -54,3 +54,8 @@ export type InstantBookingCreateResult = {
|
||||
expires: Date;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
// More properties to be added to this config in followup PRs
|
||||
export type BookingFlowConfig = {
|
||||
isDryRun: boolean;
|
||||
};
|
||||
|
||||
@@ -28,11 +28,9 @@ vi.mock("@calcom/trpc/server/routers/viewer/auth/util", () => ({
|
||||
verifyCodeUnAuthenticated: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockFindManyByEmailsWithEmailVerificationSettings, mockFindByEmailWithEmailVerificationSetting } =
|
||||
vi.hoisted(() => ({
|
||||
mockFindManyByEmailsWithEmailVerificationSettings: vi.fn(),
|
||||
mockFindByEmailWithEmailVerificationSetting: vi.fn(),
|
||||
}));
|
||||
const { mockFindManyByEmailsWithEmailVerificationSettings } = vi.hoisted(() => ({
|
||||
mockFindManyByEmailsWithEmailVerificationSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
@@ -45,7 +43,6 @@ vi.mock("@calcom/features/users/repositories/UserRepository", async (importOrigi
|
||||
const realInstance = new OriginalUserRepository(prisma);
|
||||
realInstance.findManyByEmailsWithEmailVerificationSettings =
|
||||
mockFindManyByEmailsWithEmailVerificationSettings;
|
||||
realInstance.findByEmailWithEmailVerificationSetting = mockFindByEmailWithEmailVerificationSetting;
|
||||
return realInstance;
|
||||
}),
|
||||
};
|
||||
@@ -61,7 +58,6 @@ function resetBlacklistedEmails() {
|
||||
|
||||
beforeEach(() => {
|
||||
mockFindManyByEmailsWithEmailVerificationSettings.mockResolvedValue([]);
|
||||
mockFindByEmailWithEmailVerificationSetting.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Post-Booking handling tests
|
||||
*
|
||||
* These tests focus specifically on testing what happens after a successful booking or rescheduling.
|
||||
*/
|
||||
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import {
|
||||
createBookingScenario,
|
||||
getDate,
|
||||
getGoogleCalendarCredential,
|
||||
TestData,
|
||||
getOrganizer,
|
||||
getBooker,
|
||||
getScenarioData,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
BookingLocations,
|
||||
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, expect, beforeEach, vi, test } from "vitest";
|
||||
|
||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { getNewBookingHandler } from "./getNewBookingHandler";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
// Local test runs sometimes get too slow
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
// Helper function to create hashed link test data
|
||||
function createHashedLinkTestData({
|
||||
eventTypeId,
|
||||
usageLimit = 5,
|
||||
currentUsage = 0,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
usageLimit?: number;
|
||||
currentUsage?: number;
|
||||
}) {
|
||||
const link = "test-hashed-link-abc123";
|
||||
return {
|
||||
id: 1,
|
||||
link,
|
||||
eventTypeId,
|
||||
maxUsageCount: usageLimit,
|
||||
usageCount: currentUsage,
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to verify hashed link usage count
|
||||
async function expectHashedLinkUsageToBe(linkId: string, expectedUsageCount: number) {
|
||||
const hashedLink = await prismaMock.hashedLink.findUnique({
|
||||
where: { link: linkId },
|
||||
});
|
||||
expect(hashedLink?.usageCount).toBe(expectedUsageCount);
|
||||
}
|
||||
|
||||
describe("Post-Booking Events - Hashed Link Usage", () => {
|
||||
setupAndTeardown();
|
||||
beforeEach(() => {
|
||||
// Reset test state before each test
|
||||
resetTestEmails();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("BookingEventHandler Hashed Link Usage Tracking", () => {
|
||||
test(
|
||||
`should increment hashed link usage when booking is created with hashed link
|
||||
1. Should create a booking in the database
|
||||
2. Should increment hashed link usage count via BookingEventHandler.onBookingCreated
|
||||
`,
|
||||
async () => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const hashedLinkData = createHashedLinkTestData({
|
||||
eventTypeId: 1,
|
||||
usageLimit: 5,
|
||||
currentUsage: 2, // Should be incremented to 3 after booking
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [organizer],
|
||||
})
|
||||
);
|
||||
|
||||
// Create the hashed link in the database
|
||||
await prismaMock.hashedLink.create({
|
||||
data: hashedLinkData,
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
uid: "HASHED_LINK_BOOKING_UID",
|
||||
},
|
||||
update: {
|
||||
iCalUID: "HASHED_LINK_BOOKING_UID",
|
||||
uid: "HASHED_LINK_BOOKING_UID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingRequest = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
hashedLink: hashedLinkData.link,
|
||||
hasHashedBookingLink: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingResponse = await handleNewBooking({
|
||||
bookingData: mockBookingRequest,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: bookingResponse.uid,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
|
||||
await expectHashedLinkUsageToBe(hashedLinkData.link, hashedLinkData.usageCount + 1);
|
||||
|
||||
expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED);
|
||||
expect(bookingResponse.uid).toBeDefined();
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should increment hashed link usage when rescheduling a booking with hashed link
|
||||
1. Should reschedule the booking in the database
|
||||
2. Should increment hashed link usage count via BookingEventHandler.onBookingRescheduled
|
||||
`,
|
||||
async () => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const hashedLinkData = createHashedLinkTestData({
|
||||
eventTypeId: 1,
|
||||
usageLimit: 5,
|
||||
currentUsage: 2,
|
||||
});
|
||||
|
||||
// Create an existing booking to reschedule
|
||||
const existingBookingUid = "existing-booking-uid";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [organizer],
|
||||
bookings: [
|
||||
{
|
||||
uid: existingBookingUid,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
|
||||
endTime: `${getDate({ dateIncrement: 1 }).dateString}T05:45:00.000Z`,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Create the hashed link in the database
|
||||
await prismaMock.hashedLink.create({
|
||||
data: hashedLinkData,
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
uid: "RESCHEDULED_BOOKING_UID",
|
||||
},
|
||||
update: {
|
||||
iCalUID: "RESCHEDULED_BOOKING_UID",
|
||||
uid: "RESCHEDULED_BOOKING_UID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
hashedLink: hashedLinkData.link,
|
||||
hasHashedBookingLink: true,
|
||||
rescheduleUid: existingBookingUid,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingResponse = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
|
||||
if (bookingResponse.uid) {
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
status: BookingStatus.ACCEPTED,
|
||||
uid: bookingResponse.uid,
|
||||
});
|
||||
}
|
||||
|
||||
await expectHashedLinkUsageToBe(hashedLinkData.link, hashedLinkData.usageCount + 1);
|
||||
|
||||
expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED);
|
||||
expect(bookingResponse.uid).toBeDefined();
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should not increment hashed link usage during dry run
|
||||
1. Should not increment hashed link usage (BookingEventHandler should skip processing)
|
||||
`,
|
||||
async () => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const hashedLinkData = createHashedLinkTestData({
|
||||
eventTypeId: 1,
|
||||
usageLimit: 5,
|
||||
currentUsage: 2,
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
// hashedLink will be created separately in database
|
||||
},
|
||||
],
|
||||
users: [organizer],
|
||||
})
|
||||
);
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
uid: "DRY_RUN_BOOKING_UID",
|
||||
},
|
||||
});
|
||||
|
||||
// Create the hashed link in the database
|
||||
await prismaMock.hashedLink.create({
|
||||
data: hashedLinkData,
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
hashedLink: hashedLinkData.link,
|
||||
hasHashedBookingLink: true,
|
||||
_isDryRun: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingResponse = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
|
||||
expect(bookingResponse.uid).toBe("DRY_RUN_UID");
|
||||
// Verify hashed link usage was NOT incremented during dry run
|
||||
await expectHashedLinkUsageToBe(hashedLinkData.link, hashedLinkData.usageCount);
|
||||
|
||||
expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED);
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should handle hashed link service errors gracefully without failing booking creation
|
||||
1. Should create a booking successfully
|
||||
2. Should handle hashed link service errors gracefully in BookingEventHandler
|
||||
3. Should log errors but continue booking flow
|
||||
`,
|
||||
async () => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const hashedLinkData = createHashedLinkTestData({
|
||||
eventTypeId: 1,
|
||||
usageLimit: 5,
|
||||
currentUsage: 2,
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
// hashedLink will be created separately in database
|
||||
},
|
||||
],
|
||||
users: [organizer],
|
||||
})
|
||||
);
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
uid: "ERROR_HANDLING_BOOKING_UID",
|
||||
},
|
||||
update: {
|
||||
iCalUID: "ERROR_HANDLING_BOOKING_UID",
|
||||
uid: "ERROR_HANDLING_BOOKING_UID",
|
||||
},
|
||||
});
|
||||
|
||||
// Create an invalid hashed link that will cause validation to fail
|
||||
// (maxUsageCount = 0 means it should be expired)
|
||||
const invalidHashedLinkData = {
|
||||
...hashedLinkData,
|
||||
maxUsageCount: 0, // This will cause validation to fail
|
||||
};
|
||||
|
||||
await prismaMock.hashedLink.create({
|
||||
data: invalidHashedLinkData,
|
||||
});
|
||||
|
||||
const mockBookingRequest = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
hasHashedBookingLink: true,
|
||||
hashedLink: hashedLinkData.link,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingResponse = await handleNewBooking({
|
||||
bookingData: mockBookingRequest,
|
||||
});
|
||||
|
||||
// Booking should still be created successfully despite hashed link error
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: bookingResponse.uid,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
|
||||
// Verify that even though hashed link processing failed,
|
||||
await expectHashedLinkUsageToBe(hashedLinkData.link, invalidHashedLinkData.usageCount);
|
||||
|
||||
expect(bookingResponse.status).toEqual(BookingStatus.ACCEPTED);
|
||||
expect(bookingResponse.uid).toBeDefined();
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Logger } from "tslog";
|
||||
|
||||
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types";
|
||||
|
||||
interface BookingEventHandlerDeps {
|
||||
log: Logger<unknown>;
|
||||
hashedLinkService: HashedLinkService;
|
||||
}
|
||||
|
||||
export class BookingEventHandlerService {
|
||||
private readonly log: BookingEventHandlerDeps["log"];
|
||||
private readonly hashedLinkService: BookingEventHandlerDeps["hashedLinkService"];
|
||||
|
||||
constructor(private readonly deps: BookingEventHandlerDeps) {
|
||||
this.log = deps.log;
|
||||
this.hashedLinkService = deps.hashedLinkService;
|
||||
}
|
||||
|
||||
async onBookingCreated(payload: BookingCreatedPayload) {
|
||||
this.log.debug("onBookingCreated", safeStringify(payload));
|
||||
if (payload.config.isDryRun) {
|
||||
return;
|
||||
}
|
||||
await this.onBookingCreatedOrRescheduled(payload);
|
||||
}
|
||||
|
||||
async onBookingRescheduled(payload: BookingRescheduledPayload) {
|
||||
this.log.debug("onBookingRescheduled", safeStringify(payload));
|
||||
if (payload.config.isDryRun) {
|
||||
return;
|
||||
}
|
||||
await this.onBookingCreatedOrRescheduled(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles common tasks that need to be executed in both booking created and rescheduled events
|
||||
* A dedicated place because there are many tasks that need to be executed in both events.
|
||||
*/
|
||||
private async onBookingCreatedOrRescheduled(payload: BookingCreatedPayload | BookingRescheduledPayload) {
|
||||
const results = await Promise.allSettled([
|
||||
// TODO: Migrate other post-booking tasks here, to execute them in parallel, without affecting each other
|
||||
this.updatePrivateLinkUsage(payload.bookingFormData.hashedLink),
|
||||
]);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
this.log.error(
|
||||
"Error while executing onBookingCreatedOrRescheduled task",
|
||||
safeStringify(result.reason)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async updatePrivateLinkUsage(hashedLink: string | null) {
|
||||
try {
|
||||
if (hashedLink) {
|
||||
await this.deps.hashedLinkService.validateAndIncrementUsage(hashedLink);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error("Error while updating hashed link", safeStringify(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { BookingFlowConfig } from "./dto/types";
|
||||
|
||||
export interface BookingCreatedPayload {
|
||||
config: BookingFlowConfig;
|
||||
bookingFormData: {
|
||||
hashedLink: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Add more fields here when needed
|
||||
type BookingRescheduledPayload = BookingCreatedPayload;
|
||||
@@ -33,6 +33,7 @@ import type { CheckBookingAndDurationLimitsService } from "@calcom/features/book
|
||||
import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
|
||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
||||
import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
|
||||
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
|
||||
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
|
||||
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
|
||||
@@ -42,7 +43,7 @@ import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/W
|
||||
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
|
||||
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
|
||||
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
||||
import { handleAnalyticsEvents } from "@calcom/features/tasker/tasks/analytics/handleAnalyticsEvents";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
@@ -416,6 +417,7 @@ export interface IBookingServiceDependencies {
|
||||
bookingRepository: BookingRepository;
|
||||
luckyUserService: LuckyUserService;
|
||||
userRepository: UserRepository;
|
||||
hashedLinkService: HashedLinkService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2231,6 +2233,33 @@ async function handler(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const bookingFlowConfig = {
|
||||
isDryRun,
|
||||
};
|
||||
|
||||
const bookingCreatedPayload = {
|
||||
config: bookingFlowConfig,
|
||||
bookingFormData: {
|
||||
// FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely
|
||||
hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null,
|
||||
},
|
||||
};
|
||||
|
||||
// Add more fields here when needed
|
||||
const bookingRescheduledPayload = bookingCreatedPayload;
|
||||
|
||||
const bookingEventHandler = new BookingEventHandlerService({
|
||||
log: loggerWithEventDetails,
|
||||
hashedLinkService: deps.hashedLinkService,
|
||||
});
|
||||
|
||||
// TODO: Incrementally move all stuff that happens after a booking is created to these handlers
|
||||
if (originalRescheduledBooking) {
|
||||
await bookingEventHandler.onBookingRescheduled(bookingRescheduledPayload);
|
||||
} else {
|
||||
await bookingEventHandler.onBookingCreated(bookingCreatedPayload);
|
||||
}
|
||||
|
||||
const webhookData: EventPayloadType = {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
@@ -2468,23 +2497,6 @@ async function handler(
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hashedLinkService = new HashedLinkService();
|
||||
if (hasHashedBookingLink && reqBody.hashedLink && !isDryRun) {
|
||||
await hashedLinkService.validateAndIncrementUsage(reqBody.hashedLink as string);
|
||||
}
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error("Error while updating hashed link", JSON.stringify({ error }));
|
||||
|
||||
// Handle repository errors and convert to HttpErrors
|
||||
if (error instanceof Error) {
|
||||
throw new HttpError({ statusCode: 410, message: error.message });
|
||||
}
|
||||
|
||||
// For unexpected errors, provide a generic message
|
||||
throw new HttpError({ statusCode: 500, message: "Failed to process booking link" });
|
||||
}
|
||||
|
||||
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
|
||||
|
||||
try {
|
||||
|
||||
@@ -68,6 +68,7 @@ function BasePhoneInput({
|
||||
name,
|
||||
required: rest.required,
|
||||
placeholder: rest.placeholder,
|
||||
autoComplete: "tel",
|
||||
}}
|
||||
onChange={(val: string) => {
|
||||
onChange(`+${val}`);
|
||||
@@ -118,6 +119,7 @@ function BasePhoneInputWeb({
|
||||
name,
|
||||
required: rest.required,
|
||||
placeholder: rest.placeholder,
|
||||
autoComplete: "tel",
|
||||
}}
|
||||
onChange={(val: string) => {
|
||||
onChange(`+${val}`);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import debounce from "lodash/debounce";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
@@ -79,9 +76,9 @@ export type DataTableContextType = {
|
||||
export const DataTableContext = createContext<DataTableContextType | null>(null);
|
||||
|
||||
interface DataTableProviderProps {
|
||||
useSegments?: UseSegments;
|
||||
tableIdentifier?: string;
|
||||
tableIdentifier: string;
|
||||
children: React.ReactNode;
|
||||
useSegments?: UseSegments;
|
||||
ctaContainerClassName?: string;
|
||||
defaultPageSize?: number;
|
||||
segments?: FilterSegmentOutput[];
|
||||
@@ -91,7 +88,7 @@ interface DataTableProviderProps {
|
||||
}
|
||||
|
||||
export function DataTableProvider({
|
||||
tableIdentifier: _tableIdentifier,
|
||||
tableIdentifier,
|
||||
children,
|
||||
useSegments = useSegmentsNoop,
|
||||
defaultPageSize = DEFAULT_PAGE_SIZE,
|
||||
@@ -101,10 +98,8 @@ export function DataTableProvider({
|
||||
preferredSegmentId,
|
||||
systemSegments,
|
||||
}: DataTableProviderProps) {
|
||||
const pathname = usePathname() as string | null;
|
||||
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
||||
if (!tableIdentifier) {
|
||||
throw new Error("tableIdentifier is required");
|
||||
if (!tableIdentifier.trim()) {
|
||||
throw new Error("tableIdentifier is required and cannot be empty");
|
||||
}
|
||||
|
||||
const filterToOpen = useRef<string | undefined>(undefined);
|
||||
|
||||
@@ -145,8 +145,11 @@ function UserTable() {
|
||||
// ... other table options
|
||||
});
|
||||
|
||||
const pathname = usePathname();
|
||||
const tableIdentifier = "hard-coded idenfidier" // or pathname;
|
||||
|
||||
return (
|
||||
<DataTableProvider tableIdentifier="user-table">
|
||||
<DataTableProvider tableIdentifier={tableIdentifier}>
|
||||
<DataTableWrapper
|
||||
table={table}
|
||||
paginationMode="standard"
|
||||
@@ -179,7 +182,7 @@ The context provider that manages all table state including filters, sorting, pa
|
||||
|
||||
```tsx
|
||||
interface DataTableProviderProps {
|
||||
tableIdentifier?: string; // Unique identifier for the table
|
||||
tableIdentifier: string; // Unique identifier for the table (throws if empty)
|
||||
children: React.ReactNode;
|
||||
useSegments?: UseSegments; // Custom segment hook
|
||||
defaultPageSize?: number; // Default: 10
|
||||
@@ -530,7 +533,6 @@ Segments allow users to save and share filter configurations. There are two type
|
||||
|
||||
```tsx
|
||||
<DataTableProvider
|
||||
tableIdentifier="users"
|
||||
useSegments={useSegments} // Required to enable segments
|
||||
>
|
||||
{/* Your table content */}
|
||||
@@ -564,7 +566,6 @@ const systemSegments: SystemFilterSegment[] = [
|
||||
|
||||
<DataTableProvider
|
||||
systemSegments={systemSegments}
|
||||
tableIdentifier="user-table"
|
||||
>
|
||||
{/* ... */}
|
||||
</DataTableProvider>
|
||||
@@ -1210,7 +1211,7 @@ function UserTableContainer() {
|
||||
const { data, isPending } = useUsers(filters);
|
||||
|
||||
return (
|
||||
<DataTableProvider tableIdentifier="users">
|
||||
<DataTableProvider>
|
||||
<UserTable data={data} isPending={isPending} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DI_TOKENS } from "@calcom/features/di/tokens";
|
||||
import { redisModule } from "@calcom/features/redis/di/redisModule";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
import { membershipRepositoryModule } from "@calcom/features/users/di/MembershipRepository.module";
|
||||
import { createContainer } from "../di";
|
||||
import { availableSlotsModule } from "../modules/AvailableSlots";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
@@ -13,7 +13,6 @@ import { eventTypeRepositoryModule } from "../modules/EventType";
|
||||
import { featuresRepositoryModule } from "../modules/Features";
|
||||
import { filterHostsModule } from "../modules/FilterHosts";
|
||||
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
|
||||
import { membershipRepositoryModule } from "../modules/Membership";
|
||||
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
|
||||
import { oooRepositoryModule } from "../modules/Ooo";
|
||||
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DI_TOKENS } from "@calcom/features/di/tokens";
|
||||
import { redisModule } from "@calcom/features/redis/di/redisModule";
|
||||
import { membershipRepositoryModule } from "@calcom/features/users/di/MembershipRepository.module";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
import type { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
|
||||
|
||||
import { createContainer } from "../di";
|
||||
import { membershipRepositoryModule } from "../modules/Membership";
|
||||
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
|
||||
import { teamRepositoryModule } from "../modules/Team";
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { DI_TOKENS } from "@calcom/features/di/tokens";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
|
||||
import { createModule } from "../di";
|
||||
|
||||
export const membershipRepositoryModule = createModule();
|
||||
membershipRepositoryModule
|
||||
.bind(DI_TOKENS.MEMBERSHIP_REPOSITORY)
|
||||
.toClass(MembershipRepository, [DI_TOKENS.PRISMA_CLIENT]);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user