diff --git a/apps/api/v2/src/lib/services/organization-membership.service.ts b/apps/api/v2/src/lib/services/organization-membership.service.ts new file mode 100644 index 00000000000000..59be6027c12e9d --- /dev/null +++ b/apps/api/v2/src/lib/services/organization-membership.service.ts @@ -0,0 +1,12 @@ +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { Injectable } from "@nestjs/common"; + +import { OrganizationMembershipService as BaseOrganizationMembershipService } from "@calcom/platform-libraries/organizations"; + +@Injectable() +export class OrganizationMembershipService extends BaseOrganizationMembershipService { + constructor(organizationsRepository: OrganizationsRepository) { + super({ organizationRepository: organizationsRepository }); + } +} + diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts index 0f7e2ddb83fe98..410ce2902f8192 100644 --- a/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts @@ -54,7 +54,7 @@ export class IsAdminAPIEnabledGuard implements CanActivate { } } - const org = await this.organizationsRepository.findById(Number(organizationId)); + const org = await this.organizationsRepository.findById({ id: Number(organizationId) }); if (org?.isOrganization && !org?.isPlatform) { const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus( diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts index 31d287bfdf7a75..df144a5d881d0b 100644 --- a/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts @@ -55,7 +55,7 @@ export class IsOrgGuard implements CanActivate { } } - const org = await this.organizationsRepository.findById(Number(organizationId)); + const org = await this.organizationsRepository.findById({ id: Number(organizationId) }); if (org?.isOrganization) { canAccess = true; diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts index 1dc6e1dc958ede..7fe3e03e544d80 100644 --- a/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts @@ -43,7 +43,7 @@ export class IsWebhookInOrg implements CanActivate { } } - const org = await this.organizationsRepository.findById(Number(organizationId)); + const org = await this.organizationsRepository.findById({ id: Number(organizationId) }); if (org?.isOrganization) { const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook( diff --git a/apps/api/v2/src/modules/organizations/index/organizations.repository.ts b/apps/api/v2/src/modules/organizations/index/organizations.repository.ts index 00035ca40f5af3..ba9b0eecde294a 100644 --- a/apps/api/v2/src/modules/organizations/index/organizations.repository.ts +++ b/apps/api/v2/src/modules/organizations/index/organizations.repository.ts @@ -4,23 +4,17 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { StripeService } from "@/modules/stripe/stripe.service"; import { Injectable } from "@nestjs/common"; +import { OrganizationRepository } from "@calcom/platform-libraries/organizations"; import { Prisma } from "@calcom/prisma/client"; @Injectable() -export class OrganizationsRepository { +export class OrganizationsRepository extends OrganizationRepository { constructor( private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService, private readonly stripeService: StripeService - ) {} - - async findById(organizationId: number) { - return this.dbRead.prisma.team.findUnique({ - where: { - id: organizationId, - isOrganization: true, - }, - }); + ) { + super({ prismaClient: dbWrite.prisma }); } async findByIds(organizationIds: number[]) { diff --git a/apps/api/v2/src/modules/organizations/index/organizations.service.ts b/apps/api/v2/src/modules/organizations/index/organizations.service.ts index 07c2205a0d26ef..b6e63c49271cbb 100644 --- a/apps/api/v2/src/modules/organizations/index/organizations.service.ts +++ b/apps/api/v2/src/modules/organizations/index/organizations.service.ts @@ -6,7 +6,7 @@ export class OrganizationsService { constructor(private readonly organizationsRepository: OrganizationsRepository) {} async isPlatform(organizationId: number) { - const organization = await this.organizationsRepository.findById(organizationId); + const organization = await this.organizationsRepository.findById({ id: organizationId }); return organization?.isPlatform; } } diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 58b5347796ee45..c0464345538d3a 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -13,6 +13,7 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { EmailModule } from "@/modules/email/email.module"; import { EmailService } from "@/modules/email/email.service"; +import { OrganizationMembershipService } from "@/lib/services/organization-membership.service"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; @@ -108,6 +109,7 @@ import { Module } from "@nestjs/common"; ], providers: [ OrganizationsRepository, + OrganizationMembershipService, OrganizationsTeamsRepository, OrganizationsService, OrganizationsTeamsService, @@ -200,4 +202,4 @@ import { Module } from "@nestjs/common"; OrganizationsEventTypesPrivateLinksController, ], }) -export class OrganizationsModule {} +export class OrganizationsModule { } diff --git a/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts b/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts index 303563d1ca8e08..f423fb35acb1ba 100644 --- a/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts +++ b/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts @@ -103,12 +103,12 @@ export class ManagedOrganizationsService { } private async isManagerOrganizationPlatform(managerOrganizationId: number) { - const organization = await this.organizationsRepository.findById(managerOrganizationId); + const organization = await this.organizationsRepository.findById({ id: managerOrganizationId }); return !!organization?.isPlatform; } async getManagedOrganization(managedOrganizationId: number) { - const organization = await this.organizationsRepository.findById(managedOrganizationId); + const organization = await this.organizationsRepository.findById({ id: managedOrganizationId }); if (!organization) { throw new NotFoundException(`Managed organization with id=${managedOrganizationId} does not exist.`); } diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships.controller.e2e-spec.ts index c2616685bfd635..315e8a5f08d2fa 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships.controller.e2e-spec.ts @@ -418,6 +418,209 @@ describe("Organizations Teams Memberships Endpoints", () => { .expect(404); }); + // Auto-accept tests + describe("auto-accept based on email domain", () => { + let orgWithAutoAccept: Team; + let subteamWithAutoAccept: Team; + let userWithMatchingEmail: User; + let userWithUppercaseEmail: User; + let userWithMatchingEmailForOverride: User; + let userWithNonMatchingEmail: User; + + beforeAll(async () => { + // Create org with auto-accept settings + orgWithAutoAccept = await organizationsRepositoryFixture.create({ + name: `auto-accept-org-${randomString()}`, + isOrganization: true, + }); + + // Update organizationSettings with orgAutoAcceptEmail + await organizationsRepositoryFixture.updateSettings(orgWithAutoAccept.id, { + orgAutoAcceptEmail: "acme.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }); + + // Create subteam + subteamWithAutoAccept = await teamsRepositoryFixture.create({ + name: `auto-accept-subteam-${randomString()}`, + isOrganization: false, + parent: { connect: { id: orgWithAutoAccept.id } }, + }); + + // Create event type with assignAllTeamMembers + await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { connect: { id: subteamWithAutoAccept.id } }, + title: "Auto Accept Event Type", + slug: "auto-accept-event-type", + length: 30, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + // Create users + userWithMatchingEmail = await userRepositoryFixture.create({ + email: `alice@acme.com`, + username: `alice-${randomString()}`, + }); + + userWithUppercaseEmail = await userRepositoryFixture.create({ + email: `bob@ACME.COM`, + username: `bob-${randomString()}`, + }); + + userWithMatchingEmailForOverride = await userRepositoryFixture.create({ + email: `david@acme.com`, + username: `david-${randomString()}`, + }); + + userWithNonMatchingEmail = await userRepositoryFixture.create({ + email: `charlie@external.com`, + username: `charlie-${randomString()}`, + }); + + // Add users to org + await membershipsRepositoryFixture.create({ + role: "MEMBER", + accepted: true, + user: { connect: { id: userWithMatchingEmail.id } }, + team: { connect: { id: orgWithAutoAccept.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + accepted: true, + user: { connect: { id: userWithUppercaseEmail.id } }, + team: { connect: { id: orgWithAutoAccept.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + accepted: true, + user: { connect: { id: userWithMatchingEmailForOverride.id } }, + team: { connect: { id: orgWithAutoAccept.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + accepted: true, + user: { connect: { id: userWithNonMatchingEmail.id } }, + team: { connect: { id: orgWithAutoAccept.id } }, + }); + + // Create profiles for users + await profileRepositoryFixture.create({ + uid: `usr-${userWithMatchingEmail.id}`, + username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`, + organization: { connect: { id: orgWithAutoAccept.id } }, + user: { connect: { id: userWithMatchingEmail.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userWithUppercaseEmail.id}`, + username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`, + organization: { connect: { id: orgWithAutoAccept.id } }, + user: { connect: { id: userWithUppercaseEmail.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userWithMatchingEmailForOverride.id}`, + username: userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`, + organization: { connect: { id: orgWithAutoAccept.id } }, + user: { connect: { id: userWithMatchingEmailForOverride.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userWithNonMatchingEmail.id}`, + username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`, + organization: { connect: { id: orgWithAutoAccept.id } }, + user: { connect: { id: userWithNonMatchingEmail.id } }, + }); + + // Make user an admin of the org for API access + await membershipsRepositoryFixture.create({ + role: "ADMIN", + accepted: true, + user: { connect: { id: user.id } }, + team: { connect: { id: orgWithAutoAccept.id } }, + }); + }); + + it("should auto-accept when email matches orgAutoAcceptEmail", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`) + .send({ + userId: userWithMatchingEmail.id, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.data.accepted).toBe(true); + + // Verify EventTypes assignment + const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes( + subteamWithAutoAccept.id + ); + const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers); + expect(eventTypeWithAssignAll).toBeTruthy(); + const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id); + expect(userIsHost).toBe(true); + }); + + it("should handle case-insensitive email domain matching", async () => { + // User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com" + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`) + .send({ + userId: userWithUppercaseEmail.id, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.data.accepted).toBe(true); + }); + + it("should ALWAYS auto-accept when email matches, even if accepted:false", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`) + .send({ + userId: userWithMatchingEmailForOverride.id, + role: "MEMBER", + accepted: false, + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + // Should override to true because email matches + expect(responseBody.data.accepted).toBe(true); + }); + + it("should NOT auto-accept when email does not match orgAutoAcceptEmail", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`) + .send({ + userId: userWithNonMatchingEmail.id, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.data.accepted).toBe(false); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email); + await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email); + await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email); + await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email); + await organizationsRepositoryFixture.delete(orgWithAutoAccept.id); + }); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email); diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts index 8b9a77366d8e5c..72747f4a90c86e 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts @@ -12,6 +12,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { OrganizationMembershipService } from "@/lib/services/organization-membership.service"; import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input"; @@ -58,8 +59,9 @@ export class OrganizationsTeamsMembershipsController { constructor( private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService, - private readonly organizationsRepository: OrganizationsRepository - ) {} + private readonly organizationsRepository: OrganizationsRepository, + private readonly orgMembershipService: OrganizationMembershipService + ) { } @Get("/") @ApiOperation({ summary: "Get all memberships" }) @@ -168,6 +170,9 @@ export class OrganizationsTeamsMembershipsController { }; } + + // TODO: Refactor to use inviteMembersWithNoInviterPermissionCheck when it is moved to a Service + // See: packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") @Post("/") @@ -184,7 +189,21 @@ export class OrganizationsTeamsMembershipsController { throw new UnprocessableEntityException("User is not part of the Organization"); } - const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data); + const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({ + organizationId: orgId, + userEmail: user.email, + }); + + // ALWAYS override when email matches - prevents pending memberships + // Remember organizations expect added team member to automatically start receiving bookings for the team event + const acceptedStatus = shouldAutoAccept ? true : (data.accepted ?? false); + + const membershipData = { ...data, accepted: acceptedStatus }; + const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership( + teamId, + membershipData + ); + if (membership.accepted) { try { await updateNewTeamMemberEventTypes(user.id, teamId); diff --git a/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts index 83b674979982c3..1cac91ceb9b137 100644 --- a/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts @@ -37,6 +37,20 @@ export class OrganizationRepositoryFixture { }); } + async updateSettings( + teamId: Team["id"], + settings: { + orgAutoAcceptEmail?: string; + isOrganizationVerified?: boolean; + isOrganizationConfigured?: boolean; + } + ) { + return this.prismaWriteClient.organizationSettings.update({ + where: { organizationId: teamId }, + data: settings, + }); + } + async delete(teamId: Team["id"]) { return await this.prismaWriteClient.$transaction(async (prisma) => { await prisma.organizationSettings.delete({ diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json index 03473dcc053136..de9060cfa28338 100644 --- a/apps/api/v2/tsconfig.json +++ b/apps/api/v2/tsconfig.json @@ -29,7 +29,8 @@ "@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"], "@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"], "@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"], - "@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"] + "@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"], + "@calcom/platform-libraries/organizations": ["../../../packages/platform/libraries/organizations.ts"] }, "incremental": true, "skipLibCheck": true, diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx index e9ae0e7de08f17..21a6665ca53683 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx @@ -6,7 +6,7 @@ import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable"; import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData"; @@ -61,8 +61,9 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => { }; const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id; + const organizationRepository = getOrganizationRepository(); const isOrgPrivate = organizationId - ? await OrganizationRepository.checkIfPrivate({ + ? await organizationRepository.checkIfPrivate({ orgId: organizationId, }) : false; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx index 29ec2170dc6082..e96495d6a80ef3 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx @@ -26,11 +26,9 @@ const ServerPage = async () => { 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 ; + return ; }; export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx index dc709084234e9a..da56b018205998 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx @@ -7,7 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; -import { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view"; +import { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view"; export const generateMetadata = async () => { return await _generateMetadata( @@ -28,7 +28,7 @@ const ServerPage = async () => { const userEmail = session.user.email || ""; - return ; + return ; }; export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/personal/video/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/personal/video/page.tsx deleted file mode 100644 index 1ec119859f5589..00000000000000 --- a/apps/web/app/(use-page-wrapper)/onboarding/personal/video/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 ; -}; - -export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx deleted file mode 100644 index 72f40aaa215189..00000000000000 --- a/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 ; -}; - -export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx index 5e35f5db785a2b..e4f8941d3564e6 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/organizations/[id]/edit/page.tsx @@ -4,12 +4,13 @@ import { z } from "zod"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage"; -import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; const orgIdSchema = z.object({ id: z.coerce.number() }); export const generateMetadata = async ({ params }: { params: Params }) => { + const organizationRepository = getOrganizationRepository(); const input = orgIdSchema.safeParse(await params); if (!input.success) { return await _generateMetadata( @@ -21,7 +22,7 @@ export const generateMetadata = async ({ params }: { params: Params }) => { ); } - const org = await OrganizationRepository.adminFindById({ id: input.data.id }); + const org = await organizationRepository.adminFindById({ id: input.data.id }); return await _generateMetadata( (t) => `${t("editing_org")}: ${org.name}`, @@ -33,11 +34,12 @@ export const generateMetadata = async ({ params }: { params: Params }) => { }; const Page = async ({ params }: { params: Params }) => { + const organizationRepository = getOrganizationRepository(); const input = orgIdSchema.safeParse(await params); if (!input.success) throw new Error("Invalid access"); - const org = await OrganizationRepository.adminFindById({ id: input.data.id }); + const org = await organizationRepository.adminFindById({ id: input.data.id }); const t = await getTranslate(); return ( diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx index 028a767e7e20b3..1359594aae9428 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx @@ -2,7 +2,7 @@ import { _generateMetadata, getTranslate } from "app/_utils"; import { redirect } from "next/navigation"; import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing"; -import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { validateUserHasOrg } from "../../../actions/validateUserHasOrg"; @@ -24,11 +24,12 @@ const Page = async () => { redirect("/auth/login"); } const organizationId = session?.user?.org?.id; + const organizationRepository = getOrganizationRepository(); const otherTeams = organizationId - ? await OrganizationRepository.findTeamsInOrgIamNotPartOf({ - userId: session?.user.id, - parentId: organizationId, - }) + ? await organizationRepository.findTeamsInOrgIamNotPartOf({ + userId: session?.user.id, + parentId: organizationId, + }) : []; return ( diff --git a/apps/web/lib/pages/auth/verify-email.test.ts b/apps/web/lib/pages/auth/verify-email.test.ts index d11e370cdd4f4a..ad568abde0765c 100644 --- a/apps/web/lib/pages/auth/verify-email.test.ts +++ b/apps/web/lib/pages/auth/verify-email.test.ts @@ -10,7 +10,6 @@ import { moveUserToMatchingOrg } from "./verify-email"; // TODO: This test passes but coverage is very low. vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"); -vi.mock("@calcom/features/ee/organizations/repositories/OrganizationRepository"); vi.mock("@calcom/prisma", () => { return { prisma: vi.fn(), @@ -37,7 +36,7 @@ describe("moveUserToMatchingOrg", () => { }); it("should not proceed if no matching organization is found", async () => { - organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeNoMatch(); + organizationScenarios.organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeNoMatch(); await moveUserToMatchingOrg({ email }); @@ -64,7 +63,7 @@ describe("moveUserToMatchingOrg", () => { requestedSlug: "requested-test-org", }; - organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization( + organizationScenarios.organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization( org, { email } ); @@ -85,7 +84,7 @@ describe("moveUserToMatchingOrg", () => { requestedSlug: "requested-test-org", }; - organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization( + organizationScenarios.organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization( org, { email } ); diff --git a/apps/web/lib/pages/auth/verify-email.ts b/apps/web/lib/pages/auth/verify-email.ts index 720f1b09b99f72..0b11c692f038d1 100644 --- a/apps/web/lib/pages/auth/verify-email.ts +++ b/apps/web/lib/pages/auth/verify-email.ts @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -21,7 +21,8 @@ const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email"; // TODO: To be unit tested export async function moveUserToMatchingOrg({ email }: { email: string }) { - const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email }); + const organizationRepository = getOrganizationRepository(); + const org = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email }); if (!org) { return; diff --git a/apps/web/lib/video/[uid]/getServerSideProps.ts b/apps/web/lib/video/[uid]/getServerSideProps.ts index 5d28cc47cd7d70..a278f3f4c479a4 100644 --- a/apps/web/lib/video/[uid]/getServerSideProps.ts +++ b/apps/web/lib/video/[uid]/getServerSideProps.ts @@ -8,7 +8,7 @@ import { } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; -import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import { getCalVideoReference } from "@calcom/features/get-cal-video-reference"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; @@ -167,8 +167,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { ).profile : null; + const organizationRepository = getOrganizationRepository(); + const calVideoLogo = profile?.organization - ? await OrganizationRepository.findCalVideoLogoByOrgId({ id: profile.organization.id }) + ? await organizationRepository.findCalVideoLogoByOrgId({ id: profile.organization.id }) : null; //daily.co calls have a 14 days exit buffer when a user enters a call when it's not available it will trigger the modals diff --git a/apps/web/modules/onboarding/components/OnboardingCard.tsx b/apps/web/modules/onboarding/components/OnboardingCard.tsx new file mode 100644 index 00000000000000..72ca7500db1bb6 --- /dev/null +++ b/apps/web/modules/onboarding/components/OnboardingCard.tsx @@ -0,0 +1,41 @@ +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 ( +
+ {/* Card Header */} +
+
+

{title}

+

{subtitle}

+
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ + +
+ ) : ( + children + )} +
+ + {/* Footer */} +
{footer}
+
+ ); +}; + diff --git a/apps/web/modules/onboarding/components/OnboardingLayout.tsx b/apps/web/modules/onboarding/components/OnboardingLayout.tsx new file mode 100644 index 00000000000000..1a538362f0c1a6 --- /dev/null +++ b/apps/web/modules/onboarding/components/OnboardingLayout.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { Children, type ReactNode } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { Logo } from "@calcom/ui/components/logo"; + +type OnboardingLayoutProps = { + userEmail: string; + currentStep: 1 | 2 | 3; + children: ReactNode; +}; + +export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => { + const { t } = useLocale(); + + const handleSignOut = () => { + signOut({ callbackUrl: "/auth/logout" }); + }; + + // Extract children as array + const childrenArray = Children.toArray(children); + const column1 = childrenArray[0]; + const column2 = childrenArray[1]; + + return ( +
+ {/* Logo and container - centered */} +
+ +
+ {/* Column 1 - Always visible, 40% on xl+ */} +
{column1}
+ {/* Column 2 - Hidden on mobile, visible on xl+, 60% on xl+ */} + {column2 && ( +
{column2}
+ )} +
+
+ + {/* Footer with progress dots and sign out */} +
+
+ {[1, 2, 3].map((step) => ( +
+
+ {step <= currentStep &&
} +
+ ))} +
+ +
+
+ ); +}; + diff --git a/apps/web/modules/onboarding/components/onboarding-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-browser-view.tsx new file mode 100644 index 00000000000000..37362668712702 --- /dev/null +++ b/apps/web/modules/onboarding/components/onboarding-browser-view.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Button } from "@calcom/ui/components/button"; +import { Icon, type IconName } from "@calcom/ui/components/icon"; + +type OnboardingBrowserViewProps = { + avatar?: string | null; + name?: string; + bio?: string; + username?: string | null; + teamSlug?: string; +}; + +export const OnboardingBrowserView = ({ + avatar, + name, + bio, + username, + teamSlug, +}: OnboardingBrowserViewProps) => { + const { t } = useLocale(); + const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, ""); + const displayUrl = + teamSlug !== undefined ? `${webappUrl}/team/${teamSlug || ""}` : `${webappUrl}/${username || ""}`; + + const events: Array<{ + title: string; + description: string; + duration: number; + icon: IconName; + }> = [ + { + title: t("onboarding_browser_view_demo"), + description: t("onboarding_browser_view_demo_description"), + duration: 15, + icon: "bell", + }, + { + title: t("onboarding_browser_view_quick_meeting"), + description: t("onboarding_browser_view_quick_meeting_description"), + duration: 15, + icon: "bell", + }, + { + title: t("onboarding_browser_view_longer_meeting"), + description: t("onboarding_browser_view_longer_meeting_description"), + duration: 30, + icon: "clock", + }, + { + title: t("in_person_meeting"), + description: t("onboarding_browser_view_in_person_description"), + duration: 120, + icon: "map-pin", + }, + { + title: t("onboarding_browser_view_ask_question"), + description: t("onboarding_browser_view_ask_question_description"), + duration: 15, + icon: "message-circle", + }, + ]; + return ( +
+ {/* Browser header */} +
+ {/* Navigation buttons */} +
+ + + +
+
+ +

{displayUrl}

+
+ +
+ {/* Content */} +
+
+ {/* Profile Header */} +
+
+ +
+

+ {name || t("your_name")} +

+

+ {bio || t("onboarding_browser_view_default_bio")} +

+
+
+
+ + {/* Events List */} +
+ {events.map((event, index) => ( +
+ {index > 0 &&
} +
+
+
+

{event.title}

+
+ + + {event.duration} {t("minute_timeUnit")} + +
+
+

{event.description}

+
+ +
+
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx new file mode 100644 index 00000000000000..f45e562c50f5a5 --- /dev/null +++ b/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Icon, type IconName } from "@calcom/ui/components/icon"; + +export const OnboardingCalendarBrowserView = () => { + const { t } = useLocale(); + const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, ""); + + const calendarIntegrations: Array<{ + name: string; + description: string; + icon: IconName; + }> = [ + { + name: t("google_calendar"), + description: t("onboarding_calendar_browser_view_google_description"), + icon: "calendar", + }, + { + name: t("outlook_calendar"), + description: t("onboarding_calendar_browser_view_outlook_description"), + icon: "mail", + }, + { + name: t("apple_calendar"), + description: t("onboarding_calendar_browser_view_apple_description"), + icon: "calendar-days", + }, + ]; + + return ( +
+ {/* Browser header */} +
+ {/* Navigation buttons */} +
+ + + +
+
+ +

{webappUrl}/settings/calendars

+
+ +
+ {/* Content */} +
+
+ {/* Header */} +
+
+

+ {t("connect_your_calendar")} +

+

+ {t("onboarding_calendar_browser_view_subtitle")} +

+
+
+ + {/* Calendar Integrations List */} +
+ {calendarIntegrations.map((integration, index) => ( +
+ {index > 0 &&
} +
+
+
+ +
+
+

{integration.name}

+

+ {integration.description} +

+
+
+
+ {t("connected")} +
+
+
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx new file mode 100644 index 00000000000000..e9ce7747bcf046 --- /dev/null +++ b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { trpc } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; + +import { useOnboardingStore } from "../store/onboarding-store"; + +type OnboardingInviteBrowserViewProps = { + teamName?: string; +}; + +export const OnboardingInviteBrowserView = ({ teamName }: OnboardingInviteBrowserViewProps) => { + const { data: user } = trpc.viewer.me.get.useQuery(); + const { teamBrand } = useOnboardingStore(); + + // Use default values if not provided + const rawInviterName = user?.name || user?.username || "Alex"; + const displayInviterName = rawInviterName.charAt(0).toUpperCase() + rawInviterName.slice(1); + const displayTeamName = teamName || "Deel"; + const teamAvatar = teamBrand.logo || null; + + return ( +
+ {/* Browser header */} +
+ {/* Navigation buttons */} +
+
+
+
+
+
+
+

mail.example.com

+
+
+ + {/* Content */} +
+
+ {/* Email Header */} +
+
+ +
+

+ {displayInviterName} invited you to join {displayTeamName} +

+

+ We're emailing you all the details +

+
+
+
+ + {/* Email Body */} +
+
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/components/plan-icon.tsx b/apps/web/modules/onboarding/components/plan-icon.tsx index bd047409794dfc..52e9cf8b99c912 100644 --- a/apps/web/modules/onboarding/components/plan-icon.tsx +++ b/apps/web/modules/onboarding/components/plan-icon.tsx @@ -1,24 +1,68 @@ -import classNames from "@calcom/ui/classNames"; +import { motion } from "framer-motion"; + 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%"; +// Ring sizes - just the diameters, all centered on the icon +const RING_SIZES = [166, 233, 345, 465]; + +// Positions for small user icons on rings (for team variant) +// Format: [ringIndex, angleInDegrees] +// Angles: 0 = top, 90 = right, 180 = bottom, 270 = left +const TEAM_ICON_POSITIONS = [ + { ringIndex: 2, angle: 30 }, // Top-right of third ring + { ringIndex: 2, angle: 150 }, // Bottom-left of third ring + { ringIndex: 2, angle: 210 }, // Bottom-left of third ring + { ringIndex: 2, angle: 330 }, // Top-left of third ring + { ringIndex: 3, angle: 0 }, // Top of largest ring + { ringIndex: 3, angle: 180 }, // Bottom of largest ring +]; +export function PlanIcon({ + icon, + variant = "single", + animationDirection = "down", +}: { + icon: IconName; + variant?: "single" | "organization" | "team"; + animationDirection?: "up" | "down"; +}) { + const renderIconContainer = (index: number) => { return ( -
{/* Icon */} -
- +
+
{/* Inner highlight/shine effect */} @@ -29,79 +73,88 @@ export function PlanIcon({ icon, variant = "single" }: { icon: IconName; variant opacity: 0.15, }} /> -
+ ); }; - return ( -
- {/* Outer ring - SVG with linear gradient */} - - - - - - - - - + const renderSmallUserIcon = (ringIndex: number, angle: number, index: number) => { + const ringSize = RING_SIZES[ringIndex]; + const radius = ringSize / 2; + const angleRad = (angle * Math.PI) / 180; + + // Calculate position on the ring + const x = Math.cos(angleRad) * radius; + const y = Math.sin(angleRad) * radius; + const iconHalfSize = 20.25; // Half of 40.5px - {/* Middle ring - SVG with linear gradient */} - - + +
+
- - - - - - - + + ); + }; + + return ( +
+ {/* Generate concentric rings centered on icon */} + {RING_SIZES.map((size, index) => { + const opacity = [0.6, 0.5, 0.4, 0.35][index] || 0.3; + return ( +
+ ); + })} + + {/* Small user icons on rings (for team variant) */} + {(variant === "team" || variant === "organization") && + TEAM_ICON_POSITIONS.map(({ ringIndex, angle }, index) => + renderSmallUserIcon(ringIndex, angle, index) + )} - {/* Main icon container(s) with gradient background */} - {variant === "single" ? renderIconContainer(0) : [renderIconContainer(0), renderIconContainer(1)]} + {renderIconContainer(0)}
); } diff --git a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx index 4a908efa8e243d..b41cbf0168ef90 100644 --- a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx +++ b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx @@ -1,6 +1,8 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; import { useRouter } from "next/navigation"; +import { useEffect, useRef } from "react"; import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -10,20 +12,43 @@ import { Button } from "@calcom/ui/components/button"; import { type IconName } from "@calcom/ui/components/icon"; import { RadioAreaGroup } from "@calcom/ui/components/radio"; +import { OnboardingCard } from "../components/OnboardingCard"; +import { OnboardingLayout } from "../components/OnboardingLayout"; import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt"; import { PlanIcon } from "../components/plan-icon"; -import { OnboardingLayout } from "../personal/_components/OnboardingLayout"; import { useOnboardingStore, type PlanType } from "../store/onboarding-store"; type OnboardingViewProps = { - userName: string; userEmail: string; }; -export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => { +export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { const router = useRouter(); const { t } = useLocale(); const { selectedPlan, setSelectedPlan } = useOnboardingStore(); + const previousPlanRef = useRef(null); + + // Plan order mapping for determining direction + const planOrder: Record = { + personal: 0, + team: 1, + organization: 2, + }; + + // Calculate animation direction synchronously + const getDirection = (): "up" | "down" => { + if (!selectedPlan || !previousPlanRef.current) return "down"; + const previousOrder = planOrder[previousPlanRef.current]; + const currentOrder = planOrder[selectedPlan]; + return currentOrder > previousOrder ? "down" : "up"; + }; + + const direction = getDirection(); + + // Update previous plan ref after render + useEffect(() => { + previousPlanRef.current = selectedPlan; + }, [selectedPlan]); const handleContinue = () => { if (selectedPlan === "organization") { @@ -37,7 +62,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => const planIconByType: Record = { personal: "user", - team: "users", + team: "user", organization: "users", }; @@ -56,7 +81,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => badge: t("onboarding_plan_team_badge"), description: t("onboarding_plan_team_description"), icon: planIconByType.team, - variant: "single" as const, + variant: "team" as const, }, { id: "organization" as PlanType, @@ -64,7 +89,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => badge: t("onboarding_plan_organization_badge"), description: t("onboarding_plan_organization_description"), icon: planIconByType.organization, - variant: "double" as const, + variant: "organization" as const, }, ]; @@ -76,26 +101,26 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => return true; }); + const selectedPlanData = plans.find((plan) => plan.id === selectedPlan); + return ( <> -
+ {/* Left column - Main content */} + + +
+ }> {/* Card */} -
+
- {/* Card Header */} -
-
-

- {t("onboarding_welcome_message", { userName })} -

-

- {t("onboarding_welcome_question")} -

-
-
- {/* Plan options */} "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", + container: "flex w-full items-center gap-3 p-5 pr-12", }}> -
- -
-
-

{plan.title}

- - - {plan.badge} - - -
+
+
+

{plan.title}

+ className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center"> {plan.badge} -

- {plan.description} -

+ + {plan.badge} + +

+ {plan.description} +

); })} - - {/* Footer */} -
- -
+ + + {/* Right column - Icon display */} +
+ + {selectedPlanData && ( + + )} +
diff --git a/apps/web/modules/onboarding/hooks/useSubmitPersonalOnboarding.ts b/apps/web/modules/onboarding/hooks/useSubmitPersonalOnboarding.ts new file mode 100644 index 00000000000000..15c3b10c73eb42 --- /dev/null +++ b/apps/web/modules/onboarding/hooks/useSubmitPersonalOnboarding.ts @@ -0,0 +1,80 @@ +import { useRouter } from "next/navigation"; + +import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/useWelcomeToCalcomModal"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useTelemetry } from "@calcom/lib/hooks/useTelemetry"; +import { telemetryEventTypes } from "@calcom/lib/telemetry"; +import { trpc } from "@calcom/trpc/react"; +import { showToast } from "@calcom/ui/components/toast"; + +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 useSubmitPersonalOnboarding = () => { + const router = useRouter(); + const { t } = useLocale(); + const telemetry = useTelemetry(); + const utils = trpc.useUtils(); + + 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(); + // Set flag to show welcome modal after redirect + setShowWelcomeToCalcomModalFlag(); + router.push("/event-types?welcomeToCalcomModal=true"); + }, + onError: (error) => { + showToast(t("something_went_wrong"), "error"); + console.error(error); + }, + }); + + const submitPersonalOnboarding = () => { + telemetry.event(telemetryEventTypes.onboardingFinished); + mutation.mutate({ + completedOnboarding: true, + }); + }; + + return { + submitPersonalOnboarding, + isSubmitting: mutation.isPending, + }; +}; diff --git a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx index 6868e24e86d293..66df2dbc2ea71e 100644 --- a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx +++ b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx @@ -8,8 +8,9 @@ import { HexColorPicker } from "react-colorful"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; -import { OnboardingCard } from "../../personal/_components/OnboardingCard"; -import { OnboardingLayout } from "../../personal/_components/OnboardingLayout"; +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; import { useOnboardingStore } from "../../store/onboarding-store"; type OrganizationBrandViewProps = { @@ -118,6 +119,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) return ( + {/* Left column - Main content */}
-

