Compare commits

..
99 Commits
Author SHA1 Message Date
Udit Takkar a5970cb1a8 fix: 2FA falky test (#13567)
release.yml / release (push) Canceled after 0s
* fix: 2FA falky test

* chore: increasing limit
2024-02-06 20:07:09 +00:00
Keith Williams 925dc04f1c v3.7.13 2024-02-06 17:07:05 -03:00
271f31730d fix: recurring round robin events (#13471)
* use same lucky user + check availability

* add tests for recurring round robin events

* only use luckyUsers for recurring bookings

* don't check availability for all recurring dates

* code clean up

* pass schedulingType

* fix type error

* add availability check for fixed hosts + test

* fix type error

* implement feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-06 18:18:42 +00:00
Udit Takkar 684f4a795c fix: locale flaky tests (#13564) 2024-02-06 16:52:21 +00:00
836429348c fix: 'Invalid event length' error for 1 hour dynamic events (#13522)
* fix #13507 'Invalid event length' when creating dynamic events with a 1 hour duration

* Fix failing e2e tests by dynamically injecting lengths for dynamic events

--amend 1: misc indentation cleanup
--amend 2: Added condition to only update event metadata when slug is dynamic and multipleDuration is empty

* Override the multipleDuration property while constructing the dynamicEvent object itself

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-02-06 15:55:04 +00:00
Udit TakkarandKeith Williams a55138348f fix: Flakiness in location tests (#13557)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-06 11:54:35 -03:00
66b5dfd796 fix: payload cleanup (#13552)
* fix: payload cleanup

* Update eventType.ts

* Send whitelisted fields everywhere

* Removed a user select

* Removed extra usage of users

* Put back user field with fewer selects

* Added user pick

* Removed more users selects

* Another select removal

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-06 11:20:08 -03:00
bba68e09a9 fix: add emailVerified in seed script (#13530)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-02-06 10:33:47 -03:00
Hariom Balhara bab182960f Fix admin being able to update owner details (#13534) 2024-02-06 12:52:40 +00:00
Udit TakkarandKeith Williams f09153b3bb fix: don't display setup page icon for these apps (#13537)
* fix: don't display setup page icon for these apps

* chore: make it optional

* chore: feedback

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-06 04:10:22 +00:00
3e492eb17b fix: event-type listing not showing existing event-types when an existing user is invited to an organization (#13524)
* Fix event-type listing not working

* Automatic reload of the app when being added to a different organization

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-06 03:52:24 +00:00
Omar López fe5fe8d53b v3.7.12
release.yml / release (push) Canceled after 0s
2024-02-05 13:25:43 -07:00
Omar LópezandKeith Williams 0cc5937104 chore: uses safeGet to be more explicit (#13543)
* chore: uses safeGet to be more explicit

* Update apps/web/middleware.ts

* Update apps/web/middleware.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-05 20:20:18 +00:00
Joe Au-Yeung fde3b49126 Add null upId and profile (#13545) 2024-02-05 19:54:42 +00:00
Keith Williams 556787b93b perf: yarn patch dayjs (#13542)
* perf: Use overriden tz function

* Renamed .js to .ts

* Trying to use a type file for timezone

* perf: Yarn patch dayjs

* Fixed import casing
2024-02-05 19:53:15 +00:00
Peer Richelsen dcb244d7be fixed france i18n (#13536) 2024-02-05 18:05:02 +00:00
Alex van AndelandKeith Williams 8d7fb75f71 fix: Refactored availability tests for less flakiness (#13518)
* fix: Refactored availability tests for less flakiness

* Fixed type error

* Removed redundant locator

* fix: Commit updated yarn.lock file

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-05 16:00:49 +00:00
4fe75771b8 fix: Broken routing form public links(old links) on migrating the owner user to an organization (#13416)
* Fix routing-form access restrictions

* Update packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Fix routing form access issue for users who are never migrated

* Improve comment

* wip

* Fix event-type listing not working

* Automatic reload of the app when being added to a different organization

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-02-05 15:51:30 +00:00
Alex van Andel 63f537a08f fix: wrap isSignupDisabled in try/catch 2024-02-05 14:26:40 +00:00
Crowdin Bot bed593c835 New Crowdin translations by Github Action 2024-02-04 19:22:35 +00:00
Alex / KATT f367c5ed3d chore: use one yarn version (#13517) 2024-02-04 19:19:46 +00:00
zomars af1382c5ba feat: adds signup disabling middleware 2024-02-03 14:47:51 -07:00
Tangerine Kugelmann eeb0fc795d chore: create security.txt (#13454) 2024-02-03 14:15:37 +00:00
4aa040d96e feat: routing form fields as variables in route (#13519)
* add UI for setting up custom event typ url

* extract variables when processing route

* remove console.log

* fix some UI issues

* code clean up

* add translation

* fix type error

* Update packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* make variable check case insensitive

* add function

* code clean up

* code clean up

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-02-02 19:25:37 -03:00
12466fb064 feat: New Organization Schema along with the ability to invite existing users to an organization (#13002)
* Add profile table

Copy to profile table

Update schema

Migrations

* wip

* Get switcher working

* Add organization seeder

* wip

* Remove dead code

* Fix unit tests

* relevantProfile -> profile

* cache bust

* One more comment

* Squash migrations

* Copy to profile table

* Update schema

* Fix existing user invite flow

* Add profile table

* Copy to profile table

* Update schema

* Format

* Add profile table

Copy to profile table

Update schema

Migrations

* Format

* wip

Get switcher working

Add organization seeder

wip

Remove dead code

Fix unit tests

relevantProfile -> profile

cache bust

One more comment

Squash migrations

Copy to profile table

Update schema

Fix existing user invite flow

Add profile table

Copy to profile table

Update schema

chore: small improvements to signup (#13227)

* removed features on mobile, better background color

* nit

* dark mode nits

New Crowdin translations by Github Action

fix: event reschedule workflow trigger (#13122)

* fix event reschedule workflow trigger

* uncomment previously failed test

* review fix

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>

chore: added community support to help (#13213)

refactor: Abstract `handleSeats` from `handleNewBooking` (#12062)

* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Erik <erik@erosemberg.com>

fix: Dynamic group booking  org-migration redirect (#13223)

* fix: Dynamic group booking redirect

* Fix flaky private team test

test: Check availability tab and all your functions (teste2e-availability) (#12434)

* add changes

* add changes

* add changes

* add changes

* Requested changes

* Change way to grab element

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

fix: Reschedule a cancelled booking (#13170)

* fix: Reschedule a cancelled booking

* canceled instead of cancelled

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

v3.7.0

test: In Team Event type when location is Organizer Default App and U… (#12939)

* test: In Team Event type when location is Organizer Default App and User Metadata is emoty

* chore: use variable

* fix: type error

test: add test for invalid cal video (#13103)

* test: add test for invalid cal video

* chore: add confirm

* chore: fix type error

* chore: update code

* test: add unit test

* chore: type error

* chore

* chore: type

* chore: fix test

* chore

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

chore: add error message for no availability (#13230)

* chore: add error message for no default user availability

* chore: check only availability

* chore: change message

* chore: add eventType

fix: Team revert with members (#13228)

refactor: Refactor seats logic (#12905)

* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

* Move handleSeats

* Abstract lastAttendeeDeleteBooking

* Create function for rescheduling seated events

* Fix imports on reschedule seats function

* Fix imports

* Import handleSeats function

* Fix rescheduleUid type

* Refactor owner reschedule to new time slot

* Refactor combine two booking times together

* Reschedule as an attendee

* Refactor createNewSeat

* Remove old handleSeats

* Remove lastAttendeeDeleteBooking from handleNewBooking

* Test for new attendee right params are passed

* Unit test params for reschedule

* Typo fix

* Clean up

* Create new seat test

* Test when attendee already signs up for booking

* Type fix

* Test reschedule move attendee to existing booking

* On reschedule create new booking

* Test on last attendee cancel booking

* Owner reschedule to new time slot

* Owner rescheduling, merge two bookings together

* Test: when merging more than available seats, then fail

* Test: fail when event is full

* Remove duplicate E2E tests

* Clean up

* Rename `addVideoCallDataToEvt` to `addVideoCallDataToEvent`

* Refactor `calcAppsStatus`

* Assign `evt` to resutl of `addVideoCallDataToEvent`

* Use prisma.transaction when moving attendees

* Clean create seat call

* Use ErrorCode enum

* Use attendeeRescheduledSeatedBooking function

* Await function

* Prevent double triggering of workflows

* Use inviteeToAdd in createNewSeat

* Remove unused error code

* Remove old handleSeats file

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Erik <erik@erosemberg.com>

feat: Allow admins to filter bookings only by attendee emails (#13236)

* chore: Allow admins to filter bookings only by attendee emails

* Fix type error

Fixed [CAL-2930] Org branding isnt centered in shell #13225 (#13237)

Add profile table

Copy to profile table

Update schema

Migrations

Format

* wip

* Add profile relation everywhere

* Fix schema

* Fix invite handler

* Add profileId during eventTypec reation

* Get dashboard working with profile relatioon

* Add workflow create repository

* Add profileId to host

* Add profileId to credential table

* Add destinationCalendar repository

* Add profileId to membership

* Revert all profile related changes

* Add acme and dunder

* Fix membership profileId usgae

* Various fixes

* Some renaming

* Fix failing tests

* Debugging next-aut

* Consolidate migrations

* Testing fixes

* Fix unit tests

* Self review feedback

* PR feedback

* Fix orgMigration as well

* Add profile schema and copy users to it

* Remove migrations from here

* Fix type issues

* Fix type error after main merge

* Remove movedFromUserId

* Add missing migration file

* Self review addressed

* Joe's review addressed

* Fix profile switcher

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-02 21:34:13 +00:00
Carina Wollendorfer 42a3448abb fix translation (#13516) 2024-02-02 16:01:28 +00:00
Peer RichelsenandKeith Williams 92ff49f6a1 replaced border-neutral-300 with border-subtle (#13500)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-02 15:49:41 +00:00
Udit Takkar c4aa555610 fix: remove check for location (#13493) 2024-02-02 15:41:27 +00:00
Somay Chauhan a45d77de51 fix: initialized flags on event-types page (#13469)
* fix: initialized flags on event-types page

* changes

* chore: removed `managed-event-types` flag as no longer needed

* revert: `20230404202721_add_feature_flag_managed_event_types` migration
2024-02-02 15:35:00 +00:00
ErikandKeith Williams 0421dd401a feat: API wrapper for invite members. (#13496)
* chore: API

* chore: remove

* chore: Update

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-02 12:27:17 -03:00
Hariom Balhara 094189fc3b Correcly identify the user during signup (#13506) 2024-02-02 15:18:00 +00:00
Abhijeet Singhandsupalarry 677a964d4f feat: Added new API route to get event-types using slug (#13374)
* added new api route /event-types/bySlug/{slug}

* refactor: schema query slug

* refactor: event-types/bySlug to event-types/by-slug route

* feat: allow admins to fetch event types on behalf of others

* fix: auth middleware

* added slug query param to main event-type api route

* removed the /by-slug route

* refactor logic

* remove unwanted changes

* updated swagger api doc

---------

Co-authored-by: supalarry <lauris.skraucis@gmail.com>
2024-02-02 15:10:43 +00:00
sean-brydon 2357c919c1 fix: weekly view booker blocked slots (#13511) 2024-02-02 15:08:24 +00:00
Hichem Fantar 27b7cccfa6 fix: syntax error in WordPress plugin (#13482) 2024-02-02 14:46:46 +00:00
Crowdin Bot ab2354f2a4 New Crowdin translations by Github Action 2024-02-02 14:31:56 +00:00
Alex van Andel 538e5017c5 fix: Skip booking limits for now (#13510) 2024-02-02 11:27:36 -03:00
2e3060b738 docs: Adds deployment guides for AWS, GCP and Azure (#12656)
* Deployment Steps

* Update deployment.md

* Deployment steps

* Update deployment.md

* Deployment Steps

* Update deployment.md

* Update yarn.lock

* Update yarn.lock

* Update and rename deployment.md to README.md

---------

Co-authored-by: Nathaniel-Nirmal <na453@cornell.edu>
Co-authored-by: Sriya Challa <100821752+sriyachalla@users.noreply.github.com>
Co-authored-by: Nathaniel-Nirmal <142919730+Nathaniel-Nirmal@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
2024-01-31 11:24:45 +00:00
Peer Richelsen e9d9aebd88 chore: cal.ai try out new gpt-4-0125-preview (#13461) 2024-01-31 06:42:25 -03:00
Omar López 7d1c69801e chore: optimizing bundle size (#13473)
* WIP

* Tree shaking
2024-01-30 19:49:29 -03:00
Peer Richelsen 4235b32349 chore: added 90 min to + group links (#13475) 2024-01-30 20:28:48 +00:00
Omar López 2ca031e25c v3.7.11
release.yml / release (push) Canceled after 0s
2024-01-30 09:33:30 -07:00
Omar López 9f734b8964 fix: tailwind config for web modules (#13466) 2024-01-30 15:26:41 +00:00
Peer Richelsen d6927c5049 chore: fixed theme colors (#13463)
the palette has changed so the theme has to be adjusted for pwa mode
2024-01-30 12:04:57 +00:00
Keith Williams 910f5f906f v3.7.10
release.yml / release (push) Canceled after 0s
2024-01-30 08:27:06 -03:00
Peer RichelsenandHariom 71f955e256 feat: salesroom app (#13277)
* added salesroom app

* fixes to screenshots

* new screenshots and description

* Add missing update to yarn.lock

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-01-30 11:25:29 +00:00
sean-brydon 5e60654aa9 fix: make show more banner static > absolute to allow embed auto height (#13457)
* fix: make show more banner static > abosulte to allow embed auto height

* fix: hide on mobile
2024-01-30 08:48:08 +00:00
Somay Chauhan b6175b3dde fix: Pay to book for an event that no longer has payments (#13432) 2024-01-29 22:44:45 +00:00
a0a731b357 chore: adds future opt-in banner for ADMINS only (#13315)
* chore: adds future opt-in banner for ADMINS only

* Adds banner to shell

* Update teams.e2e.ts

* fix: e2e ab tests

* Update ab-tests-redirect.e2e.ts

* Discard changes to apps/web/playwright/ab-tests-redirect.e2e.ts

* Discard changes to apps/web/playwright/app-store.e2e.ts

* Discard changes to apps/web/playwright/event-types.e2e.ts

* Discard changes to apps/web/playwright/payment.e2e.ts

* Discard changes to apps/web/playwright/settings-admin.e2e.ts

* Discard changes to apps/web/playwright/teams.e2e.ts

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-29 13:20:01 -07:00
Omar López fdaa6961f8 fix: Adds missing rate limiting headers (#13430) 2024-01-29 12:26:11 -07:00
Benny Joo d91e74ec13 chore: module based file structure for /video group (#13453)
* chore: module based file structure for /video group

* fix

* fix
2024-01-29 18:59:24 +00:00
Keith Williams 0af780aac9 fix: before/after event buffers not rendering correctly (#13451) 2024-01-29 15:29:26 +00:00
41b0589ab0 fix: seated event custom questions (#12779)
* fix: seated event custom questions

* updating tests & code refactor

* addition of prisma custom json schema and type fixes

* attendeeName parsing refactor

* response parsing fix

* response parsing fix

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-29 14:37:52 +00:00
Somay ChauhanandUdit Takkar 00e96ac7ad fix: show leave site alert for instant meetings only (#13448)
* fix: leave site alert

* fix: only use expiry time

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-01-29 13:40:47 +00:00
Keith Williams 63feb721ea fix: Deployments on main (#13450) 2024-01-29 10:00:53 -03:00
Somay ChauhanandUdit Takkar 43c1bd63ca fix: destination calendar selection resetting to default on page reload (#13440)
* fix: destination calendar selection resetting to default on page reload

* fix: duplicate destination calendar settings on duplicate event

* fix: zod error in test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-01-29 10:03:26 +00:00
8bc265faaa fix: When declining OAuth return to previous page (#13189)
* Get `onErrorReturnTo` URL

* If no code return to previous page

* On invalid permissions, return to previous page

* When not enough permissions given, invalidate the credential created

* Type fix

* Stripe go back to previous page on cancel

* Clean up console.logs

* Add early returns when redirecting

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-01-29 05:51:53 -03:00
Morgan f33ea91ab0 chore: move getFixedT to web (#13446) 2024-01-29 08:44:15 +00:00
cc0ff17d97 test: Create E2E tests for a collective event type (teste2e-bookCollective) (#12565)
* refactor

* add localize / refactor

* add changes

* fix conflicts

* replace text by testId

* refactor

* Update regularBookings.ts

* fix conflicts

* fix failing check

* fix failing test

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
2024-01-28 20:55:17 -03:00
Crowdin Bot 4d128971bb New Crowdin translations by Github Action 2024-01-28 21:09:13 +00:00
Udit Takkar f9f82283fb chore: add logs for debugging (#13441) 2024-01-28 21:06:44 +00:00
Crowdin Bot 111135a605 New Crowdin translations by Github Action 2024-01-28 08:13:15 +00:00
Somay Chauhan 04e577582d fix: copy private link tooltip (#13437) 2024-01-28 08:10:10 +00:00
neo773 6643d3c20b fix: prefill avatar when signing up (#13427) 2024-01-27 14:24:31 +00:00
Somay Chauhan 3c42301c19 fix: "Hide notes in calendar" keeps reverting to off despite saving (#13434) 2024-01-27 12:04:15 +00:00
Somay Chauhan 20517be280 fix: app crash on clicking on an event (#13435) 2024-01-27 12:03:23 +00:00
fa45a85088 Create RTL tests for TimezoneSelect component (#10765)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-01-27 07:59:44 -03:00
Benny Jooandzomars c839e40a3e chore: [app-router-migration 26] Migrate "/[user]" page (#13057)
Co-authored-by: zomars <zomars@me.com>
2024-01-27 10:57:43 +00:00
9422e28598 test: check all advanced options in a managed event-type (teste2e-managedOptions) (#12294)
* check all advanced options in a managed event-type

* Delete apps/web/playwright/booking/managedBookingAllQuestion.e2e.ts

* Delete apps/web/playwright/managedBooking/allQuestions.e2e.ts

* Refactor

* Refactor

* check all advanced options in a managed event-type

* check all advanced options in a managed event-type

* Update regularBookings.ts

* Update regularBookings.ts

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-27 07:50:38 -03:00
d804b3947d chore: [app-router-migration 36] Migrate /org/* page group (#13202)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Pradumn Kumar <pradumn@tealfeed.com>
Co-authored-by: zomars <zomars@me.com>
2024-01-26 13:06:02 -07:00
David SomersandKeith Williams 3fa710fc1c Fix typo in webhook delete response (#13409)
It's a webhook, not a schedule.

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-26 19:29:24 +00:00
f9359aaa7b feat: added ability to assign event to all members (#13219)
* feat: added ability to assign event to all members

* fix: types

* chore: incorporate assignment for collective and round robin event sas well

* fix: update event types query

* feat: round robin hosts

* fix: update

* fix: update

* fix: assignAllTeamMembers issue

* design fixes

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-01-26 17:37:38 +00:00
Carina WollendorferandCarinaWolli 7c7ca79d48 remove duplicate confirmBooking (#13423)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-01-26 16:27:18 +00:00
DmytroHryshyn 753cb5803d chore: module based file structure: event types (#13404) 2024-01-25 18:37:26 -07:00
Omar López 210bc9fc8c chore: Proposal for new module based file structure (#13396) 2024-01-25 17:31:08 -07:00
Crowdin Bot 3d8b5390df New Crowdin translations by Github Action 2024-01-25 22:30:50 +00:00
zomars a477f337dc fix: login flaky test 2024-01-25 15:27:45 -07:00
9db9204088 test: Check the webhook event tab and your funtionalities (#12433)
* Check the webhook event tab and your funtionalities

* Check the webhook event tab and your funtionalities

* add changes

* add changes

* add changes

* add changes

* add changes

* add changes

* fix tests

* Refactor

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-01-25 15:50:11 -03:00
Crowdin Bot fe46b81c63 New Crowdin translations by Github Action 2024-01-25 17:54:39 +00:00
Keith Williams 17db644be1 v3.7.9
release.yml / release (push) Canceled after 0s
2024-01-25 14:51:08 -03:00
e4037be79a feat: Limit the amount of days to load for a schedule (#13282)
* feat: Disallow next month prefetching in booker

* renamed to include NEXT_PUBLIC

* fixed boolean expression

* Now using a variable length of days to load

* Type fixes

* Now opening all dates to be clickable

* Added turbo.json var and updated example env

* Fixed issue with selectedDate being null when first loading the page

* Updated description of variable

* Made constant for the booking days

* Type fix

* Setting the days to load for weekly view to 7

* feat: add ui and ability to fetch more on click

* feat: add ui and ability to fetch more on click

* fix: loader

* fix: banner detection

* Update packages/features/bookings/Booker/Booker.tsx

* fix: types

* Not showing HavingTroubleFindingTime component when the schedule is loading

* Only enabled on monthly view

* Correctly load initial date

* Type fix

* Fixed bugs around triggering multiple requests

* Added case for when there is no selectedDate but the month has been specified

* Types

* More types

* Check for undefined

* More typing

* More typing

---------

Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-01-25 14:48:46 -03:00
DmytroHryshyn ebe6a60366 fix insights page (#13390) 2024-01-24 14:22:09 -07:00
zomars fa9c6ea0ef fix: flaky test 2024-01-24 14:21:06 -07:00
Keith Williams 9b22f44cba v3.7.8
release.yml / release (push) Canceled after 0s
2024-01-24 16:01:51 -03:00
Morgan c84f75df99 fix: Booker shows toggle external calendar on external links (#13395) 2024-01-24 15:38:17 -03:00
Crowdin Bot 8f9a4fc78f New Crowdin translations by Github Action 2024-01-24 16:06:17 +00:00
7b30ade284 test: Create E2E tests to check the responsive design for a regular booking and edit a regular event type (responsiveLayoute2e) (#12432)
* add tests for responsive layout

* test: Create E2E tests to check the responsive design for a regular booking and edit a regular event type

* test: Create E2E tests to check the responsive design for a regular booking and edit a regular event type

* test: Create E2E tests to check the responsive design for a regular booking and edit a regular event type

* Create tests

* Create tests

* Create tests

* add changes

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-24 13:02:12 -03:00
Keith WilliamsandJoe Au-Yeung 24a4646fdc fix: sync/async calls in getSchedule (#13387)
* Fixed the sync/async calls

* fixing type errors

* fixing more type errors

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-01-24 10:05:25 -05:00
Keith Williams 0860036607 v3.7.7
release.yml / release (push) Canceled after 0s
2024-01-24 11:06:38 -03:00
Peer RichelsenandOmar López d080a0ef2d feat: notion calendar (#13299)
* replaced cron with notion calendar

* fixed app store for cron

* fixed slug

* Apply suggestions from code review

---------

Co-authored-by: Omar López <zomars@me.com>
2024-01-24 12:10:15 +00:00
a6eab74fd1 feat: make org members list private (#13373)
* feat: make org members list private

* fix: add isOrg to MakeTeamPrivateSwitch in other-team-members-view

---------

Co-authored-by: supalarry <lauris.skraucis@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-01-24 11:28:34 +00:00
0a2e275908 fix: UI mismatch with form state (#10651)
* fix: UI mismatch with form state

* Fix: Sync form state with ui for more fields & slight code refactor

* Ensure Stripe app is enabled to disable seats

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-01-24 10:55:31 +00:00
Crowdin Bot 3b05ebc5b5 New Crowdin translations by Github Action 2024-01-24 10:46:13 +00:00
Omar López e5b4d2fb80 fix: API slots endpoint return format (#13377) 2024-01-24 10:43:13 +00:00
Syed Ali Shahbaz 00a2a3d8a8 Fix slot wrapper (#13379) 2024-01-24 07:01:57 +00:00
30ba168fc3 chore: bump @trpc/* and @tanstack/react-query (#13335)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Gergő Móricz <mo.geryy@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-23 16:42:29 -07:00
SashaandDmytro Hryshyn 12eec186b2 chore: [app-router-migration 31] migrate the "/auth/setup" page (#13196)
* wip

* chore: setup page migration

* fix: conventions

* fix: convention

* use client

* fix: rebase on 13.5

---------

Co-authored-by: Dmytro Hryshyn <dev.dmytroh@gmail.com>
2024-01-23 18:05:47 -03:00
MorganandOmar López 4417d649ea refactor: remove @calcom/features dependency to @calcom/web (#13365)
* refactor: remove @calcom/features dependency to @calcom/web

* refactor: remove IS_VISUAL_REGRESSION_TESTING from web

* fix: wrong imports

---------

Co-authored-by: Omar López <zomars@me.com>
2024-01-23 17:17:36 +00:00
Alex van Andel 7058da70e6 v3.7.6
release.yml / release (push) Canceled after 0s
2024-01-23 14:10:48 +00:00
Alex van Andel 2cb383c337 fix: Remove type from import getServerSideProps 2024-01-23 14:09:59 +00:00
597 changed files with 13926 additions and 6983 deletions
+2
View File
@@ -236,6 +236,8 @@ CSP_POLICY=
EDGE_CONFIG=
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD=0 # Override the booker to only load X number of days worth of data
# Control time intervals on a user's Schedule availability
NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL=
+4
View File
@@ -0,0 +1,4 @@
Contact: security@cal.com
Preferred-Languages: en
Canonical: https://cal.com/.well-known/security.txt
Policy: https://github.com/calcom/cal.com/security/policy
@@ -0,0 +1,8 @@
diff --git a/plugin/timezone.js b/plugin/timezone.js
index fb6112a96f03f53ba78162ac323dce17f635161d..991e4e5410a32f5a69cb6e0e9476a9c529657aae 100644
--- a/plugin/timezone.js
+++ b/plugin/timezone.js
@@ -1 +1 @@
-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";var t={year:0,month:1,day:2,hour:3,minute:4,second:5},e={};return function(n,i,o){var r,a=function(t,n,i){void 0===i&&(i={});var o=new Date(t),r=function(t,n){void 0===n&&(n={});var i=n.timeZoneName||"short",o=t+"|"+i,r=e[o];return r||(r=new Intl.DateTimeFormat("en-US",{hour12:!1,timeZone:t,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:i}),e[o]=r),r}(n,i);return r.formatToParts(o)},u=function(e,n){for(var i=a(e,n),r=[],u=0;u<i.length;u+=1){var f=i[u],s=f.type,m=f.value,c=t[s];c>=0&&(r[c]=parseInt(m,10))}var d=r[3],l=24===d?0:d,v=r[0]+"-"+r[1]+"-"+r[2]+" "+l+":"+r[4]+":"+r[5]+":000",h=+e;return(o.utc(v).valueOf()-(h-=h%1e3))/6e4},f=i.prototype;f.tz=function(t,e){void 0===t&&(t=r);var n=this.utcOffset(),i=this.toDate(),a=i.toLocaleString("en-US",{timeZone:t}),u=Math.round((i-new Date(a))/1e3/60),f=o(a).$set("millisecond",this.$ms).utcOffset(15*-Math.round(i.getTimezoneOffset()/15)-u,!0);if(e){var s=f.utcOffset();f=f.add(n-s,"minute")}return f.$x.$timezone=t,f},f.offsetName=function(t){var e=this.$x.$timezone||o.tz.guess(),n=a(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var s=f.startOf;f.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return s.call(this,t,e);var n=o(this.format("YYYY-MM-DD HH:mm:ss:SSS"));return s.call(n,t,e).tz(this.$x.$timezone,!0)},o.tz=function(t,e,n){var i=n&&e,a=n||e||r,f=u(+o(),a);if("string"!=typeof t)return o(t).tz(a);var s=function(t,e,n){var i=t-60*e*1e3,o=u(i,n);if(e===o)return[i,e];var r=u(i-=60*(o-e)*1e3,n);return o===r?[i,o]:[t-60*Math.min(o,r)*1e3,Math.max(o,r)]}(o.utc(t,i).valueOf(),f,a),m=s[0],c=s[1],d=o(m).utcOffset(c);return d.$x.$timezone=a,d},o.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},o.tz.setDefault=function(t){r=t}}}));
\ No newline at end of file
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";function t(){return t=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var i in n)Object.prototype.hasOwnProperty.call(n,i)&&(t[i]=n[i])}return t},t.apply(this,arguments)}var e={year:0,month:1,day:2,hour:3,minute:4,second:5},n={year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"},i={},r={};return function(o,a,u){var f,s=function(e,r,o){void 0===o&&(o={});var a=new Date(e),u=function(e,r){void 0===r&&(r={});var o=r.timeZoneName||"short",a=e+"|"+o,u=i[a];return u||(u=new Intl.DateTimeFormat("en-US",t({},n,{hour12:!1,timeZone:e,timeZoneName:o})),i[a]=u),u}(r,o);return u.formatToParts(a)},m=function(t,n){for(var i=s(t,n),r=[],o=0;o<i.length;o+=1){var a=i[o],f=a.type,m=a.value,c=e[f];c>=0&&(r[c]=parseInt(m,10))}var l=r[3],d=24===l?0:l,h=r[0]+"-"+r[1]+"-"+r[2]+" "+d+":"+r[4]+":"+r[5]+":000",v=+t;return(u.utc(h).valueOf()-(v-=v%1e3))/6e4},c=a.prototype;c.tz=function(e,i){void 0===e&&(e=f);var o=this.utcOffset(),a=this.toDate(),s=function(e){var i=r[e];return i||(i=new Intl.DateTimeFormat("en-US",t({},n,{timeZone:e})),r[e]=i),i}(e).format(a),m=Math.round((a-new Date(s))/1e3/60),c=u(s,{locale:this.$L}).$set("millisecond",this.$ms).utcOffset(15*-Math.round(a.getTimezoneOffset()/15)-m,!0);if(i){var l=c.utcOffset();c=c.add(o-l,"minute")}return c.$x.$timezone=e,c},c.offsetName=function(t){var e=this.$x.$timezone||u.tz.guess(),n=s(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var l=c.startOf;c.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return l.call(this,t,e);var n=u(this.format("YYYY-MM-DD HH:mm:ss:SSS"),{locale:this.$L});return l.call(n,t,e).tz(this.$x.$timezone,!0)},u.tz=function(t,e,n){var i=n&&e,r=n||e||f,o=m(+u(),r);if("string"!=typeof t)return u(t).tz(r);var a=function(t,e,n){var i=t-60*e*1e3,r=m(i,n);if(e===r)return[i,e];var o=m(i-=60*(r-e)*1e3,n);return r===o?[i,r]:[t-60*Math.min(r,o)*1e3,Math.max(r,o)]}(u.utc(t,i).valueOf(),o,r),s=a[0],c=a[1],l=u(s).utcOffset(c);return l.$x.$timezone=r,l},u.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},u.tz.setDefault=function(t){f=t}}}));
@@ -0,0 +1,8 @@
diff --git a/plugin/timezone.js b/plugin/timezone.js
index fb6112a96f03f53ba78162ac323dce17f635161d..991e4e5410a32f5a69cb6e0e9476a9c529657aae 100644
--- a/plugin/timezone.js
+++ b/plugin/timezone.js
@@ -1 +1 @@
-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";var t={year:0,month:1,day:2,hour:3,minute:4,second:5},e={};return function(n,i,o){var r,a=function(t,n,i){void 0===i&&(i={});var o=new Date(t),r=function(t,n){void 0===n&&(n={});var i=n.timeZoneName||"short",o=t+"|"+i,r=e[o];return r||(r=new Intl.DateTimeFormat("en-US",{hour12:!1,timeZone:t,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:i}),e[o]=r),r}(n,i);return r.formatToParts(o)},u=function(e,n){for(var i=a(e,n),r=[],u=0;u<i.length;u+=1){var f=i[u],s=f.type,m=f.value,c=t[s];c>=0&&(r[c]=parseInt(m,10))}var d=r[3],l=24===d?0:d,v=r[0]+"-"+r[1]+"-"+r[2]+" "+l+":"+r[4]+":"+r[5]+":000",h=+e;return(o.utc(v).valueOf()-(h-=h%1e3))/6e4},f=i.prototype;f.tz=function(t,e){void 0===t&&(t=r);var n=this.utcOffset(),i=this.toDate(),a=i.toLocaleString("en-US",{timeZone:t}),u=Math.round((i-new Date(a))/1e3/60),f=o(a).$set("millisecond",this.$ms).utcOffset(15*-Math.round(i.getTimezoneOffset()/15)-u,!0);if(e){var s=f.utcOffset();f=f.add(n-s,"minute")}return f.$x.$timezone=t,f},f.offsetName=function(t){var e=this.$x.$timezone||o.tz.guess(),n=a(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var s=f.startOf;f.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return s.call(this,t,e);var n=o(this.format("YYYY-MM-DD HH:mm:ss:SSS"));return s.call(n,t,e).tz(this.$x.$timezone,!0)},o.tz=function(t,e,n){var i=n&&e,a=n||e||r,f=u(+o(),a);if("string"!=typeof t)return o(t).tz(a);var s=function(t,e,n){var i=t-60*e*1e3,o=u(i,n);if(e===o)return[i,e];var r=u(i-=60*(o-e)*1e3,n);return o===r?[i,o]:[t-60*Math.min(o,r)*1e3,Math.max(o,r)]}(o.utc(t,i).valueOf(),f,a),m=s[0],c=s[1],d=o(m).utcOffset(c);return d.$x.$timezone=a,d},o.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},o.tz.setDefault=function(t){r=t}}}));
\ No newline at end of file
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";function t(){return t=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var i in n)Object.prototype.hasOwnProperty.call(n,i)&&(t[i]=n[i])}return t},t.apply(this,arguments)}var e={year:0,month:1,day:2,hour:3,minute:4,second:5},n={year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"},i={},r={};return function(o,a,u){var f,s=function(e,r,o){void 0===o&&(o={});var a=new Date(e),u=function(e,r){void 0===r&&(r={});var o=r.timeZoneName||"short",a=e+"|"+o,u=i[a];return u||(u=new Intl.DateTimeFormat("en-US",t({},n,{hour12:!1,timeZone:e,timeZoneName:o})),i[a]=u),u}(r,o);return u.formatToParts(a)},m=function(t,n){for(var i=s(t,n),r=[],o=0;o<i.length;o+=1){var a=i[o],f=a.type,m=a.value,c=e[f];c>=0&&(r[c]=parseInt(m,10))}var l=r[3],d=24===l?0:l,h=r[0]+"-"+r[1]+"-"+r[2]+" "+d+":"+r[4]+":"+r[5]+":000",v=+t;return(u.utc(h).valueOf()-(v-=v%1e3))/6e4},c=a.prototype;c.tz=function(e,i){void 0===e&&(e=f);var o=this.utcOffset(),a=this.toDate(),s=function(e){var i=r[e];return i||(i=new Intl.DateTimeFormat("en-US",t({},n,{timeZone:e})),r[e]=i),i}(e).format(a),m=Math.round((a-new Date(s))/1e3/60),c=u(s,{locale:this.$L}).$set("millisecond",this.$ms).utcOffset(15*-Math.round(a.getTimezoneOffset()/15)-m,!0);if(i){var l=c.utcOffset();c=c.add(o-l,"minute")}return c.$x.$timezone=e,c},c.offsetName=function(t){var e=this.$x.$timezone||u.tz.guess(),n=s(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var l=c.startOf;c.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return l.call(this,t,e);var n=u(this.format("YYYY-MM-DD HH:mm:ss:SSS"),{locale:this.$L});return l.call(n,t,e).tz(this.$x.$timezone,!0)},u.tz=function(t,e,n){var i=n&&e,r=n||e||f,o=m(+u(),r);if("string"!=typeof t)return u(t).tz(r);var a=function(t,e,n){var i=t-60*e*1e3,r=m(i,n);if(e===r)return[i,e];var o=m(i-=60*(r-e)*1e3,n);return r===o?[i,r]:[t-60*Math.min(r,o)*1e3,Math.max(r,o)]}(u.utc(t,i).valueOf(),o,r),s=a[0],c=a[1],l=u(s).utcOffset(c);return l.$x.$timezone=r,l},u.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},u.tz.setDefault=function(t){f=t}}}));
+1 -1
View File
@@ -13,7 +13,7 @@ import type { User, UserList } from "../types/user";
import type { WorkingHours } from "../types/workingHours";
import now from "./now";
const gptModel = "gpt-4-0613";
const gptModel = "gpt-4-0125-preview";
/**
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
+7 -1
View File
@@ -13,6 +13,12 @@
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/types/window.d.ts"
],
"exclude": ["node_modules"]
}
+2 -2
View File
@@ -1,4 +1,4 @@
import * as Sentry from "@sentry/nextjs";
import { captureException as SentryCaptureException } from "@sentry/nextjs";
import type { NextMiddleware } from "next-api-middleware";
import { redactError } from "@calcom/lib/redactError";
@@ -9,7 +9,7 @@ export const captureErrors: NextMiddleware = async (_req, res, next) => {
// middleware and the API route handler
await next();
} catch (error) {
Sentry.captureException(error);
SentryCaptureException(error);
const redactedError = redactError(error);
if (redactedError instanceof Error) {
res.status(400).json({ message: redactedError.message, error: redactedError });
+6
View File
@@ -1,6 +1,7 @@
import type { NextMiddleware } from "next-api-middleware";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { API_KEY_RATE_LIMIT } from "@calcom/lib/rateLimit";
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
@@ -9,6 +10,11 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
await checkRateLimitAndThrowError({
identifier: req.query.apiKey as string,
rateLimitingType: "api",
onRateLimiterResponse: (response) => {
res.setHeader("X-RateLimit-Limit", API_KEY_RATE_LIMIT);
res.setHeader("X-RateLimit-Remaining", response.remaining);
res.setHeader("X-RateLimit-Reset", response.reset);
},
});
await next();
+1
View File
@@ -62,6 +62,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
assignAllTeamMembers: true,
})
.merge(
z.object({
@@ -0,0 +1,7 @@
import { z } from "zod";
import { baseApiParams } from "./baseApiParams";
export const schemaQuerySlug = baseApiParams.extend({
slug: z.string().optional(),
});
+1 -1
View File
@@ -1,5 +1,5 @@
import tzdata from "tzdata";
import * as z from "zod";
import { z } from "zod";
// @note: This is a custom validation that checks if the timezone is valid and exists in the tzdb library
export const timeZone = z.string().refine((tz: string) => Object.keys(tzdata.zones).includes(tz), {
@@ -51,7 +51,6 @@ export async function getHandler(req: NextApiRequest) {
include: {
customInputs: true,
team: { select: { slug: true } },
users: true,
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
+14 -3
View File
@@ -5,6 +5,7 @@ import { defaultResponder } from "@calcom/lib/server";
import type { PrismaClient } from "@calcom/prisma";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { schemaQuerySlug } from "~/lib/validations/shared/querySlug";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
import getCalLink from "./_utils/getCalLink";
@@ -22,6 +23,12 @@ import getCalLink from "./_utils/getCalLink";
* type: string
* required: true
* description: Your API Key
* - in: query
* name: slug
* schema:
* type: string
* required: false
* description: Slug to filter event types by
* tags:
* - event-types
* externalDocs:
@@ -35,16 +42,20 @@ import getCalLink from "./_utils/getCalLink";
* description: No event types were found
*/
async function getHandler(req: NextApiRequest) {
const { userId, prisma } = req;
const { userId, prisma, isAdmin } = req;
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
const { slug } = schemaQuerySlug.parse(req.query);
const shouldUseUserId = !isAdmin || !slug || !!req.query.userId;
// When user is admin and no query params are provided we should return all event types.
// But currently we return only the event types of the user. Not changing this for backwards compatibility.
const data = await prisma.eventType.findMany({
where: {
userId: { in: userIds },
userId: shouldUseUserId ? { in: userIds } : undefined,
slug: slug, // slug will be undefined if not provided in query
},
include: {
customInputs: true,
team: { select: { slug: true } },
users: true,
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
+72
View File
@@ -0,0 +1,72 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import type { TInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
import { ZInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
import type { UserProfile } from "@calcom/types/UserProfile";
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const data = ZInviteMemberInputSchema.parse(req.body);
await checkPermissions(req, data);
async function sessionGetter() {
return {
user: {
id: req.userId,
username: "",
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
hasValidLicense: true,
expires: "",
upId: "",
};
}
const ctx = await createContext({ req, res }, sessionGetter);
try {
const caller = viewerTeamsRouter.createCaller(ctx);
await caller.inviteMember({
role: data.role,
language: data.language,
teamId: data.teamId,
usernameOrEmail: data.usernameOrEmail,
isOrg: data.isOrg,
});
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
} catch (cause) {
if (cause instanceof TRPCError) {
const statusCode = getHTTPStatusCodeFromError(cause);
throw new HttpError({ statusCode, message: cause.message });
}
throw cause;
}
}
async function checkPermissions(req: NextApiRequest, body: TInviteMemberInputSchema) {
const { userId, isAdmin, prisma } = req;
if (isAdmin) return;
// To prevent auto-accepted invites, limit it to ADMIN users
if (!isAdmin && "accepted" in body)
throw new HttpError({ statusCode: 403, message: "ADMIN needed for `accepted`" });
// Only team OWNERS and ADMINS can add other members
const membership = await prisma.membership.findFirst({
where: { userId, teamId: body.teamId, role: { in: ["ADMIN", "OWNER"] } },
});
if (!membership) throw new HttpError({ statusCode: 403, message: "You can't add members to this team" });
}
export default defaultResponder(postHandler);
+9
View File
@@ -0,0 +1,9 @@
import { defaultHandler } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
export default withMiddleware()(
defaultHandler({
POST: import("./_post"),
})
);
+98
View File
@@ -0,0 +1,98 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import dayjs from "@calcom/dayjs";
import handler from "./_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
function buildMockData() {
prismock.user.create({
data: {
id: 1,
username: "test",
name: "Test User",
email: "test@example.com",
},
});
prismock.eventType.create({
data: {
id: 1,
slug: "test",
length: 30,
title: "Test Event Type",
userId: 1,
},
});
}
describe("GET /api/slots", () => {
describe("Errors", () => {
test("Missing required data", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
});
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
{
"message": "invalid_type in 'startTime': Required; invalid_type in 'endTime': Required",
}
`);
});
});
describe("Success", () => {
describe("Regular event-type", () => {
test("Returns and event type available slots", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
eventTypeId: 1,
startTime: dayjs().format(),
endTime: dayjs().add(1, "day").format(),
usernameList: "test",
},
prisma: prismock,
});
buildMockData();
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
{
"slots": {},
}
`);
});
test("Returns and event type available slots with passed timeZone", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
eventTypeId: 1,
startTime: dayjs().format(),
endTime: dayjs().add(1, "day").format(),
usernameList: "test",
timeZone: "UTC",
},
prisma: prismock,
});
buildMockData();
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
{
"slots": {},
}
`);
});
});
});
});
+1 -1
View File
@@ -38,7 +38,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
{}
)
: availableSlots;
: availableSlots.slots;
return { slots: slotsInProvidedTimeZone };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -59,7 +59,6 @@ async function getHandler(req: NextApiRequest) {
include: {
customInputs: true,
team: { select: { slug: true } },
users: true,
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
@@ -5,6 +5,7 @@ import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { createContext } from "@calcom/trpc/server/createContext";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import type { UserProfile } from "@calcom/types/UserProfile";
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
@@ -21,9 +22,17 @@ const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
id: req.userId,
username: "" /* Not used in this context */,
role: req.isAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER,
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
hasValidLicense: true /* To comply with TS signature */,
expires: "" /* Not used in this context */,
upId: "",
};
}
/** @see https://trpc.io/docs/server-side-calls */
+1 -1
View File
@@ -39,7 +39,7 @@ export async function deleteHandler(req: NextApiRequest) {
const { prisma, query } = req;
const { id } = schemaQueryIdAsString.parse(query);
await prisma.webhook.delete({ where: { id } });
return { message: `Schedule with id: ${id} deleted successfully` };
return { message: `Webhook with id: ${id} deleted successfully` };
}
export default defaultResponder(deleteHandler);
+2 -2
View File
@@ -1,5 +1,5 @@
import * as Sentry from "@sentry/nextjs";
import { init as SentryInit } from "@sentry/nextjs";
Sentry.init({
SentryInit({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { init as SentryInit } from "@sentry/nextjs";
Sentry.init({
SentryInit({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
});
+2 -3
View File
@@ -3,6 +3,8 @@ import type { NextMiddleware, NextRequest } from "next/server";
import { NextResponse, URLPattern } from "next/server";
import z from "zod";
import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants";
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
@@ -25,9 +27,6 @@ const ROUTES: [URLPattern, boolean][] = [
enabled,
]);
const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
export const abTestMiddlewareFactory =
+9 -4
View File
@@ -1,11 +1,16 @@
"use client";
import { type DehydratedState, Hydrate } from "@tanstack/react-query";
import { type DehydratedState, HydrationBoundary } from "@tanstack/react-query";
import { useMemo } from "react";
import type { DataTransformer } from "@trpc/server";
type DataTransformer = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serialize: (data: any) => any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deserialize: (data: any) => any;
};
export function createHydrateClient(opts: { transformer?: DataTransformer }) {
export function createHydrateClient(opts: { transformer: DataTransformer }) {
return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) {
const { state, children } = props;
@@ -16,6 +21,6 @@ export function createHydrateClient(opts: { transformer?: DataTransformer }) {
return state;
}, [state]);
return <Hydrate state={transformedState}>{children}</Hydrate>;
return <HydrationBoundary state={transformedState}>{children}</HydrationBoundary>;
};
}
+1 -4
View File
@@ -4,10 +4,7 @@ import { trpc } from "app/_trpc/client";
import { useState } from "react";
import superjson from "superjson";
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
import { httpLink } from "@calcom/trpc/client/links/httpLink";
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
import { splitLink } from "@calcom/trpc/client/links/splitLink";
import { httpBatchLink, httpLink, loggerLink, splitLink } from "@calcom/trpc/client";
import { ENDPOINTS } from "@calcom/trpc/react/shared";
export type Endpoint = (typeof ENDPOINTS)[number];
+22 -1
View File
@@ -1,12 +1,33 @@
import { type TFunction } from "i18next";
import i18next from "i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { headers } from "next/headers";
import { constructGenericImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
import { getFixedT } from "@calcom/lib/server/getFixedT";
//@ts-expect-error no type definitions
import config from "@calcom/web/next-i18next.config";
import { preparePageMetadata } from "@lib/metadata";
const create = async (locale: string, ns: string) => {
const { _nextI18Next } = await serverSideTranslations(locale, [ns], config);
const _i18n = i18next.createInstance();
_i18n.init({
lng: locale,
resources: _nextI18Next?.initialI18nStore,
fallbackLng: _nextI18Next?.userConfig?.i18n.defaultLocale,
});
return _i18n;
};
const getFixedT = async (locale: string, ns: string) => {
const i18n = await create(locale, ns);
return i18n.getFixedT(locale, ns);
};
export const _generateMetadata = async (
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string
@@ -0,0 +1,12 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import LegacyPage from "~/users/views/users-type-public-view";
import { getServerSideProps, type PageProps } from "~/users/views/users-type-public-view.getServerSideProps";
const getData = withAppDirSsr<PageProps>(getServerSideProps);
const getEmbedData = withEmbedSsrAppDir(getData);
export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">;
@@ -0,0 +1,45 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { headers, cookies } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import LegacyPage from "~/users/views/users-type-public-view";
import { getServerSideProps, type PageProps } from "~/users/views/users-type-public-view.getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Record<string, string | string[]>;
searchParams: { [key: string]: string | string[] | undefined };
}) => {
const props = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams) as unknown as GetServerSidePropsContext
);
const { eventData, booking, user, slug } = props;
const rescheduleUid = booking?.uid;
const { trpc } = await import("@calcom/trpc");
const { data: event } = trpc.viewer.public.event.useQuery(
{ username: user, eventSlug: slug, isTeamEvent: false, org: eventData.entity.orgSlug ?? null },
{ refetchOnWindowFocus: false }
);
const profileName = event?.profile?.name ?? "";
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({
getData,
Page: LegacyPage,
getLayout: null,
})<"P">;
+12
View File
@@ -0,0 +1,12 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import LegacyPage from "~/users/views/users-public-view";
import { getServerSideProps, type UserPageProps } from "~/users/views/users-public-view.getServerSideProps";
const getData = withAppDirSsr<UserPageProps>(getServerSideProps);
const getEmbedData = withEmbedSsrAppDir(getData);
export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">;
+33
View File
@@ -0,0 +1,33 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { headers, cookies } from "next/headers";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import LegacyPage from "~/users/views/users-public-view";
import { getServerSideProps, type UserPageProps } from "~/users/views/users-public-view.getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Record<string, string | string[]>;
searchParams: { [key: string]: string | string[] | undefined };
}) => {
const props = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams) as unknown as GetServerSidePropsContext
);
const { profile, markdownStrippedBio } = props;
return await _generateMetadata(
() => profile.name,
() => markdownStrippedBio
);
};
const getData = withAppDirSsr<UserPageProps>(getServerSideProps);
export default WithLayout({ getLayout, getData, Page: LegacyPage })<"P">;
+12
View File
@@ -0,0 +1,12 @@
import Setup from "@pages/auth/setup";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { WithLayout } from "app/layoutHOC";
import type { InferGetServerSidePropsType } from "next";
import { getServerSideProps } from "@server/lib/setup/getServerSideProps";
export default WithLayout({
getLayout: null,
Page: Setup,
getData: withAppDirSsr<InferGetServerSidePropsType<typeof getServerSideProps>>(getServerSideProps),
})<"P">;
@@ -1,9 +1,10 @@
import { type PageProps, getServerSideProps } from "@pages/booking/[uid]";
import OldPage from "@pages/booking/[uid]/embed";
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import OldPage from "~/bookings/views/bookings-single-view";
import { getServerSideProps, type PageProps } from "~/bookings/views/bookings-single-view.getServerSideProps";
const getData = withAppDirSsr<PageProps>(getServerSideProps);
const getEmbedData = withEmbedSsrAppDir(getData);
+3 -2
View File
@@ -1,4 +1,3 @@
import OldPage, { type PageProps } from "@pages/booking/[uid]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { Params, SearchParams } from "app/_types";
import { _generateMetadata } from "app/_utils";
@@ -8,9 +7,11 @@ import { cookies, headers } from "next/headers";
import { BookingStatus } from "@calcom/prisma/enums";
import { getServerSideProps } from "@lib/booking/[uid]/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import OldPage from "~/bookings/views/bookings-single-view";
import { getServerSideProps, type PageProps } from "~/bookings/views/bookings-single-view.getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
@@ -1,4 +1,3 @@
import Page from "@pages/bookings/[status]";
import { withAppDirSsg } from "app/WithAppDirSsg";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
@@ -7,9 +6,9 @@ import type { InferGetStaticPropsType } from "next";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { APP_NAME } from "@calcom/lib/constants";
import { getStaticProps } from "@lib/bookings/[status]/getStaticProps";
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
import { validStatuses } from "~/bookings/lib/validStatuses";
import Page from "~/bookings/views/bookings-listing-view";
import { getStaticProps } from "~/bookings/views/bookings-listing-view.getStaticProps";
type Y = InferGetStaticPropsType<typeof getStaticProps>;
const getData = withAppDirSsg<Y>(getStaticProps);
@@ -0,0 +1,32 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { Params, SearchParams } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import EventTypePageWrapper from "~/event-types/views/event-types-single-view";
import { getServerSideProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { eventType } = await getData(legacyCtx);
return await _generateMetadata(
(t) => `${eventType.title} | ${t("event_type")} | ${APP_NAME}`,
() => ""
);
};
const getData = withAppDirSsr(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page: EventTypePageWrapper })<"P">;
+2 -1
View File
@@ -1,9 +1,10 @@
import EventTypes from "@pages/event-types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import EventTypes from "~/event-types/views/event-types-listing-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("event_types_page_title"),
+2 -1
View File
@@ -1,10 +1,11 @@
import LegacyPage, { getServerSideProps } from "@pages/insights/index";
import LegacyPage from "@pages/insights/index";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
export const generateMetadata = async () =>
@@ -0,0 +1,41 @@
import { type PageProps } from "@pages/team/[slug]";
import EmbedPage from "@pages/team/[slug]/embed";
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { Params, SearchParams } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) => {
const props = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams) as unknown as GetServerSidePropsContext
);
const teamName = props.team.name || "Nameless Team";
return await _generateMetadata(
() => teamName,
() => teamName
);
};
const getEmbedData = withEmbedSsrAppDir(getData);
export default WithLayout({
Page: EmbedPage,
getData: getEmbedData,
getLayout: null,
isBookingPage: true,
})<"P">;
@@ -0,0 +1,37 @@
import TeamPage, { type PageProps } from "@pages/team/[slug]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { Params, SearchParams } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) => {
const props = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams) as unknown as GetServerSidePropsContext
);
const teamName = props.team.name || "Nameless Team";
return await _generateMetadata(
() => teamName,
() => teamName
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({
Page: TeamPage,
getData,
getLayout: null,
isBookingPage: true,
})<"P">;
@@ -0,0 +1,43 @@
import TypePage, { type PageProps } from "@pages/team/[slug]/[type]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { Params, SearchParams } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx as unknown as GetServerSidePropsContext);
const { entity, user, slug, booking } = props;
const { trpc } = await import("@calcom/trpc");
const { data: event } = trpc.viewer.public.event.useQuery(
{ username: user, eventSlug: slug, isTeamEvent: false, org: entity.orgSlug ?? null },
{ refetchOnWindowFocus: false }
);
const profileName = event?.profile?.name ?? "";
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${booking?.uid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${booking?.uid ? t("reschedule") : ""} ${title}`
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({
Page: TypePage,
getData,
getLayout: null,
isBookingPage: true,
})<"P">;
@@ -0,0 +1,37 @@
import TeamPage, { type PageProps } from "@pages/team/[slug]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { Params, SearchParams } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Params;
searchParams: SearchParams;
}) => {
const props = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams) as unknown as GetServerSidePropsContext
);
const teamName = props.team.name || "Nameless Team";
return await _generateMetadata(
() => teamName,
() => teamName
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({
Page: TeamPage,
getData,
getLayout: null,
isBookingPage: true,
})<"P">;
+4 -4
View File
@@ -1,11 +1,11 @@
import Page, { type JoinCallPageProps } from "@pages/video/[uid]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { APP_NAME } from "@calcom/lib/constants";
import { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
import VideosSingleView from "~/videos/views/videos-single-view";
import { getServerSideProps, type PageProps } from "~/videos/views/videos-single-view.getServerSideProps";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -13,6 +13,6 @@ export const generateMetadata = async () =>
(t) => t("quick_video_meeting")
);
const getData = withAppDirSsr<JoinCallPageProps>(getServerSideProps);
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getData, Page, getLayout: null })<"P">;
export default WithLayout({ getData, Page: VideosSingleView, getLayout: null })<"P">;
@@ -1,9 +1,9 @@
import Page from "@pages/video/meeting-ended/[uid]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/video/meeting-ended/[uid]/getServerSideProps";
import MeetingEnded from "~/videos/views/videos-meeting-ended-single-view";
import { getServerSideProps } from "~/videos/views/videos-meeting-ended-single-view.getServerSideProps";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -13,4 +13,4 @@ export const generateMetadata = async () =>
const getData = withAppDirSsr(getServerSideProps);
export default WithLayout({ getData, Page, getLayout: null })<"P">;
export default WithLayout({ getData, Page: MeetingEnded, getLayout: null })<"P">;
@@ -1,4 +1,3 @@
import Page from "@pages/video/meeting-not-started/[uid]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
@@ -6,7 +5,8 @@ import { WithLayout } from "app/layoutHOC";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { getServerSideProps } from "@lib/video/meeting-not-started/[uid]/getServerSideProps";
import MeetingNotStarted from "~/videos/views/videos-meeting-not-started-single-view";
import { getServerSideProps } from "~/videos/views/videos-meeting-not-started-single-view.getServerSideProps";
export const generateMetadata = async ({ params }: PageProps) => {
const booking = await prisma.booking.findUnique({
@@ -24,4 +24,4 @@ export const generateMetadata = async ({ params }: PageProps) => {
const getData = withAppDirSsr(getServerSideProps);
export default WithLayout({ getData, Page, getLayout: null })<"P">;
export default WithLayout({ getData, Page: MeetingNotStarted, getLayout: null })<"P">;
@@ -1,10 +1,11 @@
import LegacyPage from "@pages/video/no-meeting-found";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsContext } from "next";
import { ssrInit } from "@server/lib/ssr";
import NoMeetingFound from "~/videos/views/videos-no-meeting-found-single-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("no_meeting_found"),
@@ -20,4 +21,4 @@ const getData = async (context: GetServerSidePropsContext) => {
};
};
export default WithLayout({ getData, Page: LegacyPage, getLayout: null })<"P">;
export default WithLayout({ getData, Page: NoMeetingFound, getLayout: null })<"P">;
@@ -16,10 +16,10 @@ import { Plus } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
interface AdditionalCalendarSelectorProps {
isLoading?: boolean;
isPending?: boolean;
}
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
const AdditionalCalendarSelector = ({ isPending }: AdditionalCalendarSelectorProps): JSX.Element | null => {
const { t } = useLocale();
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
@@ -42,7 +42,7 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={Plus} color="secondary" {...(isLoading && { loading: isLoading })}>
<Button StartIcon={Plus} color="secondary" {...(isPending && { loading: isPending })}>
{t("add")}
</Button>
</DropdownMenuTrigger>
+18 -16
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import type { IframeHTMLAttributes } from "react";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
@@ -92,17 +92,19 @@ export const AppPage = ({
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
{ appType: type },
{
onSettled(data) {
const credentialsCount = data?.credentials.length || 0;
setShowDisconnectIntegration(
data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0
);
setExistingCredentials(data?.credentials.map((credential) => credential.id) || []);
},
}
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery({ appType: type });
useEffect(
function refactorMeWithoutEffect() {
const data = appDbQuery.data;
const credentialsCount = data?.credentials.length || 0;
setShowDisconnectIntegration(
data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0
);
setExistingCredentials(data?.credentials.map((credential) => credential.id) || []);
},
[appDbQuery.data]
);
const dependencyData = trpc.viewer.appsRouter.queryForDependencies.useQuery(dependencies, {
@@ -190,7 +192,7 @@ export const AppPage = ({
)}
</header>
</div>
{!appDbQuery.isLoading ? (
{!appDbQuery.isPending ? (
isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3">
@@ -211,7 +213,7 @@ export const AppPage = ({
onClick: () => {
mutation.mutate({ type, variant, slug });
},
loading: mutation.isLoading,
loading: mutation.isPending,
};
}
return (
@@ -250,7 +252,7 @@ export const AppPage = ({
onClick: () => {
mutation.mutate({ type, variant, slug });
},
loading: mutation.isLoading,
loading: mutation.isPending,
};
}
return (
@@ -272,7 +274,7 @@ export const AppPage = ({
)}
{dependencies &&
(!dependencyData.isLoading ? (
(!dependencyData.isPending ? (
<div className="mt-6">
<AppDependencyComponent appName={name} dependencyData={dependencyData.data} />
</div>
@@ -28,7 +28,7 @@ type Props = {
onChanged: () => unknown | Promise<unknown>;
fromOnboarding?: boolean;
destinationCalendarId?: string;
isLoading?: boolean;
isPending?: boolean;
};
function CalendarList(props: Props) {
@@ -74,7 +74,7 @@ function ConnectedCalendarsList(props: Props) {
suspense: true,
refetchOnWindowFocus: false,
});
const { fromOnboarding, isLoading } = props;
const { fromOnboarding, isPending } = props;
return (
<QueryCell
query={query}
@@ -97,7 +97,7 @@ function ConnectedCalendarsList(props: Props) {
<div className="flex flex-col xl:flex-row xl:space-x-5">
{!!data.connectedCalendars.length && (
<div className="flex items-center">
<AdditionalCalendarSelector isLoading={isLoading} />
<AdditionalCalendarSelector isPending={isPending} />
</div>
)}
</div>
@@ -229,7 +229,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
hidePlaceholder
value={data.destinationCalendar?.externalId}
onChange={mutation.mutate}
isLoading={mutation.isLoading}
isPending={mutation.isPending}
/>
</div>
</div>
@@ -239,7 +239,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
isLoading={mutation.isLoading}
isPending={mutation.isPending}
/>
</>
)}
@@ -100,9 +100,9 @@ export const InstallAppButtonChild = ({
<DropdownMenuContent
className="w-auto"
onInteractOutside={(event) => {
if (mutation.isLoading) event.preventDefault();
if (mutation.isPending) event.preventDefault();
}}>
{mutation.isLoading && (
{mutation.isPending && (
<div className="z-1 fixed inset-0 flex items-center justify-center">
<Spinner />
</div>
@@ -139,7 +139,7 @@ function BookingListItem(booking: BookingItemProps) {
setRejectionDialogIsOpen(true);
},
icon: Ban,
disabled: mutation.isLoading,
disabled: mutation.isPending,
},
// For bookings with payment, only confirm if the booking is paid for
...((isPending && !paymentAppData.enabled) ||
@@ -153,7 +153,7 @@ function BookingListItem(booking: BookingItemProps) {
bookingConfirm(true);
},
icon: Check,
disabled: mutation.isLoading,
disabled: mutation.isPending,
},
]
: []),
@@ -293,7 +293,7 @@ function BookingListItem(booking: BookingItemProps) {
setViewRecordingsDialogIsOpen(true);
},
color: showCheckRecordingButton ? "secondary" : "primary",
disabled: mutation.isLoading,
disabled: mutation.isPending,
},
];
@@ -348,7 +348,7 @@ function BookingListItem(booking: BookingItemProps) {
<DialogFooter>
<DialogClose />
<Button
disabled={mutation.isLoading}
disabled={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
@@ -65,7 +65,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
<DialogClose />
<Button
data-testid="send_request"
disabled={chargeCardMutation.isLoading || chargeError}
disabled={chargeCardMutation.isPending || chargeError}
onClick={() =>
chargeCardMutation.mutate({
bookingId,
@@ -27,7 +27,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
const [rescheduleReason, setRescheduleReason] = useState("");
const { mutate: rescheduleApi, isLoading } = trpc.viewer.bookings.requestReschedule.useMutation({
const { mutate: rescheduleApi, isPending } = trpc.viewer.bookings.requestReschedule.useMutation({
async onSuccess() {
showToast(t("reschedule_request_sent"), "success");
setIsOpenDialog(false);
@@ -65,7 +65,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
<DialogClose />
<Button
data-testid="send_request"
disabled={isLoading}
disabled={isPending}
onClick={() => {
rescheduleApi({
bookingId,
@@ -21,6 +21,7 @@ import type { EditableSchema } from "@calcom/features/form-builder/schema";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import { classNames } from "@calcom/lib";
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
@@ -35,7 +36,6 @@ import {
Tooltip,
} from "@calcom/ui";
import { Copy, Edit, Info } from "@calcom/ui/components/icon";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -55,8 +55,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const { t } = useLocale();
const [showEventNameTip, setShowEventNameTip] = useState(false);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!formMethods.getValues("hashedLink"));
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
const bookingFields: Prisma.JsonObject = {};
@@ -66,27 +66,31 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
user?.theme === "dark" ||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
eventType.bookingFields.forEach(({ name }) => {
formMethods.getValues().bookingFields.forEach(({ name }) => {
bookingFields[name] = `${name} input`;
});
const eventNameObject: EventNameObjectType = {
attendeeName: t("scheduler"),
eventType: eventType.title,
eventName: eventType.eventName,
host: eventType.users[0]?.name || "Nameless",
eventType: formMethods.getValues("title"),
eventName: formMethods.getValues("eventName"),
host: formMethods.getValues("users")[0]?.name || "Nameless",
bookingFields: bookingFields,
t,
};
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
const [requiresConfirmation, setRequiresConfirmation] = useState(
formMethods.getValues("requiresConfirmation")
);
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const noShowFeeEnabled = eventType.metadata?.apps?.stripe?.paymentOption === "HOLD";
const noShowFeeEnabled =
formMethods.getValues("metadata")?.apps?.stripe?.enabled === true &&
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
useEffect(() => {
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
}, [eventType.users, hashedUrl, team?.id]);
!hashedUrl && setHashedUrl(generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id));
}, [formMethods.getValues("users"), hashedUrl, team?.id]);
const toggleGuests = (enabled: boolean) => {
const bookingFields = formMethods.getValues("bookingFields");
@@ -108,7 +112,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
};
const { shouldLockDisableProps } = useLockedFieldsManager(
eventType,
formMethods.getValues(),
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
@@ -122,7 +126,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const closeEventNameTip = () => setShowEventNameTip(false);
const setEventName = (value: string) => formMethods.setValue("eventName", value);
return (
<div className="flex flex-col space-y-4">
{/**
@@ -145,12 +148,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</Link>
</div>
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
destinationCalendar={eventType.destinationCalendar}
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
@@ -167,7 +167,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
@@ -194,7 +193,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
locations: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
},
}}
/>
@@ -203,21 +202,20 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
metadata={eventType.metadata}
metadata={formMethods.getValues("metadata")}
requiresConfirmation={requiresConfirmation}
onRequiresConfirmation={setRequiresConfirmation}
/>
<Controller
name="requiresBookerEmailVerification"
control={formMethods.control}
defaultValue={eventType.requiresBookerEmailVerification}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("requires_booker_email_verification")}
data-testid="requires-booker-email-verification"
{...shouldLockDisableProps("requiresBookerEmailVerification")}
description={t("description_requires_booker_email_verification")}
checked={value}
@@ -228,14 +226,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<Controller
name="hideCalendarNotes"
control={formMethods.control}
defaultValue={eventType.hideCalendarNotes}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_notes")}
data-testid="disable-notes"
{...shouldLockDisableProps("hideCalendarNotes")}
description={t("disable_notes_description")}
checked={value}
@@ -246,7 +243,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<Controller
name="successRedirectUrl"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
@@ -258,6 +254,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
childrenClassName="lg:ml-0"
title={t("redirect_success_booking")}
data-testid="redirect-success-booking"
{...successRedirectUrlLocked}
description={t("redirect_url_description")}
checked={redirectUrlVisible}
@@ -272,16 +269,17 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
labelSrOnly
disabled={successRedirectUrlLocked.disabled}
placeholder={t("external_redirect_url")}
data-testid="external-redirect-url"
required={redirectUrlVisible}
type="text"
defaultValue={eventType.successRedirectUrl || ""}
{...formMethods.register("successRedirectUrl")}
/>
<div
className={classNames(
"p-1 text-sm text-orange-600",
formMethods.getValues("successRedirectUrl") ? "block" : "hidden"
)}>
)}
data-testid="redirect-url-warning">
{t("redirect_url_warning")}
</div>
</div>
@@ -302,6 +300,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
title={t("enable_private_url")}
Badge={
<a
data-testid="hashedLinkCheck-info"
target="_blank"
rel="noreferrer"
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
@@ -327,7 +326,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
hint={t("private_link_hint")}
defaultValue={placeholderHashedLink}
addOnSuffix={
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
<Tooltip
content={
formMethods.getValues("hashedLink") ? t("copy_to_clipboard") : t("enabled_after_update")
}>
<Button
color="minimal"
size="sm"
@@ -336,7 +338,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
if (formMethods.getValues("hashedLink")) {
showToast(t("private_link_copied"), "success");
} else {
showToast(t("enabled_after_update_description"), "warning");
@@ -353,8 +355,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<Controller
name="seatsPerTimeSlotEnabled"
control={formMethods.control}
defaultValue={!!eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
@@ -378,7 +378,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
formMethods.setValue("requiresConfirmation", false);
setRequiresConfirmation(false);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("seatsPerTimeSlot", 2);
formMethods.setValue("seatsPerTimeSlot", eventType.seatsPerTimeSlot ?? 2);
} else {
formMethods.setValue("seatsPerTimeSlot", null);
toggleGuests(true);
@@ -388,8 +388,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div>
<TextField
@@ -399,30 +397,40 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
label={t("number_of_seats")}
type="number"
disabled={seatsLocked.disabled}
defaultValue={value || 2}
defaultValue={value}
min={1}
containerClassName="max-w-80"
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
data-testid="seats-per-time-slot"
/>
<div className="mt-4">
<CheckboxField
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
<Controller
name="seatsShowAttendees"
render={({ field: { value, onChange } }) => (
<CheckboxField
data-testid="show-attendees"
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => onChange(e)}
checked={value}
/>
)}
/>
</div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) =>
formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)
}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
<Controller
name="seatsShowAvailabilityCount"
render={({ field: { value, onChange } }) => (
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) => onChange(e)}
checked={value}
/>
)}
/>
</div>
</div>
@@ -436,8 +444,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
<Controller
name="lockTimeZoneToggleOnBookingPage"
control={formMethods.control}
defaultValue={eventType.lockTimeZoneToggleOnBookingPage}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
@@ -448,63 +454,52 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
description={t("description_lock_timezone_toggle_on_booking_page")}
checked={value}
onCheckedChange={(e) => onChange(e)}
data-testid="lock-timezone-toggle"
/>
)}
/>
{allowDisablingAttendeeConfirmationEmails(workflows) && (
<>
<Controller
name="metadata.disableStandardEmails.confirmation.attendee"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_attendees_confirmation_emails")}
description={t("disable_attendees_confirmation_emails_description")}
checked={value || false}
onCheckedChange={(e) => {
formMethods.setValue("metadata.disableStandardEmails.confirmation.attendee", e);
onChange(e);
}}
/>
</>
)}
/>
</>
<Controller
name="metadata.disableStandardEmails.confirmation.attendee"
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_attendees_confirmation_emails")}
description={t("disable_attendees_confirmation_emails_description")}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
</>
)}
/>
)}
{allowDisablingHostConfirmationEmails(workflows) && (
<>
<Controller
name="metadata.disableStandardEmails.confirmation.host"
control={formMethods.control}
defaultValue={!!eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_host_confirmation_emails")}
description={t("disable_host_confirmation_emails_description")}
checked={value || false}
onCheckedChange={(e) => {
formMethods.setValue("metadata.disableStandardEmails.confirmation.host", e);
onChange(e);
}}
/>
</>
)}
/>
</>
<Controller
name="metadata.disableStandardEmails.confirmation.host"
defaultValue={!!formMethods.getValues("seatsPerTimeSlot")}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_host_confirmation_emails")}
description={t("disable_host_confirmation_emails_description")}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
</>
)}
/>
)}
{showEventNameTip && (
<CustomEventTypeModal
close={closeEventNameTip}
setValue={setEventName}
defaultValue={formMethods.getValues("eventName") || eventType.eventName || ""}
setValue={(val: string) => formMethods.setValue("eventName", val)}
defaultValue={formMethods.getValues("eventName")}
placeHolder={eventNamePlaceholder}
event={eventNameObject}
/>
+9 -10
View File
@@ -18,21 +18,21 @@ export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
const { t } = useLocale();
const { data: eventTypeApps, isLoading } = trpc.viewer.integrations.useQuery({
const { data: eventTypeApps, isPending } = trpc.viewer.integrations.useQuery({
extendsFeature: "EventType",
teamId: eventType.team?.id || eventType.parent?.teamId,
});
const methods = useFormContext<FormValues>();
const formMethods = useFormContext<FormValues>();
const installedApps =
eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || [];
const notInstalledApps =
eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || [];
const allAppsData = methods.watch("metadata")?.apps || {};
const allAppsData = formMethods.watch("metadata")?.apps || {};
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
methods.setValue("metadata", {
...methods.getValues("metadata"),
formMethods.setValue("metadata", {
...formMethods.getValues("metadata"),
apps: _allAppsData,
});
};
@@ -47,7 +47,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
};
};
const eventTypeFormMetadata = methods.getValues("metadata");
const eventTypeFormMetadata = formMethods.getValues("metadata");
const getAppDataSetter = (
appId: EventTypeAppsList,
@@ -56,8 +56,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {};
const appData = allAppsDataFromForm[appId];
setAllAppsData({
...allAppsDataFromForm,
@@ -138,7 +137,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
message={t("locked_apps_description")}
/>
)}
{!isLoading && !installedApps?.length ? (
{!isPending && !installedApps?.length ? (
<EmptyScreen
Icon={Grid}
headline={t("empty_installed_apps_headline")}
@@ -179,7 +178,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</div>
{!shouldLockDisableProps("apps").disabled && (
<div className="bg-muted mt-6 rounded-md p-8">
{!isLoading && notInstalledApps?.length ? (
{!isPending && notInstalledApps?.length ? (
<>
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
{t("available_apps_lower_case")}
@@ -85,7 +85,7 @@ const EventTypeScheduleDetails = memo(
const { watch } = useFormContext<FormValues>();
const scheduleId = watch("schedule");
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
const { isPending, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{
scheduleId:
scheduleId || loggedInUser?.defaultScheduleId || selectedScheduleValue?.value || undefined,
@@ -112,7 +112,7 @@ const EventTypeScheduleDetails = memo(
)}>
{day}
</span>
{isLoading ? (
{isPending ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
@@ -142,7 +142,7 @@ const EventTypeScheduleDetails = memo(
{!!schedule?.id && !schedule.isManaged && !schedule.readOnly && (
<Button
href={`/availability/${schedule.id}`}
disabled={isLoading}
disabled={isPending}
color="minimal"
EndIcon={ExternalLink}
target="_blank"
@@ -169,8 +169,15 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
const watchSchedule = watch("schedule");
const [options, setOptions] = useState<AvailabilityOption[]>([]);
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
onSuccess: ({ schedules }) => {
const { data, isPending } = trpc.viewer.availability.list.useQuery(undefined);
useEffect(
function refactorMeWithoutEffect() {
if (!data) {
return;
}
const schedules = data.schedules;
const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
@@ -225,8 +232,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
setValue("availability", value);
},
});
[data]
);
const availabilityValue = watch("availability");
useEffect(() => {
@@ -241,8 +248,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
{t("availability")}
{shouldLockIndicator("availability")}
</label>
{isLoading && <SelectSkeletonLoader />}
{!isLoading && (
{isPending && <SelectSkeletonLoader />}
{!isPending && (
<Controller
name="schedule"
render={({ field }) => {
@@ -1,10 +1,10 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as RadioGroup from "@radix-ui/react-radio-group";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import type { FormValues } from "pages/event-types/[type]";
import type { Key } from "react";
import React, { useEffect, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import type { SingleValue } from "react-select";
import { classNames } from "@calcom/lib";
@@ -107,7 +107,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
);
});
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
export const EventLimitsTab = () => {
const { t, i18n } = useLocale();
const formMethods = useFormContext<FormValues>();
@@ -126,47 +126,29 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
},
];
const periodType =
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const watchPeriodType = useWatch({
control: formMethods.control,
name: "periodType",
defaultValue: periodType?.type,
});
const watchPeriodType = formMethods.watch("periodType");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
{ value: 1, label: t("calendar_days") },
];
// offsetStart toggle is client-side only, opened by default if offsetStart is set
const offsetStartValue = useWatch({
control: formMethods.control,
name: "offsetStart",
});
const [offsetToggle, setOffsetToggle] = useState(() => offsetStartValue > 0);
const [offsetToggle, setOffsetToggle] = useState(formMethods.getValues("offsetStart") > 0);
// Preview how the offset will affect start times
const watchOffsetStartValue = formMethods.watch("offsetStart");
const offsetOriginalTime = new Date();
offsetOriginalTime.setHours(9, 0, 0, 0);
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + watchOffsetStartValue * 60 * 1000);
return (
<div>
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
<Label htmlFor="beforeEventBuffer">{t("before_event")}</Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
defaultValue={eventType.beforeEventBuffer || 0}
name="beforeEventBuffer"
render={({ field: { onChange, value } }) => {
const beforeBufferOptions = [
{
@@ -194,11 +176,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
<Label htmlFor="afterEventBuffer">{t("after_event")}</Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
defaultValue={eventType.afterEventBuffer || 0}
name="afterEventBuffer"
render={({ field: { onChange, value } }) => {
const afterBufferOptions = [
{
@@ -235,7 +215,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
<Controller
name="slotInterval"
control={formMethods.control}
render={() => {
const slotIntervalOptions = [
{
@@ -254,8 +233,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
}}
defaultValue={
slotIntervalOptions.find((option) => option.value === eventType.slotInterval) ||
slotIntervalOptions[0]
slotIntervalOptions.find(
(option) => option.value === formMethods.getValues("slotInterval")
) || slotIntervalOptions[0]
}
options={slotIntervalOptions}
/>
@@ -267,7 +247,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
<Controller
name="bookingLimits"
control={formMethods.control}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
@@ -300,7 +279,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
<Controller
name="onlyShowFirstAvailableSlot"
control={formMethods.control}
render={({ field: { value } }) => {
const isChecked = value;
return (
@@ -323,7 +301,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
<Controller
name="durationLimits"
control={formMethods.control}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
@@ -361,7 +338,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
<Controller
name="periodType"
control={formMethods.control}
render={({ field: { value } }) => {
const isChecked = value && value !== "UNLIMITED";
@@ -380,7 +356,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.map((period) => {
@@ -408,22 +383,23 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
onChange={(opt) =>
formMethods.setValue("periodCountCalendarDays", opt?.value === 1 ? "1" : "0")
}
name="periodCoundCalendarDays"
value={optionsPeriod.find((opt) => {
opt.value ===
(formMethods.getValues("periodCountCalendarDays") === "1" ? 1 : 0);
})}
defaultValue={optionsPeriod.find(
(opt) =>
opt.value ===
(formMethods.getValues("periodCountCalendarDays") === "1" ? 1 : 0)
)}
/>
</div>
)}
@@ -431,8 +407,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
@@ -481,7 +455,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
type="number"
containerClassName="max-w-80"
label={t("offset_start")}
{...formMethods.register("offsetStart")}
{...formMethods.register("offsetStart", { setValueAs: (value) => Number(value) })}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
@@ -9,9 +9,5 @@ export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "even
const requirePayment = paymentAppData.price > 0;
return (
<div className="">
<RecurringEventController paymentEnabled={requirePayment} eventType={eventType} />
</div>
);
return <RecurringEventController paymentEnabled={requirePayment} eventType={eventType} />;
};
+27 -42
View File
@@ -48,21 +48,19 @@ const getLocationFromType = (
}
};
const getLocationInfo = (props: Pick<EventTypeSetupProps, "eventType" | "locationOptions">) => {
const getLocationInfo = ({
eventType,
locationOptions,
}: Pick<EventTypeSetupProps, "eventType" | "locationOptions">) => {
const locationAvailable =
props.eventType.locations &&
props.eventType.locations.length > 0 &&
props.locationOptions.some((op) =>
op.options.find((opt) => opt.value === props.eventType.locations[0].type)
);
const locationDetails = props.eventType.locations &&
props.eventType.locations.length > 0 &&
eventType.locations &&
eventType.locations.length > 0 &&
locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type));
const locationDetails = eventType.locations &&
eventType.locations.length > 0 &&
!locationAvailable && {
slug: props.eventType.locations[0].type
.replace("integrations:", "")
.replace(":", "-")
.replace("_video", ""),
name: props.eventType.locations[0].type
slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""),
name: eventType.locations[0].type
.replace("integrations:", "")
.replace(":", " ")
.replace("_video", "")
@@ -72,16 +70,11 @@ const getLocationInfo = (props: Pick<EventTypeSetupProps, "eventType" | "locatio
};
return { locationAvailable, locationDetails };
};
interface DescriptionEditorProps {
description?: string | null;
editable?: boolean;
}
const DescriptionEditor = (props: DescriptionEditorProps) => {
const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
const formMethods = useFormContext<FormValues>();
const [mounted, setIsMounted] = useState(false);
const { t } = useLocale();
const { description } = props;
const [firstRender, setFirstRender] = useState(true);
useEffect(() => {
setIsMounted(true);
@@ -89,11 +82,11 @@ const DescriptionEditor = (props: DescriptionEditorProps) => {
return mounted ? (
<Editor
getText={() => md.render(formMethods.getValues("description") || description || "")}
getText={() => md.render(formMethods.getValues("description") || "")}
setText={(value: string) => formMethods.setValue("description", turndown(value))}
excludedToolbarItems={["blockType"]}
placeholder={t("quick_video_meeting")}
editable={props.editable}
editable={isEditable}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
@@ -113,7 +106,9 @@ export const EventSetupTab = (
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType, team, destinationCalendar } = props;
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
const [multipleDuration, setMultipleDuration] = useState(
formMethods.getValues("metadata")?.multipleDuration
);
const orgBranding = useOrgBranding();
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
@@ -143,12 +138,12 @@ export const EventSetupTab = (
}>
>(multipleDurationOptions.filter((mdOpt) => multipleDuration?.includes(mdOpt.value)));
const [defaultDuration, setDefaultDuration] = useState(
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
selectedMultipleDuration.find((opt) => opt.value === formMethods.getValues("length")) ?? null
);
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
useLockedFieldsManager(
eventType,
formMethods.getValues(),
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
@@ -195,7 +190,6 @@ export const EventSetupTab = (
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
@@ -219,7 +213,6 @@ export const EventSetupTab = (
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
@@ -339,7 +332,7 @@ export const EventSetupTab = (
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) => {
const fieldValues = formMethods.getValues().locations[index];
const fieldValues = formMethods.getValues("locations")[index];
updateLocationField(index, {
...fieldValues,
displayLocationPublicly: e.target.checked,
@@ -408,7 +401,7 @@ export const EventSetupTab = (
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
href={`${CAL_URL}/event-types/${formMethods.getValues("id")}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
@@ -463,7 +456,6 @@ export const EventSetupTab = (
required
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
<div>
@@ -471,23 +463,19 @@ export const EventSetupTab = (
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
<DescriptionEditor isEditable={!descriptionLockedProps.disabled} />
</div>
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
{urlPrefix}/
{!isManagedEventType
? team
? (orgBranding ? "" : "team/") + team.slug
: eventType.users[0].username
: formMethods.getValues("users")[0].username
: t("username_placeholder")}
/
</>
@@ -564,7 +552,7 @@ export const EventSetupTab = (
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
defaultValue={formMethods.getValues("length") ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
min={1}
@@ -580,6 +568,8 @@ export const EventSetupTab = (
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
setSelectedMultipleDuration([]);
setDefaultDuration(null);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
@@ -600,12 +590,7 @@ export const EventSetupTab = (
{shouldLockIndicator("locations")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
/>
<Controller name="locations" render={() => <Locations />} />
</div>
</div>
</div>
+186 -45
View File
@@ -1,8 +1,8 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useRef } from "react";
import type { ComponentProps } from "react";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { Options } from "react-select";
@@ -11,7 +11,7 @@ import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTea
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Label, Select } from "@calcom/ui";
import { Label, Select, SettingsToggle } from "@calcom/ui";
interface IUserToValue {
id: number | null;
@@ -97,6 +97,44 @@ const ChildrenEventTypesList = ({
);
};
const AssignAllTeamMembers = ({
assignAllTeamMembers,
setAssignAllTeamMembers,
onActive,
onInactive,
}: {
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
onActive: () => void;
onInactive?: () => void;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
return (
<Controller<FormValues>
name="assignAllTeamMembers"
render={() => (
<SettingsToggle
title={t("automatically_add_all_team_members")}
description={t("including_people_who_join_in_future")}
checked={assignAllTeamMembers}
onCheckedChange={(active) => {
formMethods.setValue("assignAllTeamMembers", active);
setAssignAllTeamMembers(active);
if (active) {
onActive();
} else if (!!onInactive) {
onInactive();
}
}}
toggleSwitchAtTheEnd
/>
)}
/>
);
};
const CheckedHostField = ({
labelText,
placeholder,
@@ -107,7 +145,7 @@ const CheckedHostField = ({
helperText,
...rest
}: {
labelText: string;
labelText?: string;
placeholder: string;
isFixed: boolean;
value: { isFixed: boolean; userId: number }[];
@@ -118,7 +156,7 @@ const CheckedHostField = ({
return (
<div className="bg-muted flex flex-col space-y-5 p-4">
<div>
<Label>{labelText}</Label>
{labelText ? <Label>{labelText}</Label> : <></>}
<CheckedTeamSelect
isOptionDisabled={(option) => !!value.find((host) => host.userId.toString() === option.value)}
onChange={(options) => {
@@ -165,6 +203,8 @@ const RoundRobinHosts = ({
teamMembers,
value,
onChange,
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
value: { isFixed: boolean; userId: number }[];
onChange: (hosts: { isFixed: boolean; userId: number }[]) => void;
@@ -174,51 +214,102 @@ const RoundRobinHosts = ({
avatar: string;
email: string;
}[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
return (
<>
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
isFixed={true}
onChange={(changeValue) => {
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
}}
value={value}
placeholder={t("add_fixed_hosts")}
labelText={t("fixed_hosts")}
helperText={FixedHostHelper}
/>
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
onChange={(changeValue) => onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])}
value={value}
isFixed={false}
placeholder={t("add_attendees")}
labelText={t("round_robin_hosts")}
helperText={t("round_robin_helper")}
/>
</>
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-8">
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
isFixed={true}
onChange={(changeValue) => {
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
}}
value={value}
placeholder={t("add_fixed_hosts")}
labelText={t("fixed_hosts")}
helperText={FixedHostHelper}
/>
<div className="bg-muted flex flex-col">
<Label className="p-4">{t("round_robin_hosts")}</Label>
<div className="px-4 pb-2">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() =>
formMethods.setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: false,
userId: parseInt(teamMember.value, 10),
}))
)
}
onInactive={() => formMethods.setValue("hosts", [])}
/>
</div>
{assignAllTeamMembers ? (
<div className="mb-4" />
) : (
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
onChange={(changeValue) =>
onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])
}
value={value}
isFixed={false}
placeholder={t("add_attendees")}
helperText={t("round_robin_helper")}
/>
)}
</div>
</div>
</div>
);
};
const ChildrenEventTypes = ({
childrenEventTypeOptions,
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
childrenEventTypeOptions: ReturnType<typeof mapMemberToChildrenOption>[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const formMethods = useFormContext<FormValues>();
return (
<Controller<FormValues>
name="children"
render={({ field: { onChange, value } }) => (
<ChildrenEventTypesList value={value} options={childrenEventTypeOptions} onChange={onChange} />
)}
/>
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-4">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() => formMethods.setValue("children", childrenEventTypeOptions)}
/>
{!assignAllTeamMembers ? (
<Controller<FormValues>
name="children"
render={({ field: { onChange, value } }) => (
<ChildrenEventTypesList value={value} options={childrenEventTypeOptions} onChange={onChange} />
)}
/>
) : (
<></>
)}
</div>
</div>
);
};
const Hosts = ({
teamMembers,
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
teamMembers: {
value: string;
@@ -226,6 +317,8 @@ const Hosts = ({
avatar: string;
email: string;
}[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useLocale();
const {
@@ -255,24 +348,53 @@ const Hosts = ({
});
}, [schedulingType, resetField, getValues, submitCount]);
const formMethods = useFormContext<FormValues>();
return (
<Controller<FormValues>
name="hosts"
render={({ field: { onChange, value } }) => {
const schedulingTypeRender = {
COLLECTIVE: (
<CheckedHostField
value={value}
onChange={onChange}
isFixed={true}
options={teamMembers.sort(sortByLabel)}
placeholder={t("add_attendees")}
labelText={t("team")}
/>
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-4">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() =>
formMethods.setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
}))
)
}
onInactive={() => formMethods.setValue("hosts", [])}
/>
{assignAllTeamMembers ? (
<></>
) : (
<CheckedHostField
value={value}
onChange={onChange}
isFixed={true}
options={teamMembers.sort(sortByLabel)}
placeholder={t("add_attendees")}
labelText={t("team")}
/>
)}
</div>
</div>
),
ROUND_ROBIN: (
<>
<RoundRobinHosts teamMembers={teamMembers} onChange={onChange} value={value} />
<RoundRobinHosts
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
teamMembers={teamMembers}
onChange={onChange}
value={value}
/>
{/*<TextField
required
type="number"
@@ -320,9 +442,18 @@ export const EventTeamTab = ({
.filter(pendingMembers)
.map((member) => mapUserToValue(member, t("pending")));
const childrenEventTypeOptions = teamMembers.filter(pendingMembers).map((member) => {
return mapMemberToChildrenOption(member, eventType.slug, t("pending"));
return mapMemberToChildrenOption(
{ ...member, eventTypes: member.eventTypes.filter((et) => et !== eventType.slug) },
eventType.slug,
t("pending")
);
});
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
const formMethods = useFormContext<FormValues>();
const [assignAllTeamMembers, setAssignAllTeamMembers] = useState<boolean>(
formMethods.getValues("assignAllTeamMembers") ?? false
);
return (
<div>
{team && !isManagedEventType && (
@@ -338,16 +469,26 @@ export const EventTeamTab = ({
className="w-full"
onChange={(val) => {
onChange(val?.value);
formMethods.setValue("assignAllTeamMembers", false);
setAssignAllTeamMembers(false);
}}
/>
)}
/>
</div>
<Hosts teamMembers={teamMembersOptions} />
<Hosts
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
teamMembers={teamMembersOptions}
/>
</div>
)}
{team && isManagedEventType && (
<ChildrenEventTypes childrenEventTypeOptions={childrenEventTypeOptions} />
<ChildrenEventTypes
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
childrenEventTypeOptions={childrenEventTypeOptions}
/>
)}
</div>
);
@@ -67,55 +67,66 @@ type Props = {
availability?: AvailabilityOption;
isUserOrganizationAdmin: boolean;
bookerUrl: string;
activeWebhooksNumber: number;
};
function getNavigation(props: {
type getNavigationProps = {
t: TFunction;
eventType: Props["eventType"];
length: number;
id: number;
multipleDuration?: EventTypeSetupProps["eventType"]["metadata"]["multipleDuration"];
enabledAppsNumber: number;
enabledWorkflowsNumber: number;
installedAppsNumber: number;
availability: AvailabilityOption | undefined;
}) {
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props;
const duration =
eventType.metadata?.multipleDuration?.map((duration) => ` ${duration}`) || eventType.length;
};
function getNavigation({
length,
id,
multipleDuration,
t,
enabledAppsNumber,
installedAppsNumber,
enabledWorkflowsNumber,
}: getNavigationProps) {
const duration = multipleDuration?.map((duration) => ` ${duration}`) || length;
return [
{
name: "event_setup_tab_title",
href: `/event-types/${eventType.id}?tabName=setup`,
href: `/event-types/${id}?tabName=setup`,
icon: LinkIcon,
info: `${duration} ${t("minute_timeUnit")}`, // TODO: Get this from props
},
{
name: "event_limit_tab_title",
href: `/event-types/${eventType.id}?tabName=limits`,
href: `/event-types/${id}?tabName=limits`,
icon: Clock,
info: `event_limit_tab_description`,
},
{
name: "event_advanced_tab_title",
href: `/event-types/${eventType.id}?tabName=advanced`,
href: `/event-types/${id}?tabName=advanced`,
icon: Sliders,
info: `event_advanced_tab_description`,
},
{
name: "recurring",
href: `/event-types/${eventType.id}?tabName=recurring`,
href: `/event-types/${id}?tabName=recurring`,
icon: Repeat,
info: `recurring_event_tab_description`,
},
{
name: "apps",
href: `/event-types/${eventType.id}?tabName=apps`,
href: `/event-types/${id}?tabName=apps`,
icon: Grid,
//TODO: Handle proper translation with count handling
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
},
{
name: "workflows",
href: `/event-types/${eventType.id}?tabName=workflows`,
href: `/event-types/${id}?tabName=workflows`,
icon: Zap,
info: `${enabledWorkflowsNumber} ${t("active")}`,
},
@@ -136,6 +147,7 @@ function EventTypeSingleLayout({
availability,
isUserOrganizationAdmin,
bookerUrl,
activeWebhooksNumber,
}: Props) {
const utils = trpc.useContext();
const { t } = useLocale();
@@ -145,7 +157,7 @@ function EventTypeSingleLayout({
const hasPermsToDelete =
currentUserMembership?.role !== "MEMBER" ||
!currentUserMembership ||
eventType.schedulingType === SchedulingType.MANAGED ||
formMethods.getValues("schedulingType") === SchedulingType.MANAGED ||
isUserOrganizationAdmin;
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
@@ -167,16 +179,23 @@ function EventTypeSingleLayout({
});
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
eventType,
formMethods.getValues(),
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const length = formMethods.watch("length");
const multipleDuration = formMethods.watch("metadata")?.multipleDuration;
const watchSchedulingType = formMethods.watch("schedulingType");
const watchChildrenCount = formMethods.watch("children").length;
// Define tab navigation here
const EventTypeTabs = useMemo(() => {
const navigation = getNavigation({
t,
eventType,
length,
multipleDuration,
id: formMethods.getValues("id"),
enabledAppsNumber,
installedAppsNumber,
enabledWorkflowsNumber,
@@ -185,31 +204,29 @@ function EventTypeSingleLayout({
navigation.splice(1, 0, {
name: "availability",
href: `/event-types/${eventType.id}?tabName=availability`,
href: `/event-types/${formMethods.getValues("id")}?tabName=availability`,
icon: Calendar,
info:
isManagedEventType || isChildrenManagedEventType
? eventType.schedule === null
? formMethods.getValues("schedule") === null
? "members_default_schedule"
: isChildrenManagedEventType
? `${
eventType.scheduleName
? `${eventType.scheduleName} - ${t("managed")}`
formMethods.getValues("scheduleName")
? `${formMethods.getValues("scheduleName")} - ${t("managed")}`
: `default_schedule_name`
}`
: eventType.scheduleName ?? `default_schedule_name`
: eventType.scheduleName ?? `default_schedule_name`,
: formMethods.getValues("scheduleName") ?? `default_schedule_name`
: formMethods.getValues("scheduleName") ?? `default_schedule_name`,
});
// If there is a team put this navigation item within the tabs
if (team) {
navigation.splice(2, 0, {
name: "assignment",
href: `/event-types/${eventType.id}?tabName=team`,
href: `/event-types/${formMethods.getValues("id")}?tabName=team`,
icon: Users,
info: `${t(eventType.schedulingType?.toLowerCase() ?? "")}${
isManagedEventType
? ` - ${t("number_member", { count: formMethods.watch("children").length || 0 })}`
: ""
info: `${t(watchSchedulingType?.toLowerCase() ?? "")}${
isManagedEventType ? ` - ${t("number_member", { count: watchChildrenCount || 0 })}` : ""
}`,
});
}
@@ -225,15 +242,14 @@ function EventTypeSingleLayout({
}
navigation.push({
name: "webhooks",
href: `/event-types/${eventType.id}?tabName=webhooks`,
href: `/event-types/${formMethods.getValues("id")}?tabName=webhooks`,
icon: TbWebhook,
info: `${eventType.webhooks.filter((webhook) => webhook.active).length} ${t("active")}`,
info: `${activeWebhooksNumber} ${t("active")}`,
});
}
return navigation;
}, [
t,
eventType,
enabledAppsNumber,
installedAppsNumber,
enabledWorkflowsNumber,
@@ -241,16 +257,23 @@ function EventTypeSingleLayout({
isManagedEventType,
isChildrenManagedEventType,
team,
formMethods,
length,
multipleDuration,
formMethods.getValues("id"),
watchSchedulingType,
watchChildrenCount,
activeWebhooksNumber,
]);
const permalink = `${bookerUrl}/${
team ? `${!team.parentId ? "team/" : ""}${team.slug}` : eventType.users[0].username
team ? `${!team.parentId ? "team/" : ""}${team.slug}` : formMethods.getValues("users")[0].username
}/${eventType.slug}`;
const embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
const embedLink = `${
team ? `team/${team.slug}` : formMethods.getValues("users")[0].username
}/${formMethods.getValues("slug")}`;
const isManagedEvent = formMethods.getValues("schedulingType") === SchedulingType.MANAGED ? "_managed" : "";
// const title = formMethods.watch("title");
return (
<Shell
backPath="/event-types"
@@ -258,7 +281,7 @@ function EventTypeSingleLayout({
heading={eventType.title}
CTA={
<div className="flex items-center justify-end">
{!eventType.metadata?.managedEventConfig && (
{!formMethods.getValues("metadata")?.managedEventConfig && (
<>
<div
className={classNames(
@@ -333,7 +356,7 @@ function EventTypeSingleLayout({
tooltip={t("embed")}
tooltipSide="bottom"
tooltipOffset={4}
eventId={eventType.id}
eventId={formMethods.getValues("id")}
/>
</>
)}
@@ -446,14 +469,14 @@ function EventTypeSingleLayout({
</Suspense>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
isPending={deleteMutation.isPending}
variety="danger"
title={t(`delete${isManagedEvent}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: eventType.id });
deleteMutation.mutate({ id: formMethods.getValues("id") });
}}>
<p className="mt-5">
<Trans
@@ -35,8 +35,9 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
setEditModalOpen(false);
await utils.viewer.webhook.list.invalidate();
showToast(t("webhook_updated_successfully"), "success");
await utils.viewer.webhook.list.invalidate();
await utils.viewer.eventTypes.get.invalidate();
},
onError(error) {
showToast(`${error.message}`, "error");
@@ -45,9 +46,10 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
async onSuccess() {
setCreateModalOpen(false);
showToast(t("webhook_created_successfully"), "success");
await utils.viewer.webhook.list.invalidate();
setCreateModalOpen(false);
await utils.viewer.eventTypes.get.invalidate();
},
onError(error) {
showToast(`${error.message}`, "error");
@@ -108,7 +108,7 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
eventTypeId: eventType.id,
eventTriggers: [WebhookTriggerEvents.INSTANT_MEETING],
});
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery({
const { data: installedApps, isPending } = trpc.viewer.integrations.useQuery({
variant: "other",
onlyInstalled: true,
});
@@ -188,7 +188,7 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
return (
<div>
{webhooks && !isLoading && (
{webhooks && !isPending && (
<>
<div>
{webhooks.length ? (
@@ -8,18 +8,18 @@ interface ManagedEventDialogProps {
slugExistsChildrenDialogOpen: ChildrenEventType[];
slug: string;
onOpenChange: () => void;
isLoading: boolean;
isPending: boolean;
onConfirm: (e: { preventDefault: () => void }) => void;
}
export default function ManagedEventDialog(props: ManagedEventDialogProps) {
const { t } = useLocale();
const { slugExistsChildrenDialogOpen, slug, onOpenChange, isLoading, onConfirm } = props;
const { slugExistsChildrenDialogOpen, slug, onOpenChange, isPending, onConfirm } = props;
return (
<Dialog open={slugExistsChildrenDialogOpen.length > 0} onOpenChange={onOpenChange}>
<ConfirmationDialogContent
isLoading={isLoading}
isPending={isPending}
variety="warning"
title={t("managed_event_dialog_title", {
slug,
@@ -19,11 +19,10 @@ export default function RecurringEventController({
paymentEnabled,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(
eventType.recurringEvent
);
const formMethods = useFormContext<FormValues>();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(
formMethods.getValues("recurringEvent")
);
/* Just yearly-0, monthly-1 and weekly-2 */
const recurringEventFreqOptions = Object.entries(Frequency)
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
@@ -75,6 +75,7 @@ export default function RequiresConfirmationController({
)}
childrenClassName="lg:ml-0"
title={t("requires_confirmation")}
data-testid="requires-confirmation"
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
description={t("requires_confirmation_description")}
@@ -28,7 +28,7 @@ const AppConnectionItem = (props: IAppConnectionItem) => {
color="secondary"
disabled={installed}
type="button"
loading={buttonProps?.isLoading}
loading={buttonProps?.isPending}
onClick={(event) => {
// Save cookie key to return url step
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
@@ -31,7 +31,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
return (
<>
{/* Already connected calendars */}
{!queryConnectedCalendars.isLoading &&
{!queryConnectedCalendars.isPending &&
firstCalendar &&
firstCalendar.integration &&
firstCalendar.integration.title &&
@@ -76,7 +76,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
</List>
)}
{queryIntegrations.isLoading && <StepConnectionLoader />}
{queryIntegrations.isPending && <StepConnectionLoader />}
<button
type="button"
@@ -13,7 +13,7 @@ interface ConnectedAppStepProps {
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep } = props;
const { data: queryConnectedVideoApps, isLoading } = trpc.viewer.integrations.useQuery({
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
sortByMostPopular: true,
@@ -26,7 +26,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
return (
<>
{!isLoading && (
{!isPending && (
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
@@ -48,7 +48,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
</List>
)}
{isLoading && <StepConnectionLoader />}
{isPending && <StepConnectionLoader />}
<button
type="button"
data-testid="save-video-button"
@@ -8,7 +8,6 @@ import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { UserAvatar } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
@@ -97,18 +96,10 @@ const UserProfile = () => {
},
];
const organization =
user.organization && user.organization.id
? {
...(user.organization as Ensure<typeof user.organization, "id">),
slug: user.organization.slug || null,
requestedSlug: user.organization.metadata?.requestedSlug || null,
}
: null;
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />}
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
<input
ref={avatarRef}
type="hidden"
@@ -113,7 +113,7 @@ const UserSettings = (props: IUserSettingsProps) => {
<Button
type="submit"
className="mt-8 flex w-full flex-row justify-center"
disabled={mutation.isLoading}>
disabled={mutation.isPending}>
{t("next_step_text")}
<ArrowRight className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</Button>
+1 -1
View File
@@ -3,7 +3,7 @@ import classNames from "classnames";
import { signIn } from "next-auth/react";
import React from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import * as z from "zod";
import { z } from "zod";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { WEBSITE_URL } from "@calcom/lib/constants";
@@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { noop } from "lodash";
import { useCallback, useState } from "react";
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
import * as z from "zod";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { CONSOLE_URL } from "@calcom/lib/constants";
+2 -2
View File
@@ -7,7 +7,7 @@ import { Check } from "@calcom/ui/components/icon";
const StepDone = (props: {
currentStep: number;
nextStepPath: string;
setIsLoading: Dispatch<SetStateAction<boolean>>;
setIsPending: Dispatch<SetStateAction<boolean>>;
}) => {
const router = useRouter();
const { t } = useLocale();
@@ -18,7 +18,7 @@ const StepDone = (props: {
name={`wizard-step-${props.currentStep}`}
className="flex justify-center space-y-4"
onSubmit={(e) => {
props.setIsLoading(true);
props.setIsPending(true);
e.preventDefault();
router.replace(props.nextStepPath);
}}>
+7 -2
View File
@@ -5,11 +5,16 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import type { UserProfile } from "@calcom/types/UserProfile";
import { UserAvatar } from "@calcom/ui";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username" | "organizationId"> & {
type MemberType = Pick<
MembersType[number],
"id" | "name" | "bio" | "username" | "organizationId" | "avatarUrl"
> & {
profile: Omit<UserProfile, "upId">;
safeBio: string | null;
bookerUrl: string;
};
@@ -27,7 +32,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
key={member.id}
href={{ pathname: `${member.bookerUrl}/${member.username}`, query: queryParamsToForward }}>
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
<UserAvatar size="md" user={member} />
<UserAvatar noOrganizationIndicator size="md" user={member} />
<section className="mt-2 line-clamp-4 w-full space-y-1">
<p className="text-default font-medium">{member.name}</p>
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
@@ -293,7 +293,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
<Button
type="button"
loading={updateUsername.isLoading}
loading={updateUsername.isPending}
data-testid="go-to-billing"
href={paymentLink}>
<>
@@ -305,7 +305,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
{usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE && (
<Button
type="button"
loading={updateUsername.isLoading}
loading={updateUsername.isPending}
data-testid="save-username"
onClick={() => {
saveUsername();
@@ -177,7 +177,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
<DialogFooter className="mt-4">
<Button
type="button"
loading={updateUsernameMutation.isLoading}
loading={updateUsernameMutation.isPending}
data-testid="save-username"
onClick={updateUsername}>
{t("save")}
@@ -2,8 +2,7 @@ import type { InputHTMLAttributes } from "react";
import React, { forwardRef } from "react";
import classNames from "@calcom/lib/classNames";
import InfoBadge from "@components/ui/InfoBadge";
import { InfoBadge } from "@calcom/ui";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label?: React.ReactNode;
@@ -34,6 +34,7 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
<Select<LocationOption>
name="location"
id="location-select"
data-testid="location-select"
components={{
Option: (props) => {
return (
-1
View File
@@ -1 +0,0 @@
export const IS_VISUAL_REGRESSION_TESTING = Boolean(globalThis.window?.Meticulous?.isRunningAsTest);
+8 -36
View File
@@ -1,21 +1,15 @@
import type {
QueryObserverLoadingErrorResult,
QueryObserverLoadingResult,
QueryObserverPendingResult,
QueryObserverRefetchErrorResult,
QueryObserverSuccessResult,
QueryObserverLoadingErrorResult,
UseQueryResult,
} from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import type { DecorateProcedure } from "@calcom/trpc/react/shared";
import type { AnyQueryProcedure, inferProcedureInput, inferProcedureOutput } from "@calcom/trpc/server";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Alert, Loader } from "@calcom/ui";
import type { UseTRPCQueryOptions } from "@trpc/react-query/shared";
type ErrorLike = {
message: string;
};
@@ -27,7 +21,7 @@ interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
error?: (
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
) => JSXElementOrNull;
loading?: (query: QueryObserverLoadingResult<TData, TError> | null) => JSXElementOrNull;
loading?: (query: QueryObserverPendingResult<TData, TError> | null) => JSXElementOrNull;
}
interface QueryCellOptionsNoEmpty<TData, TError extends ErrorLike>
@@ -58,8 +52,11 @@ export function QueryCell<TData, TError extends ErrorLike>(
const { isLocaleReady } = useLocale();
const StatusLoader = opts.customLoader || <Loader />; // Fixes edge case where this can return null form query cell
if (query.status === "loading" || !isLocaleReady) {
return opts.loading?.(query.status === "loading" ? query : null) ?? StatusLoader;
if (!isLocaleReady) {
return opts.loading?.(query.status === "pending" ? query : null) ?? StatusLoader;
}
if (query.status === "pending") {
return opts.loading?.(query) ?? StatusLoader;
}
if (query.status === "success") {
@@ -81,28 +78,3 @@ export function QueryCell<TData, TError extends ErrorLike>(
// impossible state
return null;
}
type TError = TRPCClientErrorLike<AppRouter>;
const withQuery = <
TQuery extends AnyQueryProcedure,
TInput = inferProcedureInput<TQuery>,
TOutput = inferProcedureOutput<TQuery>
>(
queryProcedure: DecorateProcedure<TQuery, inferProcedureInput<TQuery>, inferProcedureOutput<TQuery>>,
input?: TInput,
params?: UseTRPCQueryOptions<TQuery, TInput, TOutput, TOutput, TError>
) => {
return function WithQuery(
opts: Omit<
Partial<QueryCellOptionsWithEmpty<TOutput, TError>> & QueryCellOptionsNoEmpty<TOutput, TError>,
"query"
>
) {
const query = queryProcedure.useQuery(input, params);
return <QueryCell query={query} {...opts} />;
};
};
export { withQuery };
+33 -15
View File
@@ -1,4 +1,5 @@
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -115,21 +116,23 @@ export const handleSeatsEventTypeOnBooking = async (
}>
>,
seatReferenceUid?: string,
userId?: number
isHost?: boolean
) => {
if (eventType?.seatsPerTimeSlot !== null) {
// @TODO: right now bookings with seats doesn't save every description that its entered by every user
delete bookingInfo.description;
} else {
return;
}
// @TODO: If handling teams, we need to do more check ups for this.
if (bookingInfo?.user?.id === userId) {
return;
}
if (!eventType.seatsShowAttendees) {
const seatAttendee = await prisma.bookingSeat.findFirst({
bookingInfo["responses"] = {};
type seatAttendee = {
attendee: {
email: string;
name: string;
};
id: number;
data: Prisma.JsonValue;
bookingId: number;
attendeeId: number;
referenceUid: string;
} | null;
let seatAttendee: seatAttendee = null;
if (seatReferenceUid) {
seatAttendee = await prisma.bookingSeat.findFirst({
where: {
referenceUid: seatReferenceUid,
},
@@ -142,16 +145,31 @@ export const handleSeatsEventTypeOnBooking = async (
},
},
});
}
if (seatAttendee) {
const seatAttendeeData = seatAttendee.data as unknown as {
description?: string;
responses: Prisma.JsonValue;
};
bookingInfo["description"] = seatAttendeeData.description ?? null;
bookingInfo["responses"] = bookingResponsesDbSchema.parse(seatAttendeeData.responses ?? {});
}
if (!eventType.seatsShowAttendees && !isHost) {
if (seatAttendee) {
const attendee = bookingInfo?.attendees?.find((a) => {
return a.email === seatAttendee.attendee?.email;
return a.email === seatAttendee?.attendee?.email;
});
bookingInfo["attendees"] = attendee ? [attendee] : [];
} else {
bookingInfo["attendees"] = [];
}
}
// // @TODO: If handling teams, we need to do more check ups for this.
// if (bookingInfo?.user?.id === userId) {
// return;
// }
return bookingInfo;
};
@@ -5,6 +5,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
@@ -56,19 +57,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return notFound;
}
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
away: true,
hideBranding: true,
},
const [user] = await UserRepository.findUsersByUsername({
usernameList: [username],
orgSlug: org,
});
if (!user) {
@@ -0,0 +1,23 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { ssrInit } from "@server/lib/ssr";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const session = await getServerSession({ req: context.req, res: context.res });
if (!session) {
return {
redirect: {
redirect: {
permanent: false,
destination: "/auth/login",
},
},
};
}
return { props: { trpcState: ssr.dehydrate() } };
};
@@ -5,9 +5,10 @@ import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/or
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { getServerSideProps as GSSUserTypePage } from "@lib/[user]/[type]/getServerSideProps";
import { getServerSideProps as GSSTeamTypePage } from "@lib/team/[slug]/[type]/getServerSideProps";
import { getServerSideProps as GSSUserTypePage } from "~/users/views/users-type-public-view.getServerSideProps";
const paramsSchema = z.object({
orgSlug: z.string().transform((s) => slugify(s)),
user: z.string(),
+5 -2
View File
@@ -1589,17 +1589,20 @@ async function createTeamOutsideOrg(
});
}
async function createUserOutsideOrg(data: Omit<Prisma.UserCreateArgs["data"], "organization">) {
async function createUserOutsideOrg(
data: Omit<Prisma.UserCreateArgs["data"], "organization" | "movedToProfile">
) {
return await prismock.user.create({
data: {
...data,
movedToProfileId: null,
organizationId: null,
},
});
}
async function createUserInsideTheOrg(
data: Omit<Prisma.UserUncheckedCreateInput, "organization" | "organizationId" | "id">,
data: Omit<Prisma.UserUncheckedCreateInput, "organization" | "organizationId" | "id" | "movedToProfileId">,
orgId: number
) {
const org = await prismock.team.findUnique({
+37 -3
View File
@@ -3,6 +3,7 @@ import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomai
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { RedirectType } from "@calcom/prisma/client";
@@ -327,7 +328,7 @@ async function setOrgSlugIfNotSet(
slug: string | null;
},
orgMetadata: {
requestedSlug?: string | undefined;
requestedSlug?: string | null | undefined;
},
targetOrgId: number
) {
@@ -348,7 +349,7 @@ async function setOrgSlugIfNotSet(
function assertUserPartOfOrgAndRemigrationAllowed(
userToMoveToOrg: {
organizationId: User["organizationId"];
organizationId: number | null;
},
targetOrgId: number,
targetOrgUsername: string,
@@ -384,7 +385,7 @@ async function getTeamOrThrowError(targetOrgId: number) {
function assertUserPartOfOtherOrg(
userToMoveToOrg: {
organizationId: User["organizationId"];
organizationId: number | null;
} | null,
userName: string | undefined,
userId: number | undefined,
@@ -594,6 +595,35 @@ async function dbMoveUserToOrg({
},
},
});
await prisma.profile.upsert({
create: {
uid: ProfileRepository.generateProfileUid(),
userId: userToMoveToOrg.id,
organizationId: targetOrgId,
username: targetOrgUsername,
movedFromUser: {
connect: {
id: userToMoveToOrg.id,
},
},
},
update: {
organizationId: targetOrgId,
username: targetOrgUsername,
movedFromUser: {
connect: {
id: userToMoveToOrg.id,
},
},
},
where: {
userId_organizationId: {
userId: userToMoveToOrg.id,
organizationId: targetOrgId,
},
},
});
}
async function moveTeamsWithoutMembersToOrg({
@@ -841,6 +871,10 @@ async function dbRemoveUserFromOrg({
},
},
});
await ProfileRepository.deleteMany({
userIds: [userToRemoveFromOrg.id],
});
}
async function removeMembership({
@@ -18,9 +18,21 @@ import { ssrInit } from "@server/lib/ssr";
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
const getTheLastArrayElement = (value: ReadonlyArray<string> | string | undefined): string | undefined => {
if (value === undefined || typeof value === "string") {
return value;
}
return value.at(-1);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const slug = getTheLastArrayElement(context.query.slug) ?? getTheLastArrayElement(context.query.orgSlug);
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
context.req,
context.params?.orgSlug ?? context.query?.orgSlug
);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
// Provided by Rewrite from next.config.js
@@ -109,7 +121,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
name: member.name,
id: member.id,
avatarUrl: member.avatarUrl,
bio: member.bio,
profile: member.profile,
subteams: member.subteams,
username: member.username,
accepted: member.accepted,
+23 -12
View File
@@ -11,6 +11,14 @@ import { csp } from "@lib/csp";
import { abTestMiddlewareFactory } from "./abTest/middlewareFactory";
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
try {
return get<T>(key);
} catch (error) {
// Don't crash if EDGE_CONFIG env var is missing
}
};
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
@@ -24,18 +32,12 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
//
// - For this reason our matchers are sufficient for an app-wide maintenance page.
//
try {
// Check whether the maintenance page should be shown
const isInMaintenanceMode = await get<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
req.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(req.nextUrl);
}
} catch (error) {
// show the default page if EDGE_CONFIG env var is missing,
// but log the error to the console
// console.error(error);
// Check whether the maintenance page should be shown
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
req.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(req.nextUrl);
}
}
@@ -60,6 +62,14 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
requestHeaders.set("x-cal-timezone", req.headers.get("x-vercel-ip-timezone") ?? "");
}
if (url.pathname.startsWith("/api/auth/signup")) {
const isSignupDisabled = await safeGet<boolean>("isSignupDisabled");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isSignupDisabled) {
return NextResponse.json({ error: "Signup is disabled" }, { status: 503 });
}
}
if (url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login")) {
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
requestHeaders.set("x-csp-enforce", "true");
@@ -114,6 +124,7 @@ export const config = {
// https://github.com/vercel/next.js/discussions/42458
matcher: [
"/:path*/embed",
"/api/auth/signup",
"/api/trpc/:path*",
"/login",
"/auth/login",
@@ -0,0 +1 @@
export const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
@@ -3,7 +3,7 @@ import { z } from "zod";
import { getTranslations } from "@server/lib/getTranslations";
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
import { validStatuses } from "~/bookings/lib/validStatuses";
const querySchema = z.object({
status: z.enum(validStatuses),
@@ -0,0 +1,252 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Fragment, useState } from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle";
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { ShellMain } from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
import { Alert, Button, EmptyScreen, HorizontalTabs } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import useMeQuery from "@lib/hooks/useMeQuery";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
import { validStatuses } from "~/bookings/lib/validStatuses";
type BookingListingStatus = z.infer<NonNullable<typeof filterQuerySchema>>["status"];
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
type RecurringInfo = {
recurringEventId: string | null;
count: number;
firstDate: Date | null;
bookings: { [key: string]: Date[] };
};
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
{
name: "upcoming",
href: "/bookings/upcoming",
},
{
name: "unconfirmed",
href: "/bookings/unconfirmed",
},
{
name: "recurring",
href: "/bookings/recurring",
},
{
name: "past",
href: "/bookings/past",
},
{
name: "cancelled",
href: "/bookings/cancelled",
},
];
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
upcoming: "upcoming_bookings",
recurring: "recurring_bookings",
past: "past_bookings",
cancelled: "cancelled_bookings",
unconfirmed: "unconfirmed_bookings",
};
const querySchema = z.object({
status: z.enum(validStatuses),
});
export default function Bookings() {
const params = useParamsWithFallback();
const { data: filterQuery } = useFilterQuery();
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
const { t } = useLocale();
const user = useMeQuery().data;
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
limit: 10,
filters: {
...filterQuery,
status: filterQuery.status ?? status,
},
},
{
// first render has status `undefined`
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
// Animate page (tab) transitions to look smoothing
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {
query.fetchNextPage();
}
});
const isEmpty = !query.data?.pages[0]?.bookings.length;
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
if (!booking.recurringEventId) {
return true;
}
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
shownBookings[booking.recurringEventId].push(booking);
return false;
}
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
}
return true;
};
let recurringInfoToday: RecurringInfo | undefined;
const bookingsToday =
query.data?.pages.map((page) =>
page.bookings.filter((booking: BookingOutput) => {
recurringInfoToday = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
);
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
})
)[0] || [];
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
return (
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
<FilterToggle setIsFiltersVisible={setIsFiltersVisible} />
</div>
<FiltersContainer isFiltersVisible={isFiltersVisible} />
<main className="w-full">
<div className="flex w-full flex-col" ref={animationParentRef}>
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "pending" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
{!!bookingsToday.length && status === "upcoming" && (
<div className="mb-6 pt-2 xl:pt-0">
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
/>
))}
</Fragment>
</tbody>
</table>
</div>
</div>
)}
<div className="pt-2 xl:pt-0">
<div className="border-subtle overflow-hidden rounded-md border">
<table data-testid={`${status}-bookings`} className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
const recurringInfo = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
);
return (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
})}
</Fragment>
))}
</tbody>
</table>
</div>
<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>
</>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon={Calendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
</div>
</main>
</div>
</ShellMain>
);
}
@@ -9,6 +9,8 @@ import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUi
import prisma from "@calcom/prisma";
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import { ssrInit } from "@server/lib/ssr";
const stringToBoolean = z
@@ -29,6 +31,8 @@ const querySchema = z.object({
seatReferenceUid: z.string().optional(),
});
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export async function getServerSideProps(context: GetServerSidePropsContext) {
// this is needed to prevent bundling of lib/booking to the client bundle
// usually functions that are used in getServerSideProps are tree shaken from client bundle
@@ -52,12 +56,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const parsedQuery = querySchema.safeParse(context.query);
if (!parsedQuery.success) return { notFound: true } as const;
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
const { eventTypeSlug } = parsedQuery.data;
let { uid, seatReferenceUid } = parsedQuery.data;
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
const maybeBookingUidFromSeat = await maybeGetBookingUidFromSeat(prisma, uid);
if (maybeBookingUidFromSeat.uid) uid = maybeBookingUidFromSeat.uid;
if (maybeBookingUidFromSeat.seatReferenceUid) seatReferenceUid = maybeBookingUidFromSeat.seatReferenceUid;
const bookingInfoRaw = await prisma.booking.findFirst({
where: {
uid: maybeUid,
uid: uid,
},
select: {
title: true,
@@ -164,7 +171,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, session?.user.id);
await handleSeatsEventTypeOnBooking(
eventType,
bookingInfo,
seatReferenceUid,
session?.user.id === eventType.userId
);
}
const payment = await prisma.payment.findFirst({
@@ -14,6 +14,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import type { getEventLocationValue } from "@calcom/app-store/locations";
import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import type { nameObjectSchema } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
import type { ConfigType } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
@@ -44,17 +45,13 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { Alert, Badge, Button, EmailInput, HeadSeo, useCalcomTheme } from "@calcom/ui";
import { AlertCircle, Calendar, Check, ChevronLeft, ExternalLink, X } from "@calcom/ui/components/icon";
import { getServerSideProps } from "@lib/booking/[uid]/getServerSideProps";
import { timeZone } from "@lib/clock";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import CancelBooking from "@components/booking/CancelBooking";
import EventReservationSchema from "@components/schemas/EventReservationSchema";
export { getServerSideProps };
export type PageProps = inferSSRProps<typeof getServerSideProps>;
import type { PageProps } from "./bookings-single-view.getServerSideProps";
const stringToBoolean = z
.string()
@@ -94,6 +91,7 @@ export default function Success(props: PageProps) {
const routerQuery = useRouterQuery();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const { eventType, bookingInfo, requiresLoginToUpdate } = props;
const {
allRemainingBookings,
isSuccessBookingPage,
@@ -103,35 +101,28 @@ export default function Success(props: PageProps) {
seatReferenceUid,
} = querySchema.parse(routerQuery);
const attendeeTimeZone = props?.bookingInfo?.attendees.find(
(attendee) => attendee.email === email
)?.timeZone;
const attendeeTimeZone = bookingInfo?.attendees.find((attendee) => attendee.email === email)?.timeZone;
const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone();
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
const location = bookingInfo.location as ReturnType<typeof getEventLocationValue>;
let rescheduleLocation: string | undefined;
if (
typeof props.bookingInfo.responses.location === "object" &&
"optionValue" in props.bookingInfo.responses.location
typeof bookingInfo.responses?.location === "object" &&
"optionValue" in bookingInfo.responses.location
) {
rescheduleLocation = props.bookingInfo.responses.location.optionValue;
rescheduleLocation = bookingInfo.responses.location.optionValue;
}
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
props?.bookingInfo?.metadata || {}
bookingInfo?.metadata || {}
)?.videoCallUrl;
const status = props.bookingInfo?.status;
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
const status = bookingInfo?.status;
const reschedule = bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = bookingInfo.cancellationReason || bookingInfo.rejectionReason;
const attendeeName =
typeof props?.bookingInfo?.attendees?.[0]?.name === "string"
? props?.bookingInfo?.attendees?.[0]?.name
: "Nameless";
const attendees = props?.bookingInfo?.attendees;
const attendees = bookingInfo?.attendees;
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
@@ -140,15 +131,14 @@ export default function Success(props: PageProps) {
);
const { data: session } = useSession();
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
const { eventType, bookingInfo } = props;
const [date, setDate] = useState(dayjs.utc(bookingInfo.startTime));
const isBackgroundTransparent = useIsBackgroundTransparent();
const isEmbed = useIsEmbed();
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
const { requiresLoginToUpdate } = props;
function setIsCancellationMode(value: boolean) {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
@@ -163,13 +153,13 @@ export default function Success(props: PageProps) {
router.replace(`${pathname}?${_searchParams.toString()}`);
}
let evtName = props.eventType.eventName;
let evtName = eventType.eventName;
if (eventType.isDynamic && bookingInfo.responses?.title) {
evtName = bookingInfo.responses.title as string;
}
const eventNameObject = {
attendeeName,
eventType: props.eventType.title,
attendeeName: bookingInfo.responses.name as z.infer<typeof nameObjectSchema> | string,
eventType: eventType.title,
eventName: evtName,
host: props.profile.name || "Nameless",
location: location,
@@ -227,9 +217,7 @@ export default function Success(props: PageProps) {
}, [eventType, needsConfirmation]);
useEffect(() => {
setCalculatedDuration(
dayjs(props.bookingInfo.endTime).diff(dayjs(props.bookingInfo.startTime), "minutes")
);
setCalculatedDuration(dayjs(bookingInfo.endTime).diff(dayjs(bookingInfo.startTime), "minutes"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -249,7 +237,7 @@ export default function Success(props: PageProps) {
],
startInputType: "utc",
title: eventName,
description: props.eventType.description ? props.eventType.description : undefined,
description: eventType.description ? eventType.description : undefined,
/** formatted to required type of description ^ */
duration: {
minutes: calculatedDuration,
@@ -325,6 +313,7 @@ export default function Success(props: PageProps) {
<div className="-mb-4 ml-4 mt-2">
<Link
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
data-testid="back-to-bookings"
className="hover:bg-subtle text-subtle hover:text-default mt-2 inline-flex px-1 py-2 text-sm dark:hover:bg-transparent">
<ChevronLeft className="h-5 w-5 rtl:rotate-180" /> {t("back_to_bookings")}
</Link>
@@ -427,7 +416,7 @@ export default function Success(props: PageProps) {
{reschedule && !!formerTime && (
<p className="line-through">
<RecurringBookings
eventType={props.eventType}
eventType={eventType}
duration={calculatedDuration}
recurringBookings={props.recurringBookings}
allRemainingBookings={allRemainingBookings}
@@ -439,7 +428,7 @@ export default function Success(props: PageProps) {
</p>
)}
<RecurringBookings
eventType={props.eventType}
eventType={eventType}
duration={calculatedDuration}
recurringBookings={props.recurringBookings}
allRemainingBookings={allRemainingBookings}
@@ -641,15 +630,15 @@ export default function Success(props: PageProps) {
.add(calculatedDuration, "minute")
.utc()
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
props.eventType.description
eventType.description
}${
typeof locationVideoCallUrl === "string"
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
: ""
}${
props.eventType.recurringEvent
eventType.recurringEvent
? `&recur=${encodeURIComponent(
new RRule(props.eventType.recurringEvent).toString()
new RRule(eventType.recurringEvent).toString()
)}`
: ""
}`}
@@ -667,7 +656,7 @@ export default function Success(props: PageProps) {
href={
encodeURI(
`https://outlook.live.com/calendar/0/deeplink/compose?body=${
props.eventType.description
eventType.description
}&enddt=${date
.add(calculatedDuration, "minute")
.utc()
@@ -694,7 +683,7 @@ export default function Success(props: PageProps) {
href={
encodeURI(
`https://outlook.office.com/calendar/0/deeplink/compose?body=${
props.eventType.description
eventType.description
}&enddt=${date
.add(calculatedDuration, "minute")
.utc()
@@ -720,7 +709,7 @@ export default function Success(props: PageProps) {
<Link
href={`data:text/calendar,${eventLink()}`}
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
download={`${props.eventType.title}.ics`}>
download={`${eventType.title}.ics`}>
<svg
version="1.1"
fill="currentColor"
@@ -837,7 +826,7 @@ type RecurringBookingsProps = {
tz: string;
};
export function RecurringBookings({
function RecurringBookings({
eventType,
recurringBookings,
duration,
@@ -0,0 +1,972 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Trans } from "next-i18next";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { FC } from "react";
import { memo, useEffect, useState } from "react";
import { z } from "zod";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { ShellMain } from "@calcom/features/shell/Shell";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { CAL_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import {
Alert,
Avatar,
Badge,
Button,
ButtonGroup,
ConfirmationDialogContent,
CreateButton,
Dialog,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
EmptyScreen,
HeadSeo,
HorizontalTabs,
Label,
showToast,
Skeleton,
Switch,
Tooltip,
ArrowButton,
UserAvatarGroup,
} from "@calcom/ui";
import {
Clipboard,
Code,
Copy,
Edit,
Edit2,
ExternalLink,
Link as LinkIcon,
MoreHorizontal,
Trash,
Upload,
Users,
VenetianMask,
} from "@calcom/ui/components/icon";
import type { AppProps } from "@lib/app-providers";
import useMeQuery from "@lib/hooks/useMeQuery";
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
interface EventTypeListHeadingProps {
profile: EventTypeGroupProfile;
membershipCount: number;
teamId?: number | null;
bookerUrl: string;
}
type EventTypeGroup = EventTypeGroups[number];
type EventType = EventTypeGroup["eventTypes"][number];
interface EventTypeListProps {
group: EventTypeGroup;
groupIndex: number;
readOnly: boolean;
bookerUrl: string | null;
types: EventType[];
}
interface MobileTeamsTabProps {
eventTypeGroups: EventTypeGroups;
}
const querySchema = z.object({
teamId: z.nullable(z.coerce.number()).optional().default(null),
});
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
const { eventTypeGroups } = props;
const orgBranding = useOrgBranding();
const tabs = eventTypeGroups.map((item) => ({
name: item.profile.name ?? "",
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
avatar: orgBranding
? `${orgBranding.fullDomain}${item.teamId ? "/team" : ""}/${item.profile.slug}/avatar.png`
: item.profile.image ?? `${WEBAPP_URL + (item.teamId && "/team")}/${item.profile.slug}/avatar.png`,
}));
const { data } = useTypedQuery(querySchema);
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
return (
<div>
<HorizontalTabs tabs={tabs} />
{events.length > 0 ? (
<EventTypeList
types={events[0].eventTypes}
group={events[0]}
groupIndex={0}
bookerUrl={events[0].bookerUrl}
readOnly={events[0].metadata.readOnly}
/>
) : (
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
)}
</div>
);
};
const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
const { t } = useLocale();
const content = () => (
<div>
<span
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={`event-type-slug-${type.id}`}>
{`/${
type.schedulingType !== SchedulingType.MANAGED ? group.profile.slug : t("username_placeholder")
}/${type.slug}`}
</small>
) : null}
{readOnly && (
<Badge variant="gray" className="ml-2">
{t("readonly")}
</Badge>
)}
</div>
);
return readOnly ? (
<div className="flex-1 overflow-hidden pr-4 text-sm">
{content()}
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type}
shortenDescription
/>
</div>
) : (
<Link
href={`/event-types/${type.id}?tabName=setup`}
className="flex-1 overflow-hidden pr-4 text-sm"
title={type.title}>
<div>
<span
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={`event-type-slug-${type.id}`}>
{`/${group.profile.slug}/${type.slug}`}
</small>
) : null}
{readOnly && (
<Badge variant="gray" className="ml-2">
{t("readonly")}
</Badge>
)}
</div>
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={{ ...type, descriptionAsSafeHTML: type.safeDescription }}
shortenDescription
/>
</Link>
);
};
const MemoizedItem = memo(Item);
export const EventTypeList = ({
group,
groupIndex,
readOnly,
types,
bookerUrl,
}: EventTypeListProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState<SchedulingType | null>(
null
);
const utils = trpc.useContext();
const mutation = trpc.viewer.eventTypeOrder.useMutation({
onError: async (err) => {
console.error(err.message);
await utils.viewer.eventTypes.getByViewer.cancel();
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
await utils.viewer.eventTypes.invalidate();
},
onSettled: () => {
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
utils.viewer.eventTypes.invalidate();
},
});
const setHiddenMutation = trpc.viewer.eventTypes.update.useMutation({
onMutate: async ({ id }) => {
await utils.viewer.eventTypes.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
const newList = [...types];
const itemIndex = newList.findIndex((item) => item.id === id);
if (itemIndex !== -1 && newList[itemIndex]) {
newList[itemIndex].hidden = !newList[itemIndex].hidden;
}
utils.viewer.eventTypes.getByViewer.setData(undefined, {
...previousValue,
eventTypeGroups: [
...previousValue.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...previousValue.eventTypeGroups.slice(groupIndex + 1),
],
});
}
return { previousValue };
},
onError: async (err, _, context) => {
if (context?.previousValue) {
utils.viewer.eventTypes.getByViewer.setData(undefined, context.previousValue);
}
console.error(err.message);
},
onSettled: () => {
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
utils.viewer.eventTypes.invalidate();
},
});
async function moveEventType(index: number, increment: 1 | -1) {
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
await utils.viewer.eventTypes.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
utils.viewer.eventTypes.getByViewer.setData(undefined, {
...previousValue,
eventTypeGroups: [
...previousValue.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...previousValue.eventTypeGroups.slice(groupIndex + 1),
],
});
}
mutation.mutate({
ids: newList.map((type) => type.id),
});
}
async function deleteEventTypeHandler(id: number) {
const payload = { id };
deleteMutation.mutate(payload);
}
// inject selection data into url for correct router history
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value) newSearchParams.set(key, value.toString());
if (value === null) newSearchParams.delete(key);
}
setParamsIfDefined("dialog", "duplicate");
setParamsIfDefined("title", eventType.title);
setParamsIfDefined("description", eventType.description);
setParamsIfDefined("slug", eventType.slug);
setParamsIfDefined("id", eventType.id);
setParamsIfDefined("length", eventType.length);
setParamsIfDefined("pageSlug", group.profile.slug);
router.push(`${pathname}?${newSearchParams.toString()}`);
};
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: () => {
showToast(t("event_type_deleted_successfully"), "success");
setDeleteDialogOpen(false);
},
onMutate: async ({ id }) => {
await utils.viewer.eventTypes.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
const newList = types.filter((item) => item.id !== id);
utils.viewer.eventTypes.getByViewer.setData(undefined, {
...previousValue,
eventTypeGroups: [
...previousValue.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...previousValue.eventTypeGroups.slice(groupIndex + 1),
],
});
}
return { previousValue };
},
onError: (err, _, context) => {
if (context?.previousValue) {
utils.viewer.eventTypes.getByViewer.setData(undefined, context.previousValue);
}
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setDeleteDialogOpen(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
onSettled: () => {
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
utils.viewer.eventTypes.invalidate();
},
});
const [isNativeShare, setNativeShare] = useState(true);
useEffect(() => {
if (!navigator.share) {
setNativeShare(false);
}
}, []);
if (!types.length) {
return group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
);
}
const firstItem = types[0];
const lastItem = types[types.length - 1];
const isManagedEventPrefix = () => {
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
};
return (
<div className="bg-default border-subtle mb-16 flex overflow-hidden rounded-md border">
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
{types.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${bookerUrl}/${embedLink}`;
const isPrivateURLEnabled = type.hashedLink?.link;
const placeholderHashedLink = `${CAL_URL}/d/${type.hashedLink?.link}/${type.slug}`;
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
return (
<li key={type.id}>
<div className="hover:bg-muted flex w-full items-center justify-between transition">
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
{!(firstItem && firstItem.id === type.id) && (
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === type.id) && (
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
)}
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden sm:mt-0 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{type.team && !isManagedEventType && (
<UserAvatarGroup
className="relative right-3"
size="sm"
truncateAfter={4}
users={type?.users ?? []}
/>
)}
{isManagedEventType && type?.children && type.children?.length > 0 && (
<UserAvatarGroup
className="relative right-3"
size="sm"
truncateAfter={4}
users={type?.children.flatMap((ch) => ch.users) ?? []}
/>
)}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{!isManagedEventType && (
<>
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Tooltip
content={type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")}>
<div className="self-center rounded-md p-2">
<Switch
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
</Tooltip>
</>
)}
<ButtonGroup combined>
{!isManagedEventType && (
<>
<Tooltip content={t("preview")}>
<Button
data-testid="preview-link-button"
color="secondary"
target="_blank"
variant="icon"
href={calLink}
StartIcon={ExternalLink}
/>
</Tooltip>
<Tooltip content={t("copy_link")}>
<Button
color="secondary"
variant="icon"
StartIcon={LinkIcon}
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
/>
</Tooltip>
{isPrivateURLEnabled && (
<Tooltip content={t("copy_private_link_to_event")}>
<Button
color="secondary"
variant="icon"
StartIcon={VenetianMask}
onClick={() => {
showToast(t("private_link_copied"), "success");
navigator.clipboard.writeText(placeholderHashedLink);
}}
/>
</Tooltip>
)}
</>
)}
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
<Button
type="button"
variant="icon"
color="secondary"
StartIcon={MoreHorizontal}
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
{!readOnly && (
<DropdownMenuItem>
<DropdownItem
type="button"
data-testid={`event-type-edit-${type.id}`}
StartIcon={Edit2}
onClick={() => router.push(`/event-types/${type.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={`event-type-duplicate-${type.id}`}
StartIcon={Copy}
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{!isManagedEventType && (
<DropdownMenuItem className="outline-none">
<EventTypeEmbedButton
namespace={type.slug}
as={DropdownItem}
type="button"
StartIcon={Code}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}
eventId={type.id}>
{t("embed")}
</EventTypeEmbedButton>
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
!isChildrenManagedEventType && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon={Trash}
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</ButtonGroup>
</div>
</div>
</div>
</div>
<div className="min-w-9 mx-5 flex sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
<Button type="button" variant="icon" color="secondary" StartIcon={MoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{!isManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
href={calLink}
target="_blank"
StartIcon={ExternalLink}
className="w-full rounded-none">
{t("preview")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={`event-type-duplicate-${type.id}`}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}
StartIcon={Clipboard}
className="w-full rounded-none text-left">
{t("copy_link")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{isNativeShare ? (
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={`event-type-duplicate-${type.id}`}
onClick={() => {
navigator
.share({
title: t("share"),
text: t("share_event", { appName: APP_NAME }),
url: calLink,
})
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
}}
StartIcon={Upload}
className="w-full rounded-none">
{t("share")}
</DropdownItem>
</DropdownMenuItem>
) : null}
{!readOnly && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => router.push(`/event-types/${type.id}`)}
StartIcon={Edit}
className="w-full rounded-none">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
StartIcon={Copy}
data-testid={`event-type-duplicate-${type.id}`}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
!isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon={Trash}
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{!isManagedEventType && (
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 inline cursor-pointer self-center pr-2 ">
{type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")}
</Skeleton>
<Switch
id="hiddenSwitch"
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
</div>
</div>
</li>
);
})}
</ul>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
variety="danger"
title={t(`delete${isManagedEventPrefix()}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
isPending={deleteMutation.isPending}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}>
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEventPrefix()}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>
Anyone who they&apos;ve shared their link with will no longer be able to book using it.
</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
</div>
);
};
const EventTypeListHeading = ({
profile,
membershipCount,
teamId,
bookerUrl,
}: EventTypeListHeadingProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<div className="mb-4 flex items-center space-x-2">
<Avatar
alt={profile.name || ""}
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
imageSrc={profile.image}
size="md"
className="mt-1 inline-flex justify-center"
/>
<div>
<Link
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
className="text-emphasis font-bold">
{profile.name || ""}
</Link>
{membershipCount && teamId && (
<span className="text-subtle relative -top-px me-2 ms-2 text-xs">
<Link href={`/settings/teams/${teamId}/members`}>
<Badge variant="gray">
<Users className="-mt-px mr-1 inline h-3 w-3" />
{membershipCount}
</Badge>
</Link>
</span>
)}
{profile.slug && (
<Link href={`${bookerUrl}/${profile.slug}`} className="text-subtle block text-xs">
{`${bookerUrl.replace("https://", "").replace("http://", "")}/${profile.slug}`}
</Link>
)}
</div>
{!profile.slug && !!teamId && (
<button onClick={() => publishTeamMutation.mutate({ teamId })}>
<Badge variant="gray" className="-ml-2 mb-1">
{t("upgrade")}
</Badge>
</button>
)}
</div>
);
};
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
const { t } = useLocale();
return (
<EmptyScreen
Icon={LinkIcon}
headline={t("new_event_type_heading")}
description={t("new_event_type_description")}
className="mb-16"
buttonRaw={
<Button href={`?dialog=new&eventPage=${slug}`} variant="button">
{t("create")}
</Button>
}
/>
);
};
const CTA = ({ data }: { data: GetByViewerResponse }) => {
const { t } = useLocale();
if (!data) return null;
const profileOptions = data.profiles
.filter((profile) => !profile.readOnly)
.map((profile) => {
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
membershipRole: profile.membershipRole,
slug: profile.slug,
};
});
return (
<CreateButton
data-testid="new-event-type"
subtitle={t("create_event_on").toUpperCase()}
options={profileOptions}
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
/>
);
};
const Actions = () => {
return (
<div className="hidden items-center md:flex">
<TeamsFilter useProfileFilter popoverTriggerClassNames="mb-0" showVerticalDivider={true} />
</div>
);
};
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
const { t } = useLocale();
return (
<>
<EmptyScreen
headline={t("team_no_event_types")}
buttonRaw={
<Button
href={`?dialog=new&eventPage=${group.profile.slug}&teamId=${group.teamId}`}
variant="button"
className="mt-5">
{t("create")}
</Button>
}
/>
</>
);
};
const Main = ({
status,
errorMessage,
data,
filters,
}: {
status: string;
data: GetByViewerResponse;
errorMessage?: string;
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
}) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const searchParams = useCompatSearchParams();
if (!data || status === "pending") {
return <SkeletonLoader />;
}
if (status === "error") {
return <Alert severity="error" title="Something went wrong" message={errorMessage} />;
}
const isFilteredByOnlyOneItem =
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) && data.eventTypeGroups.length === 1;
return (
<>
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
<>
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
<div
className="mt-4 flex flex-col"
data-testid={`slug-${group.profile.slug}`}
key={group.profile.slug}>
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
bookerUrl={group.bookerUrl}
/>
{group.eventTypes.length ? (
<EventTypeList
types={group.eventTypes}
group={group}
bookerUrl={group.bookerUrl}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
) : group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
)}
</div>
))
)}
</>
) : (
data.eventTypeGroups.length === 1 && (
<EventTypeList
types={data.eventTypeGroups[0].eventTypes}
group={data.eventTypeGroups[0]}
groupIndex={0}
bookerUrl={data.eventTypeGroups[0].bookerUrl}
readOnly={data.eventTypeGroups[0].metadata.readOnly}
/>
)
)}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
<EventTypeEmbedDialog />
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
</>
);
};
const EventTypesPage: React.FC & {
PageWrapper?: AppProps["Component"]["PageWrapper"];
getLayout?: AppProps["Component"]["getLayout"];
} = () => {
const { t } = useLocale();
const searchParams = useCompatSearchParams();
const { open } = useIntercom();
const { data: user } = useMeQuery();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [showProfileBanner, setShowProfileBanner] = useState(false);
const orgBranding = useOrgBranding();
const routerQuery = useRouterQuery();
const filters = getTeamsFiltersFromQuery(routerQuery);
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
refetchOnWindowFocus: false,
gcTime: 1 * 60 * 60 * 1000,
staleTime: 1 * 60 * 60 * 1000,
});
useEffect(() => {
if (searchParams?.get("openIntercom") === "true") {
open();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setShowProfileBanner(
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
);
}, [orgBranding, user]);
return (
<ShellMain
withoutSeo
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
beforeCTAactions={<Actions />}
CTA={<CTA data={data} />}>
<HeadSeo
title="Event Types"
description="Create events to share for people to book on your calendar."
/>
<Main data={data} status={status} errorMessage={error?.message} filters={filters} />
</ShellMain>
);
};
export default EventTypesPage;
@@ -18,23 +18,30 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const ssr = await ssrInit(context);
if (Number.isNaN(typeParam)) {
return {
const notFound = {
notFound: true,
};
} as const;
return notFound;
}
if (!session?.user?.id) {
return {
const redirect = {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
} as const;
return redirect;
}
await ssr.viewer.eventTypes.get.prefetch({ id: typeParam });
const { eventType } = await ssr.viewer.eventTypes.get.fetch({ id: typeParam });
return {
props: {
eventType,
type: typeParam,
trpcState: ssr.dehydrate(),
},
@@ -0,0 +1,706 @@
"use client";
/* eslint-disable @typescript-eslint/no-empty-function */
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { getEventLocationType } from "@calcom/app-store/locations";
import { validateCustomEventName } from "@calcom/core/event";
import type { EventLocationType } from "@calcom/core/location";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { Prisma } from "@calcom/prisma/client";
import type { PeriodType, SchedulingType } from "@calcom/prisma/enums";
import type {
BookerLayoutSettings,
customInputSchema,
EventTypeMetaDataSchema,
} from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
import { Form, showToast } from "@calcom/ui";
import type { AppProps } from "@lib/app-providers";
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
const EventSetupTab = dynamic(() =>
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
);
const EventAvailabilityTab = dynamic(() =>
import("@components/eventtype/EventAvailabilityTab").then((mod) => mod.EventAvailabilityTab)
);
const EventTeamTab = dynamic(() =>
import("@components/eventtype/EventTeamTab").then((mod) => mod.EventTeamTab)
);
const EventLimitsTab = dynamic(() =>
import("@components/eventtype/EventLimitsTab").then((mod) => mod.EventLimitsTab)
);
const EventAdvancedTab = dynamic(() =>
import("@components/eventtype/EventAdvancedTab").then((mod) => mod.EventAdvancedTab)
);
const EventInstantTab = dynamic(() =>
import("@components/eventtype/EventInstantTab").then((mod) => mod.EventInstantTab)
);
const EventRecurringTab = dynamic(() =>
import("@components/eventtype/EventRecurringTab").then((mod) => mod.EventRecurringTab)
);
const EventAppsTab = dynamic(() =>
import("@components/eventtype/EventAppsTab").then((mod) => mod.EventAppsTab)
);
const EventWorkflowsTab = dynamic(() => import("@components/eventtype/EventWorkfowsTab"));
const EventWebhooksTab = dynamic(() =>
import("@components/eventtype/EventWebhooksTab").then((mod) => mod.EventWebhooksTab)
);
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
export type FormValues = {
id: number;
title: string;
eventTitle: string;
eventName: string;
slug: string;
isInstantEvent: boolean;
length: number;
offsetStart: number;
description: string;
disableGuests: boolean;
lockTimeZoneToggleOnBookingPage: boolean;
requiresConfirmation: boolean;
requiresBookerEmailVerification: boolean;
recurringEvent: RecurringEvent | null;
schedulingType: SchedulingType | null;
hidden: boolean;
hideCalendarNotes: boolean;
hashedLink: string | undefined;
locations: {
type: EventLocationType["type"];
address?: string;
attendeeAddress?: string;
link?: string;
hostPhoneNumber?: string;
displayLocationPublicly?: boolean;
phone?: string;
hostDefault?: string;
credentialId?: number;
teamName?: string;
}[];
customInputs: CustomInputParsed[];
schedule: number | null;
periodType: PeriodType;
periodDays: number;
periodCountCalendarDays: "1" | "0";
periodDates: { startDate: Date; endDate: Date };
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
seatsPerTimeSlotEnabled: boolean;
scheduleName: string;
minimumBookingNotice: number;
minimumBookingNoticeInDurationType: number;
beforeEventBuffer: number;
afterEventBuffer: number;
slotInterval: number | null;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
destinationCalendar: {
integration: string;
externalId: string;
};
successRedirectUrl: string;
durationLimits?: IntervalLimit;
bookingLimits?: IntervalLimit;
onlyShowFirstAvailableSlot: boolean;
children: ChildrenEventType[];
hosts: { userId: number; isFixed: boolean }[];
bookingFields: z.infer<typeof eventTypeBookingFields>;
availability?: AvailabilityOption;
bookerLayouts: BookerLayoutSettings;
multipleDurationEnabled: boolean;
users: EventTypeSetup["users"];
assignAllTeamMembers: boolean;
};
export type CustomInputParsed = typeof customInputSchema._output;
const querySchema = z.object({
tabName: z
.enum([
"setup",
"availability",
"apps",
"limits",
"instant",
"recurring",
"team",
"advanced",
"workflows",
"webhooks",
])
.optional()
.default("setup"),
});
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
const EventTypePage = (props: EventTypeSetupProps) => {
const { t } = useLocale();
const utils = trpc.useContext();
const telemetry = useTelemetry();
const {
data: { tabName },
} = useTypedQuery(querySchema);
const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({
extendsFeature: "EventType",
teamId: props.eventType.team?.id || props.eventType.parent?.teamId,
onlyInstalled: true,
});
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
formMethods.setValue(
"children",
formMethods.getValues().children.map((child) => ({
...child,
created: true,
}))
);
formMethods.setValue("assignAllTeamMembers", formMethods.getValues("assignAllTeamMembers") || false);
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`;
}
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
message = `${err.data.code}: ${t(err.message)}`;
}
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
message = t("unexpected_error_try_again");
}
showToast(message ? t(message) : t(err.message), "error");
},
});
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const metadata = eventType.metadata;
// fallback to !!eventType.schedule when 'useHostSchedulesForTeamEvent' is undefined
if (!!team && metadata !== null) {
metadata.config = {
...metadata.config,
useHostSchedulesForTeamEvent:
typeof eventType.metadata?.config?.useHostSchedulesForTeamEvent !== "undefined"
? eventType.metadata?.config?.useHostSchedulesForTeamEvent === true
: !!eventType.schedule,
};
} else {
// Make sure non-team events NEVER have this config key;
delete metadata?.config?.useHostSchedulesForTeamEvent;
}
const bookingFields: Prisma.JsonObject = {};
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = name;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const defaultValues: any = useMemo(() => {
return {
title: eventType.title,
id: eventType.id,
slug: eventType.slug,
afterEventBuffer: eventType.afterEventBuffer,
beforeEventBuffer: eventType.beforeEventBuffer,
eventName: eventType.eventName || "",
scheduleName: eventType.scheduleName,
periodDays: eventType.periodDays || 30,
requiresBookerEmailVerification: eventType.requiresBookerEmailVerification,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
seatsShowAttendees: eventType.seatsShowAttendees,
seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount,
lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage,
locations: eventType.locations || [],
destinationCalendar: eventType.destinationCalendar,
recurringEvent: eventType.recurringEvent || null,
isInstantEvent: eventType.isInstantEvent,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined,
durationLimits: eventType.durationLimits || undefined,
length: eventType.length,
hidden: eventType.hidden,
hashedLink: eventType.hashedLink?.link || undefined,
periodDates: {
startDate: periodDates.startDate,
endDate: periodDates.endDate,
},
hideCalendarNotes: eventType.hideCalendarNotes,
offsetStart: eventType.offsetStart,
bookingFields: eventType.bookingFields,
periodType: eventType.periodType,
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
schedulingType: eventType.schedulingType,
requiresConfirmation: eventType.requiresConfirmation,
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata,
hosts: eventType.hosts,
successRedirectUrl: eventType.successRedirectUrl || "",
users: eventType.users,
children: eventType.children.map((ch) => ({
...ch,
created: true,
owner: {
...ch.owner,
eventTypeSlugs:
eventType.team?.members
.find((mem) => mem.user.id === ch.owner.id)
?.user.eventTypes.map((evTy) => evTy.slug)
.filter((slug) => slug !== eventType.slug) ?? [],
},
})),
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
assignAllTeamMembers: eventType.assignAllTeamMembers,
};
}, [eventType, periodDates, metadata]);
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(
z
.object({
// Length if string, is converted to a number or it can be a number
// Make it optional because it's not submitted from all tabs of the page
eventName: z
.string()
.refine(
(val) =>
validateCustomEventName(val, t("invalid_event_name_variables"), bookingFields) === true,
{
message: t("invalid_event_name_variables"),
}
)
.optional(),
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
bookingFields: eventTypeBookingFields,
locations: z
.array(
z
.object({
type: z.string(),
address: z.string().optional(),
link: z.string().url().optional(),
phone: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
hostPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
displayLocationPublicly: z.boolean().optional(),
credentialId: z.number().optional(),
teamName: z.string().optional(),
})
.passthrough()
.superRefine((val, ctx) => {
if (val?.link) {
const link = val.link;
const eventLocationType = getEventLocationType(val.type);
if (
eventLocationType &&
!eventLocationType.default &&
eventLocationType.linkType === "static" &&
eventLocationType.urlRegExp
) {
const valid = z
.string()
.regex(new RegExp(eventLocationType.urlRegExp))
.safeParse(link).success;
if (!valid) {
const sampleUrl = eventLocationType.organizerInputPlaceholder;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: t("invalid_url_error_message", {
label: eventLocationType.label,
sampleUrl: sampleUrl ?? "https://cal.com",
}),
});
}
return;
}
const valid = z.string().url().optional().safeParse(link).success;
if (!valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: `Invalid URL`,
});
}
}
return;
})
)
.optional(),
})
// TODO: Add schema for other fields later.
.passthrough()
),
});
useEffect(() => {
if (!formMethods.formState.isDirty) {
//TODO: What's the best way to sync the form with backend
formMethods.setValue("bookingFields", defaultValues.bookingFields);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues]);
const appsMetadata = formMethods.getValues("metadata")?.apps;
const availability = formMethods.watch("availability");
let numberOfActiveApps = 0;
if (appsMetadata) {
numberOfActiveApps = Object.entries(appsMetadata).filter(
([appId, appData]) =>
eventTypeApps?.items.find((app) => app.slug === appId)?.isInstalled && appData.enabled
).length;
}
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
const tabMap = {
setup: (
<EventSetupTab
eventType={eventType}
locationOptions={locationOptions}
team={team}
teamMembers={teamMembers}
destinationCalendar={destinationCalendar}
/>
),
availability: <EventAvailabilityTab eventType={eventType} isTeamEvent={!!team} />,
team: <EventTeamTab teamMembers={teamMembers} team={team} eventType={eventType} />,
limits: <EventLimitsTab />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
workflows: (
<EventWorkflowsTab
eventType={eventType}
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
/>
),
webhooks: <EventWebhooksTab eventType={eventType} />,
} as const;
const handleSubmit = async (values: FormValues) => {
const {
periodDates,
periodCountCalendarDays,
beforeEventBuffer,
afterEventBuffer,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
recurringEvent,
locations,
metadata,
customInputs,
children,
assignAllTeamMembers,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
minimumBookingNoticeInDurationType,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
bookerLayouts,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled,
length,
...input
} = values;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (!length && !metadata?.multipleDuration?.includes(length)) {
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
throw new Error(t("event_setup_multiple_payment_apps_error"));
if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
updateMutation.mutate({
...rest,
length,
locations,
recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
metadata,
customInputs,
children,
assignAllTeamMembers,
});
};
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
const slug = formMethods.watch("slug") ?? eventType.slug;
// Optional prerender all tabs after 300 ms on mount
useEffect(() => {
const timeout = setTimeout(() => {
const Components = [
EventSetupTab,
EventAvailabilityTab,
EventTeamTab,
EventLimitsTab,
EventAdvancedTab,
EventInstantTab,
EventRecurringTab,
EventAppsTab,
EventWorkflowsTab,
EventWebhooksTab,
];
Components.forEach((C) => {
// @ts-expect-error Property 'render' does not exist on type 'ComponentClass
C.render.preload();
});
}, 300);
return () => {
clearTimeout(timeout);
};
}, []);
return (
<>
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={eventTypeApps?.items.length || 0}
enabledWorkflowsNumber={eventType.workflows.length}
eventType={eventType}
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
team={team}
availability={availability}
isUpdateMutationLoading={updateMutation.isPending}
formMethods={formMethods}
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
disableBorder={true}
currentUserMembership={currentUserMembership}
bookerUrl={eventType.bookerUrl}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
<Form
form={formMethods}
id="event-type-form"
handleSubmit={async (values) => {
const {
periodDates,
periodCountCalendarDays,
beforeEventBuffer,
afterEventBuffer,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
recurringEvent,
locations,
metadata,
customInputs,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled,
length,
...input
} = values;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (!length && !metadata?.multipleDuration?.includes(length)) {
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
throw new Error(t("event_setup_multiple_payment_apps_error"));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
updateMutation.mutate({
...rest,
length,
locations,
recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
metadata,
customInputs,
});
}}>
<div ref={animationParentRef}>{tabMap[tabName]}</div>
</Form>
</EventTypeSingleLayout>
{slugExistsChildrenDialogOpen.length ? (
<ManagedEventTypeDialog
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
isPending={formMethods.formState.isSubmitting}
onOpenChange={() => {
setSlugExistsChildrenDialogOpen([]);
}}
slug={slug}
onConfirm={(e: { preventDefault: () => void }) => {
e.preventDefault();
handleSubmit(formMethods.getValues());
telemetry.event(telemetryEventTypes.slugReplacementAction);
setSlugExistsChildrenDialogOpen([]);
}}
/>
) : null}
</>
);
};
const EventTypePageWrapper: React.FC<PageProps> & {
PageWrapper?: AppProps["Component"]["PageWrapper"];
getLayout?: AppProps["Component"]["getLayout"];
} = (props) => {
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
if (!data) return null;
return <EventTypePage {...(data as EventTypeSetupProps)} />;
};
export default EventTypePageWrapper;
@@ -0,0 +1,277 @@
import type { DehydratedState } from "@tanstack/react-query";
import type { GetServerSideProps } from "next";
import { encode } from "querystring";
import type { z } from "zod";
import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import type { EmbedProps } from "@lib/withEmbedSsr";
import { ssrInit } from "@server/lib/ssr";
const log = logger.getSubLogger({ prefix: ["[[pages/[user]]]"] });
export type UserPageProps = {
trpcState: DehydratedState;
profile: {
name: string;
image: string;
theme: string | null;
brandColor: string;
darkBrandColor: string;
organization: {
requestedSlug: string | null;
slug: string | null;
id: number | null;
} | null;
allowSEOIndexing: boolean;
username: string | null;
};
users: (Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl"> & {
profile: UserProfile;
})[];
themeBasis: string | null;
markdownStrippedBio: string;
safeBio: string;
entity: {
isUnpublished?: boolean;
orgSlug?: string | null;
name?: string | null;
};
eventTypes: ({
descriptionAsSafeHTML: string;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
} & Pick<
EventType,
| "id"
| "title"
| "slug"
| "length"
| "hidden"
| "lockTimeZoneToggleOnBookingPage"
| "requiresConfirmation"
| "requiresBookerEmailVerification"
| "price"
| "currency"
| "recurringEvent"
>)[];
} & EmbedProps;
export const getEventTypesWithHiddenFromDB = async (userId: number) => {
const eventTypes = await prisma.eventType.findMany({
where: {
AND: [
{
teamId: null,
},
{
OR: [
{
userId,
},
{
users: {
some: {
id: userId,
},
},
},
],
},
],
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
select: {
...baseEventTypeSelect,
metadata: true,
},
});
// map and filter metadata, exclude eventType entirely when faulty metadata is found.
// report error to exception so we don't lose the error.
return eventTypes.reduce<typeof eventTypes>((eventTypes, eventType) => {
const parsedMetadata = EventTypeMetaDataSchema.safeParse(eventType.metadata);
if (!parsedMetadata.success) {
log.error(parsedMetadata.error);
return eventTypes;
}
eventTypes.push({
...eventType,
metadata: parsedMetadata.data,
});
return eventTypes;
}, []);
};
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
let outOfOffice = false;
if (usernameList.length === 1) {
const result = await handleUserRedirection({ username: usernameList[0] });
if (result && result.outOfOffice) {
outOfOffice = true;
}
if (result && result.redirect?.destination) {
return result;
}
}
if (!isOrgContext) {
// If there is no org context, see if some redirect is setup due to org migration
const redirect = await getTemporaryOrgRedirect({
slugs: usernameList,
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
}
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const usersWithoutAvatar = usersInOrgContext.map((user) => {
const { avatar: _1, ...rest } = user;
return rest;
});
const isDynamicGroup = usersWithoutAvatar.length > 1;
log.debug(safeStringify({ usersInOrgContext, isValidOrgDomain, currentOrgDomain, isDynamicGroup }));
if (isDynamicGroup) {
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
log.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
return {
redirect: {
permanent: false,
destination: destinationUrl,
},
} as {
redirect: {
permanent: false;
destination: string;
};
};
}
const users = usersWithoutAvatar.map((user) => ({
...user,
avatar: `/${user.username}/avatar.png`,
}));
const isNonOrgUser = (user: { profile: UserProfile }) => {
return !user.profile?.organization;
};
const isThereAnyNonOrgUser = users.some(isNonOrgUser);
if (!users.length || (!isValidOrgDomain && !isThereAnyNonOrgUser)) {
return {
notFound: true,
} as {
notFound: true;
};
}
const [user] = users; //to be used when dealing with single user, not dynamic group
const profile = {
name: user.name || user.username || "",
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: user.profile.organization,
};
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id);
const dataFetchEnd = Date.now();
if (context.query.log === "1") {
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
}
const eventTypesRaw = eventTypesWithHidden.filter((evt) => !evt.hidden);
const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}));
// if profile only has one public event-type, redirect to it
if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) {
// Redirect but don't change the URL
const urlDestination = `/${user.profile.username}/${eventTypes[0].slug}`;
const { query } = context;
const urlQuery = new URLSearchParams(encode(query));
return {
redirect: {
permanent: false,
destination: `${urlDestination}?${urlQuery}`,
},
};
}
const safeBio = markdownToSafeHTML(user.bio) || "";
const markdownStrippedBio = stripMarkdown(user?.bio || "");
const org = usersWithoutAvatar[0].profile.organization;
return {
props: {
users: users.map((user) => ({
name: user.name,
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
away: usernameList.length === 1 ? outOfOffice : user.away,
verified: user.verified,
profile: user.profile,
})),
entity: {
isUnpublished: org?.slug === null,
orgSlug: currentOrgDomain,
name: org?.name ?? null,
},
eventTypes,
safeBio,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: user.username,
trpcState: ssr.dehydrate(),
markdownStrippedBio,
},
};
};
@@ -0,0 +1,187 @@
"use client";
import classNames from "classnames";
import type { InferGetServerSidePropsType } from "next";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Toaster } from "react-hot-toast";
import {
sdkActionManager,
useEmbedNonStylesConfig,
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import { HeadSeo, UnpublishedEntity, UserAvatar } from "@calcom/ui";
import { ArrowRight, Verified } from "@calcom/ui/components/icon";
import { type getServerSideProps } from "./users-public-view.getServerSideProps";
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
const searchParams = useSearchParams();
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(profile.theme);
const { t } = useLocale();
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
const isEmbed = useIsEmbed(props.isEmbed);
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const {
// So it doesn't display in the Link (and make tests fail)
user: _user,
orgSlug: _orgSlug,
redirect: _redirect,
...query
} = useRouterQuery();
const isRedirect = searchParams?.get("redirected") === "true" || false;
const fromUserNameRedirected = searchParams?.get("username") || "";
/*
const telemetry = useTelemetry();
useEffect(() => {
if (top !== window) {
//page_view will be collected automatically by _middleware.ts
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/[user]"));
}
}, [telemetry, router.asPath]); */
if (entity?.isUnpublished) {
return (
<div className="flex h-full min-h-[100dvh] items-center justify-center">
<UnpublishedEntity {...entity} />
</div>
);
}
const isEventListEmpty = eventTypes.length === 0;
return (
<>
<HeadSeo
title={profile.name}
description={markdownStrippedBio}
meeting={{
title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
users: [{ username: `${user.username}`, name: `${user.name}` }],
}}
nextSeoProps={{
noindex: !profile.allowSEOIndexing,
nofollow: !profile.allowSEOIndexing,
}}
/>
<div className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "max-w-3xl" : "")}>
<main
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
"max-w-3xl px-4 py-24"
)}>
{isRedirect && (
<div className="mb-8 rounded-md bg-blue-100 p-4 dark:border dark:bg-transparent dark:bg-transparent">
<h2 className="text-default mb-2 text-sm font-semibold dark:text-white">
{t("user_redirect_title", {
username: fromUserNameRedirected,
})}{" "}
🏝️
</h2>
<p className="text-default text-sm">
{t("user_redirect_description", {
profile: {
username: user.username,
},
username: fromUserNameRedirected,
})}{" "}
😄
</p>
</div>
)}
<div className="mb-8 text-center">
<UserAvatar
size="xl"
user={{
avatarUrl: user.avatarUrl,
profile: user.profile,
name: profile.name,
username: profile.username,
}}
/>
<h1 className="font-cal text-emphasis my-1 text-3xl" data-testid="name-title">
{profile.name}
{user.verified && (
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
)}
</h1>
{!isBioEmpty && (
<>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: props.safeBio }}
/>
</>
)}
</div>
<div
className={classNames("rounded-md ", !isEventListEmpty && "border-subtle border")}
data-testid="event-types">
{user.away ? (
<div className="overflow-hidden rounded-sm border ">
<div className="text-muted p-8 text-center">
<h2 className="font-cal text-default mb-2 text-3xl">😴{` ${t("user_away")}`}</h2>
<p className="mx-auto max-w-md">{t("user_away_description") as string}</p>
</div>
</div>
) : (
eventTypes.map((type) => (
<div
key={type.id}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0">
<ArrowRight className="text-emphasis absolute right-4 top-4 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100" />
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
<div className="block w-full p-5">
<Link
prefetch={false}
href={{
pathname: `/${user.username}/${type.slug}`,
query,
}}
passHref
onClick={async () => {
sdkActionManager?.fire("eventTypeSelected", {
eventType: type,
});
}}
data-testid="event-type-link">
<div className="flex flex-wrap items-center">
<h2 className=" text-default pr-2 text-sm font-semibold">{type.title}</h2>
</div>
<EventTypeDescription eventType={type} isPublic={true} shortenDescription />
</Link>
</div>
</div>
))
)}
</div>
{isEventListEmpty && <EmptyPage name={profile.name || "User"} />}
</main>
<Toaster position="bottom-right" />
</div>
</>
);
}
export default UserPage;
@@ -5,13 +5,17 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import { type EmbedProps } from "@lib/withEmbedSsr";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
@@ -35,22 +39,13 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
}
}
const users = await prisma.user.findMany({
where: {
username: {
in: usernames,
},
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
allowDynamicBooking: true,
},
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList: usernames,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const users = usersInOrgContext;
if (!users.length) {
return {
notFound: true,
@@ -125,16 +120,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findFirst({
where: {
username,
organization: userOrgQuery(context.req, context.params?.orgSlug),
},
select: {
id: true,
hideBranding: true,
allowSEOIndexing: true,
},
const [user] = await UserRepository.findUsersByUsername({
usernameList: [username],
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
if (!user) {
@@ -6,14 +6,7 @@ import { Booker } from "@calcom/atoms";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getServerSideProps } from "@lib/[user]/[type]/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
export type PageProps = Omit<inferSSRProps<typeof getServerSideProps>, "trpcState"> & EmbedProps;
export { getServerSideProps };
import { type PageProps } from "./users-type-public-view.getServerSideProps";
export const getMultipleDurationValue = (
multipleDurationConfig: number[] | undefined,
@@ -69,6 +62,3 @@ export default function Type({
</main>
);
}
Type.isBookingPage = true;
Type.PageWrapper = PageWrapper;

Some files were not shown because too many files have changed in this diff Show More