Compare commits

..
36 Commits
Author SHA1 Message Date
emrysal f738b3bc0a chore: release v5.8.6
Post release / release (push) Canceled after 0s
2025-10-28 02:30:12 +00:00
Morgan 0853b4ca35 chore: add missing parseIntPipe on oauth client user endpoints (#24714) 2025-10-28 01:58:37 +00:00
Rodrigo Ehlers 8be05b1dc5 chore: explicitly set timezone for local (#24719) 2025-10-27 20:29:16 +00:00
Keith Williams 71515ad17c refactor: remove tRPC references from packages/lib (#24553) 2025-10-27 18:13:25 +00:00
sean-brydon 66234ffba9 chore: illustrations to getting-started page in onboarding v3 (#24673)
* i18n

* WIP icon stuff

* More icon work

* More gradient tuning

* Mvoe plan-icon to planicon.tsx

* Fix cubic suggestion

* Fix darkmode icon and gradients

* Fix type error
2025-10-27 17:32:06 +00:00
Rodrigo Ehlers 66c5f810ea fix: remove singular query that was using slow OR query (#24715)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

Removes and replaces the singular query that was still using the slow OR query from prisma. It was mainly used on the public booking page and triggered after someone entered their email (600ms debounce delay).

## Visual Demo (For contributors especially)

Internal change only nothing changes on the UI or business logic.

#### Video Demo (if applicable):

N/A

#### Image Demo (if applicable):

N/A

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

Basically the same as https://github.com/calcom/cal.com/pull/24298 since it touches relevant code.

1. Have a user that has the setting to prevent impersonation on.
2. Go to another users public booking page of any meeting.
3. Try to enter the email of the first user into the booking for.
4. Wait 1 second and make sure the button shows "Verify email".

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2025-10-27 17:11:05 +00:00
81d9d84b4a fix: make next environment variables optional, externalize react runtime (#24613)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-27 15:16:05 +00:00
sean-brydon 6ddc666827 feat: onboarding v3 - app install flow personal (#24710)
## What does this PR do?

Refactors the personal onboarding flow by creating reusable components for better code organization and maintainability. The changes include:

- Creates shared components for the onboarding experience: `OnboardingLayout`, `OnboardingCard`, `InstallableAppCard`, and `SkipButton`
- Implements a custom hook `useAppInstallation` to manage app installation state and callbacks
- Enhances the video app connection step to automatically set the first connected video app as default
- Improves the calendar connection flow with proper installation handling
- Adds visual indicators for connected apps

## Visual Demo (For contributors especially)

The refactored components maintain the same visual appearance while improving code structure and reusability.

## Mandatory Tasks

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Go through the personal onboarding flow
- Test connecting calendar apps in step 3
- Test connecting video apps in step 4
- Verify that the first connected video app is automatically set as default
- Check that connected apps show the "connected" indicator
- Verify that skipping steps works correctly

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-10-27 15:01:09 +00:00
sean-brydonandcubic-dev-ai[bot] 01346abb85 feat: Add personal v3 onboarding flow (#24681)
* WIP personal onbaording flow

* Add i18n

* Fix overflow clip blockin timezone select

* Remove duplicate label

* Move to two column approach

* Restore lock fle

* Remove duplicate key in common json

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update personal-settings-view.tsx

* Fix endpoint

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-10-27 13:36:46 +00:00
Lauris Skraucisandrez1coder 3d9650c841 docs: cancelling normal booking (#24703)
* docs: cancelling normal booking

* Update apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts

Co-authored-by: rez1coder <66778119+rez1coder@users.noreply.github.com>

---------

Co-authored-by: rez1coder <66778119+rez1coder@users.noreply.github.com>
2025-10-27 12:39:11 +00:00
Anik Dhabal Babu f8ede75dd8 fix: few flaky e2e tests (#24690)
* fix: flaky e2e tests

* fix one more flakes

* update
2025-10-27 08:38:59 -03:00
sean-brydon 435587f043 feat: onboarding v3 teams (#24573)
## What does this PR do?
This PR is stacked on https://github.com/calcom/cal.com/pull/24299 this adds the v3 flow for onboarding with teams.

    
<!-- This is an auto-generated description by cubic. -->
---

## Summary by cubic
Adds the v3 team onboarding flow with Details, Brand, and Invite steps, including slug validation, branding, and member invites. Updates routing to support the Team plan and creates teams with Stripe checkout when needed.

- **New Features**
  - Team Details: name and slug with async availability check and URL preview.
  - Team Brand: hex color picker and logo upload with live preview.
  - Team Invite: add/remove emails, invite role toggle (Member/Admin), and form validation.
  - State: adds teamDetails, teamBrand, and teamInvites to the onboarding store with actions.
  - Creation: new useCreateTeam hook; redirects to Stripe if checkout URL is returned, or to Getting Started on success.
  - Routing/Auth: protected team pages (details, brand, invite) and updates plan selection to route to /onboarding/teams.

<!-- End of auto-generated description by cubic. -->
2025-10-27 11:00:11 +00:00
Anik Dhabal BabuandDevin AI e5abe93940 fix: Add beforeAll hooks to ensure organization settings are properly configured in integration tests (#24674)
The flaky test failures were caused by the tests depending on the database being properly seeded with the isAdminAPIEnabled flag set to true for the Acme organization. The tests would fail randomly when the database wasn't properly seeded or when the organization settings weren't configured correctly.

This fix adds beforeAll hooks to the failing integration tests to ensure that:
1. The Acme organization has isAdminAPIEnabled set to true
2. The Dunder Mifflin organization has isAdminAPIEnabled set to false

This ensures consistent test behavior regardless of the database state and prevents the flaky failures.

Fixes the following failing tests:
- isAdmin.integration-test.ts: Returns org-wide admin when user is set as such & admin API access is granted
- retrieveScopedAccessibleUsers.integration-test.ts: Returns members when admin user ID is supplied and members IDs are supplied
- retrieveScopedAccessibleUsers.integration-test.ts: Returns members when admin user ID is an admin of an org
- _get.integration-test.ts: Returns bookings for org users when accessed by org admin
- _patch.integration-test.ts: Allows PATCH when user is org-wide admin

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-27 05:32:05 -03:00
c3a5158a53 feat: add availabilityLoaded embed event (#21350)
* feat: add availabiityLoaded embed event

* chore

* fix

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-10-27 06:22:09 +00:00
Sagnik PalandDhairyashil Shinde a9e14b9816 fix/23800-capitalize-button-lable-consistency (#23822)
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-10-27 11:23:03 +05:30
Dhairyashil Shinde e207f40611 refactor: update the config to auto close stale pr (#24701) 2025-10-26 23:28:38 +00:00
Shrey-Sutariya 4ca2076c72 fix: add spacing between form and submit button in TeamEventTypeForm (#24680)
* fix: add spacing between form and submit button in TeamEventTypeForm

* fix: address PR feedback - add bottom padding to form div
2025-10-26 12:46:19 +00:00
Akshat Bajetha 959b803d84 fix: event-title-overflow (#24693)
* fix: event-title-overflow

* fix: title overflow in multiple components

* fix: title overflow in readonly component
2025-10-26 12:14:21 +00:00
Albin_Baby 7f6136004c fix: enable overflow (#24560) 2025-10-25 16:18:59 +00:00
spandevandVolnei Munhoz bb79368b2d fix: empty event types page doesn't show no more results anymore (#24655)
* fix: empty event types page doesn't show no more results anymore

* update: conditional rendering of results button

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-10-25 15:32:44 +00:00
chauhan_sandDevin AI 70825dd138 fix: define process.env variables in atoms vite config to prevent 'process is undefined' errors (#24665)
- Add all process.env variables used by Next.js code to the define section
- This ensures they are replaced at build time and don't cause runtime errors in Vite projects
- Fixes #24213

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-25 20:38:48 +05:30
Volnei Munhoz 05c399db9c lint stage not loading config from file - using root instead (#24671) 2025-10-25 10:51:18 +00:00
Morgan 57776b829b fix: old rec booking endpoint api v2 (#24669)
* fix: old rec booking endpoint api v2

* fixup! fix: old rec booking endpoint api v2

* fixup! fixup! fix: old rec booking endpoint api v2
2025-10-25 07:24:48 -03:00
Kartik 7fd28c39f3 fix: improve mobile positioning for link dialog in form builder checkbox editor (#24268)
* Updated positionEditorElement function to respect viewport boundaries

* cleanup
2025-10-25 10:03:22 +00:00
Pallav ff17aa79fc updated animation for drop down menu (#23650) 2025-10-25 09:23:50 +00:00
emrysal 578be32936 chore: release v5.8.5
Post release / release (push) Canceled after 0s
2025-10-25 00:25:46 +00:00
Dhairyashil ShindeandAnik Dhabal Babu b183239ff5 chore: add autocomplete for inputs - name, phone, location (#24422)
* add autocomplete for inputs - name, phone, location

* Addressed coderabits comments

* autocomplete for email and url

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-24 21:36:41 +00:00
Pallav 013e614397 fix: handle empty location in event types (#24342) 2025-10-24 17:58:34 -03:00
sean-brydonandAnik Dhabal Babu 6843ced3db Use correct memebrships for permission calculations (#24653)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-24 07:44:47 +01:00
Anik Dhabal Babu 7318015a6e fix: Delete all workflow reminders when workflow is deleted (#24615) 2025-10-24 02:54:08 +00:00
emrysal fc4d0a6aa3 chore: release v5.8.4
Post release / release (push) Canceled after 0s
2025-10-24 00:27:10 +00:00
sean-brydon 8e0cdf3671 feat: Onboarding V3 (#24299)
* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

* WIP flow for self-hosted. Same service call just split logic

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

* pass role and team name from onboarding to save in schema

* Add test to ensure role + name + team are persisted into onboarding table

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Revert packages

* Revert all packages/* changes to original branch point

* Layout fixes + design

* Add slugify

* Support saving brand,logo and banner

* Cleanup

* iMobile fixes

* More mobile and darkmdoe fixes

* Add I18n

* Fix lock file

* Fix types

* Fix types errors

* Copy changes
2025-10-23 20:01:54 +01:00
Eunjae LeeandDevin AI fcd9480f9f fix: update seed-insights.ts Prisma connection handling for v6.16 (#24660)
* fix: update seed-insights.ts to use correct Prisma import and connection handling

- Change prisma import from named export to default export to match Prisma v6.16 changes
- Update promise chain from .then().catch() to .catch().finally() for proper connection cleanup
- Comment out duplicate runSeed() call that was causing the script to execute twice
- Fixes TypeError: Cannot read properties of undefined (reading '$connect')

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix implementation

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-23 15:59:05 +00:00
Eunjae LeeandDevin AI 09ddbe886f refactor: make DataTableProvider framework-agnostic by requiring tableIdentifier (#24513)
* refactor: make DataTableProvider framework-agnostic by requiring tableIdentifier

- Remove Next.js usePathname dependency from DataTableProvider
- Make tableIdentifier a required prop instead of optional
- Update all usages to provide explicit tableIdentifier values
- This makes DataTableProvider usable in non-Next.js contexts

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use usePathname at usage sites instead of hardcoding tableIdentifier

- Add validation in DataTableProvider for empty/nullish tableIdentifier
- Use usePathname() in Next.js apps to pass pathname as tableIdentifier
- Use descriptive identifiers for non-Next.js package components
- This keeps DataTableProvider framework-agnostic while allowing Next.js apps to use pathname

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* use pathname instead of hard-coded identifiers

* change type of tableIdentifier

* simplify

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-23 15:32:16 +02:00
Eunjae LeeandDevin AI 4ac8a51bc2 feat: migrate nuqs from v1.20.0 to v2.7.2 (#24514)
* feat: migrate nuqs from v1.20.0 to v2.7.2

- Update nuqs package version in apps/web/package.json
- Add NuqsAdapter wrapper in root layout for App Router compatibility
- Server-side imports already using correct 'nuqs/server' path
- All existing parsers and usage patterns remain compatible
- No breaking changes to application functionality
- Fix unnecessary escape characters in fontFamily regex

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* move adapter

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-23 10:24:22 -03:00
82951cd04e chore: [Booking Flow Refactor - 5] Move post booking things to separate service BookingEventHandlerService starting with HashedLink usage handling (#24025)
* chore: Move post booking stuff to separate service

* refactor: improve ISP compliance in BookingEventHandlerService

- Refactor updatePrivateLinkUsage to only accept hashedLink parameter instead of full payload
- Remove shouldProcess function and inline isDryRun check for better clarity
- Addresses feedback from PR #24025

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* define only what is used

* Define hashed-link-service as well in api-v2

* fix: export HashedLinkService through platform-libraries

- Add HashedLinkService to platform-libraries/private-links.ts
- Update API V2 to import from platform library instead of direct path
- Fixes MODULE_NOT_FOUND error in API V2 build

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-23 15:05:55 +03:00
188 changed files with 5598 additions and 526 deletions
+3
View File
@@ -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
+4 -4
View File
@@ -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
View File
View File
View File
@@ -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,
});
@@ -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;
+11 -4
View File
@@ -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}
+1 -1
View File
@@ -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}
+11 -8
View File
@@ -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>
+11 -8
View File
@@ -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 />
+4 -1
View File
@@ -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>
);
};
+3 -1
View File
@@ -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 = () => {
+2 -2
View File
@@ -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",
+10 -6
View File
@@ -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();
+2 -1
View File
@@ -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");
+1 -1
View File
@@ -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);
+52 -1
View File
@@ -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({
+18 -6
View File
@@ -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."
}
}
},
+4 -4
View File
@@ -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 & {
+18
View File
@@ -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;
@@ -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,
},
});
+5
View File
@@ -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);
+6 -5
View File
@@ -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