{t("onboarding_logo_size_hint")}

+

+ {t("onboarding_logo_size_hint")} +

{/* Banner Upload */} @@ -225,69 +229,14 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)

- - {/* Right side - Preview */} -
-
-

{t("preview")}

-
- {/* Banner preview */} -
- {bannerPreview && ( - {t("onboarding_banner_preview_alt")} - )} -
- - {/* Content */} -
-
-
- {/* Logo preview */} -
- {logoPreview && ( - {t("onboarding_logo_preview_alt")} - )} -
-

- {organizationDetails.name || t("onboarding_preview_nameless")} -

-
-
-
-

- {t("onboarding_preview_example_title")} -

-

- {t("onboarding_preview_example_description")} -

-
-
-
-
- {[134, 104, 84, 104].map((width, i) => ( -
-
-
-
- ))} -
-
-
-
-
+ + {/* Right column - Browser view */} + ); }; diff --git a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx index be492a3a4058f0..10542bfc8dbfe0 100644 --- a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx +++ b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx @@ -7,8 +7,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; import { Label, TextField, TextArea } from "@calcom/ui/components/form"; -import { OnboardingCard } from "../../personal/_components/OnboardingCard"; -import { OnboardingLayout } from "../../personal/_components/OnboardingLayout"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; import { useOnboardingStore } from "../../store/onboarding-store"; import { ValidatedOrganizationSlug } from "./validated-organization-slug"; @@ -77,6 +78,7 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr return ( + {/* Left column - Main content */}
+ + {/* Right column - Browser view */} + ); }; diff --git a/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx b/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx index 11f7a590089f12..c0cd7966295a6e 100644 --- a/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx +++ b/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx @@ -12,6 +12,7 @@ import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/componen import { Icon } from "@calcom/ui/components/icon"; import { Logo } from "@calcom/ui/components/logo"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding"; import { useOnboardingStore } from "../../store/onboarding-store"; @@ -117,135 +118,55 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp {/* Main content */}
-
- {/* Card */} -
-
- {/* Card Header */} -
-
-

- {t("onboarding_org_invite_title")} -

-

- {isEmailMode - ? t("onboarding_org_invite_subtitle_email") - : t("onboarding_org_invite_subtitle_full")} -

+
+ {/* Left column - Main content */} +
+ {/* Card */} +
+
+ {/* Card Header */} +
+
+

+ {t("onboarding_org_invite_title")} +

+

+ {isEmailMode + ? t("onboarding_org_invite_subtitle_email") + : t("onboarding_org_invite_subtitle_full")} +

+
-
- - {/* Content */} -
-
- {!isEmailMode ? ( - // Coming soon - // Initial invite options view -
- -
-
- {t("onboarding_or_divider")} -
-
- -
- - - -
- {/* Role selector */} -
-
- {t("onboarding_invite_all_as")} - value && setInviteRole(value as "MEMBER" | "ADMIN")} - options={[ - { value: "ADMIN", label: t("onboarding_admins") }, - { value: "MEMBER", label: t("members") }, - ]} - /> +
+
+ {t("onboarding_or_divider")} +
- {t("onboarding_modify_roles_later")} -
-
- ) : ( - // Email invite form -
-
- {/* Email and Team inputs */} -
-
- - -
- {fields.map((field, index) => ( -
-
- - t.value === form.watch(`invites.${index}.team`))} + onChange={(option) => { + if (option) { + form.setValue(`invites.${index}.team`, option.value); + } + }} + placeholder={t("select_team")} + /> +
+ +
+ ))} + + {/* Add button */} + +
+ + {/* Role selector */} +
+
+ {t("onboarding_invite_all_as")} + value && setInviteRole(value as "MEMBER" | "ADMIN")} + options={[ + { value: "MEMBER", label: t("members") }, + { value: "ADMIN", label: t("onboarding_admins") }, + ]} + /> +
+ {t("onboarding_modify_roles_later")} +
+
+
+ )} +
-
- {/* Footer */} -
- + {/* Footer */} +
+ +
-
- {/* Skip button */} -
- + {/* Skip button */} +
+ +
+ + {/* Right column - Browser view */} +
diff --git a/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx b/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx index c7b76a37bd1feb..f30674773e75ed 100644 --- a/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx +++ b/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx @@ -1,15 +1,14 @@ "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 { OnboardingCalendarBrowserView } from "../../components/onboarding-calendar-browser-view"; +import { useSubmitPersonalOnboarding } from "../../hooks/useSubmitPersonalOnboarding"; import { InstallableAppCard } from "../_components/InstallableAppCard"; -import { OnboardingCard } from "../_components/OnboardingCard"; -import { OnboardingLayout } from "../_components/OnboardingLayout"; -import { SkipButton } from "../_components/SkipButton"; +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; import { useAppInstallation } from "../_components/useAppInstallation"; type PersonalCalendarViewProps = { @@ -17,9 +16,9 @@ type PersonalCalendarViewProps = { }; export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => { - const router = useRouter(); const { t } = useLocale(); const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation(); + const { submitPersonalOnboarding, isSubmitting } = useSubmitPersonalOnboarding(); const queryIntegrations = trpc.viewer.apps.integrations.useQuery({ variant: "calendar", @@ -29,23 +28,38 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) = }); const handleContinue = () => { - router.push("/onboarding/personal/video"); + submitPersonalOnboarding(); }; const handleSkip = () => { - router.push("/onboarding/personal/video"); + submitPersonalOnboarding(); }; return ( - + + {/* Left column - Main content */} - {t("continue")} - +
+ + +
}>
{queryIntegrations.data?.items?.map((app) => ( @@ -60,7 +74,8 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
- + {/* Right column - Browser view */} +
); }; diff --git a/apps/web/modules/onboarding/personal/profile/personal-profile-view.tsx b/apps/web/modules/onboarding/personal/profile/personal-profile-view.tsx deleted file mode 100644 index 67efa29b4f623a..00000000000000 --- a/apps/web/modules/onboarding/personal/profile/personal-profile-view.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"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 { showToast } from "@calcom/ui/components/toast"; - -import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; - -import { OnboardingCard } from "../_components/OnboardingCard"; -import { OnboardingLayout } from "../_components/OnboardingLayout"; -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(null); - const [imageSrc, setImageSrc] = useState(""); - 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({ - 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; - } - - return ( - - - {t("continue")} - - }> - {/* Form */} -
-
-
-
-
- {/* Avatar */} -
- -
- {user && } - - { - 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} - /> -
-
- - {/* Username */} -
- -
- - {/* Bio */} -
- - md.render(getValues("bio") || user?.bio || "")} - setText={(value: string) => setValue("bio", turndown(value))} - excludedToolbarItems={["blockType"]} - firstRender={firstRender} - setFirstRender={setFirstRender} - /> -

- {t("few_sentences_about_yourself")} -

-
-
-
-
-
-
-
-
- ); -}; diff --git a/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx b/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx index 84b4638351dd86..ba2ae424910145 100644 --- a/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx +++ b/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx @@ -2,23 +2,26 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useEffect, useRef, useState } from "react"; +import { FormProvider, 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 { UserAvatar } from "@calcom/ui/components/avatar"; import { Button } from "@calcom/ui/components/button"; -import { Label, TextField } from "@calcom/ui/components/form"; +import { Label, TextArea, TextField } from "@calcom/ui/components/form"; +import { ImageUploader } from "@calcom/ui/components/image-uploader"; +import { showToast } from "@calcom/ui/components/toast"; +import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; + +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt"; import { useOnboardingStore } from "../../store/onboarding-store"; -import { OnboardingCard } from "../_components/OnboardingCard"; -import { OnboardingLayout } from "../_components/OnboardingLayout"; type PersonalSettingsViewProps = { userEmail: string; @@ -28,17 +31,17 @@ type PersonalSettingsViewProps = { export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => { const router = useRouter(); const { t } = useLocale(); + const { data: user } = trpc.viewer.me.get.useQuery(); const { personalDetails, setPersonalDetails } = useOnboardingStore(); - const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences(); - const [name, setName] = useState(""); + const avatarRef = useRef(null); + const [imageSrc, setImageSrc] = useState(""); useEffect(() => { - setName(personalDetails.name || userName || ""); - if (personalDetails.timezone) { - setSelectedTimeZone(personalDetails.timezone); + if (user) { + setImageSrc(personalDetails.avatar || user.avatar || ""); } - }, [personalDetails, userName, setSelectedTimeZone]); + }, [personalDetails.avatar, user]); const formSchema = z.object({ name: z @@ -47,98 +50,169 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi .max(FULL_NAME_LENGTH_MAX_LIMIT, { message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }), }), + bio: z.string().optional(), }); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { name: personalDetails.name || userName || "", + bio: personalDetails.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(); }, }); + async function updateProfileHandler(newAvatar: string) { + avatarMutation.mutate({ + avatarUrl: newAvatar, + }); + } + const handleContinue = form.handleSubmit(async (data) => { // Save to store setPersonalDetails({ name: data.name, - timezone: selectedTimeZone, + bio: data.bio || "", }); // Save to backend await mutation.mutateAsync({ name: data.name, - timeZone: selectedTimeZone, + bio: data.bio || "", }); - router.push("/onboarding/personal/profile"); + router.push("/onboarding/personal/calendar"); }); + if (!user) { + return null; + } + return ( <> + {/* Left column - Main content */} - {t("continue")} - +
+ + +
}> - {/* Form */} -
-
-
-
-
- {/* Name */} -
- { - setName(e.target.value); - form.setValue("name", e.target.value); - }} - placeholder="John Doe" - /> - {form.formState.errors.name && ( -

{form.formState.errors.name.message}

- )} + +
+ {/* Profile Picture */} +
+ +
+ {user && ( +
+
- - {/* Timezone */} -
- - setSelectedTimeZone(value)} - /> -

- {t("current_time")}{" "} - {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()} -

-
-
+ )} + + { + 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} + />
+

{t("onboarding_logo_size_hint")}

-
-
+ + {/* Name */} +
+ + {form.formState.errors.name && ( +

{form.formState.errors.name.message}

+ )} +
+ + {/* Username */} +
+ { + // Refetch user to get updated username and save to store + const updatedUser = await utils.viewer.me.get.fetch(); + if (updatedUser?.username) { + setPersonalDetails({ username: updatedUser.username }); + } + }} + /> +
+ + {/* Bio */} +
+ +