Compare commits

..
84 Commits
Author SHA1 Message Date
Keith Williams 903a154dbc v3.7.14
release.yml / release (push) Canceled after 0s
2024-02-06 19:52:05 -03:00
Carina WollendorferandCarinaWolli 1a3d54a0d0 fix: org user event types with same event slug (#13571)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-06 22:50:02 +00:00
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
402 changed files with 12193 additions and 5715 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.
+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"),
})
);
@@ -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 =
+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">;
@@ -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">;
@@ -215,6 +215,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
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}
@@ -231,6 +232,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
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}
@@ -252,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}
@@ -266,6 +269,7 @@ 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"
{...formMethods.register("successRedirectUrl")}
@@ -274,7 +278,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
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>
@@ -295,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">
@@ -398,12 +404,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
data-testid="seats-per-time-slot"
/>
<div className="mt-4">
<Controller
name="seatsShowAttendees"
render={({ field: { value, onChange } }) => (
<CheckboxField
data-testid="show-attendees"
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => onChange(e)}
@@ -446,6 +454,7 @@ 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"
/>
)}
/>
@@ -146,9 +146,9 @@ export const EventLimitsTab = () => {
<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"
name="beforeEventBuffer"
render={({ field: { onChange, value } }) => {
const beforeBufferOptions = [
{
@@ -176,9 +176,9 @@ export const EventLimitsTab = () => {
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
<Label htmlFor="afterEventBuffer">{t("after_event")}</Label>
<Controller
name="afterBufferTime"
name="afterEventBuffer"
render={({ field: { onChange, value } }) => {
const afterBufferOptions = [
{
+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>
);
@@ -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")}
@@ -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"
+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";
+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">
@@ -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 (
+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;
@@ -2,6 +2,9 @@ import type { GetServerSidePropsContext } from "next";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const booking = await prisma.booking.findUnique({
where: {
@@ -0,0 +1,62 @@
"use client";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import { Button, HeadSeo } from "@calcom/ui";
import { ArrowRight, Calendar, X } from "@calcom/ui/components/icon";
import { type PageProps } from "./videos-meeting-ended-single-view.getServerSideProps";
export default function MeetingUnavailable(props: PageProps) {
const { t } = useLocale();
return (
<div>
<HeadSeo title="Meeting Unavailable" description="Meeting Unavailable" />
<main className="mx-auto my-24 max-w-3xl">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
<div
className="bg-default inline-block transform overflow-hidden rounded-lg px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<X className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-headline">
This meeting is in the past.
</h3>
</div>
<div className="mt-4 border-b border-t py-4">
<h2 className="font-cal text-default mb-2 text-center text-lg font-medium">
{props.booking.title}
</h2>
<p className="text-subtle text-center">
<Calendar className="-mt-1 mr-1 inline-block h-4 w-4" />
{dayjs(props.booking.startTime).format(`${detectBrowserTimeFormat}, dddd DD MMMM YYYY`)}
</p>
</div>
</div>
<div className="mt-5 text-center sm:mt-6">
<div className="mt-5">
<Button data-testid="return-home" href="/event-types" EndIcon={ArrowRight}>
{t("go_back")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
@@ -1,6 +1,9 @@
import type { GetServerSidePropsContext } from "next";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
// change the type
export async function getServerSideProps(context: GetServerSidePropsContext) {
@@ -0,0 +1,38 @@
"use client";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import { Button, HeadSeo, EmptyScreen } from "@calcom/ui";
import { ArrowRight, Calendar, Clock } from "@calcom/ui/components/icon";
import { type PageProps } from "./videos-meeting-not-started-single-view.getServerSideProps";
export default function MeetingNotStarted(props: PageProps) {
const { t } = useLocale();
return (
<>
<HeadSeo title={t("this_meeting_has_not_started_yet")} description={props.booking.title} />
<main className="mx-auto my-24 max-w-3xl">
<EmptyScreen
Icon={Clock}
headline={t("this_meeting_has_not_started_yet")}
description={
<>
<h2 className="mb-2 text-center font-medium">{props.booking.title}</h2>
<p className="text-subtle text-center">
<Calendar className="-mt-1 mr-1 inline-block h-4 w-4" />
{dayjs(props.booking.startTime).format(`${detectBrowserTimeFormat}, dddd DD MMMM YYYY`)}
</p>
</>
}
buttonRaw={
<Button data-testid="return-home" href="/event-types" EndIcon={ArrowRight}>
{t("go_back")}
</Button>
}
/>
</main>
</>
);
}
@@ -0,0 +1,27 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, EmptyScreen, HeadSeo } from "@calcom/ui";
import { X, ArrowRight } from "@calcom/ui/components/icon";
export default function NoMeetingFound() {
const { t } = useLocale();
return (
<>
<HeadSeo title={t("no_meeting_found")} description={t("no_meeting_found")} />
<main className="mx-auto my-24 max-w-3xl">
<EmptyScreen
Icon={X}
headline={t("no_meeting_found")}
description={t("no_meeting_found_description")}
buttonRaw={
<Button data-testid="return-home" href="/event-types" EndIcon={ArrowRight}>
{t("go_back_home")}
</Button>
}
/>
</main>
</>
);
}
@@ -3,10 +3,15 @@ import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import { ssrInit } from "@server/lib/ssr";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export async function getServerSideProps(context: GetServerSidePropsContext) {
@@ -29,11 +34,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
timeZone: true,
name: true,
email: true,
organization: {
select: {
calVideoLogo: true,
},
},
username: true,
},
},
references: {
@@ -59,6 +60,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const profile = booking.user
? (
await UserRepository.enrichUserWithItsProfile({
user: booking.user,
})
).profile
: null;
//daily.co calls have a 60 minute exit buffer when a user enters a call when it's not available it will trigger the modals
const now = new Date();
const exitDate = new Date(now.getTime() - 60 * 60 * 1000);
@@ -98,6 +107,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking: {
...bookingObj,
...(bookingObj.description && { description: md.render(bookingObj.description) }),
user: bookingObj.user
? {
...bookingObj.user,
organization: profile?.organization,
}
: bookingObj.user,
},
trpcState: ssr.dehydrate(),
},
@@ -0,0 +1,243 @@
"use client";
import DailyIframe from "@daily-co/daily-js";
import Head from "next/head";
import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { ChevronRight } from "@calcom/ui/components/icon";
import { type PageProps } from "./videos-single-view.getServerSideProps";
export default function JoinCall(props: PageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking } = props;
useEffect(() => {
const callFrame = DailyIframe.createFrame({
theme: {
colors: {
accent: "#FFF",
accentText: "#111111",
background: "#111111",
backgroundAccent: "#111111",
baseText: "#FFF",
border: "#292929",
mainAreaBg: "#111111",
mainAreaBgAccent: "#1A1A1A",
mainAreaText: "#FFF",
supportiveText: "#FFF",
},
},
showLeaveButton: true,
iframeStyle: {
position: "fixed",
width: "100%",
height: "100%",
},
url: meetingUrl,
...(typeof meetingPassword === "string" && { token: meetingPassword }),
});
callFrame.join();
return () => {
callFrame.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const title = `${APP_NAME} Video`;
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={t("quick_video_meeting")} />
<meta property="og:image" content={SEO_IMG_OGIMG_VIDEO} />
<meta property="og:type" content="website" />
<meta property="og:url" content={`${WEBSITE_URL}/video`} />
<meta property="og:title" content={`${APP_NAME} Video`} />
<meta property="og:description" content={t("quick_video_meeting")} />
<meta property="twitter:image" content={SEO_IMG_OGIMG_VIDEO} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={`${WEBSITE_URL}/video`} />
<meta property="twitter:title" content={`${APP_NAME} Video`} />
<meta property="twitter:description" content={t("quick_video_meeting")} />
</Head>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
left: 32,
}}
/>
) : (
<img
className="fixed z-10 hidden sm:inline-block"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Logo"
style={{
top: 32,
left: 32,
}}
/>
)}
</div>
<VideoMeetingInfo booking={booking} />
</>
);
}
interface ProgressBarProps {
startTime: string;
endTime: string;
}
function ProgressBar(props: ProgressBarProps) {
const { t } = useLocale();
const { startTime, endTime } = props;
const currentTime = dayjs().second(0).millisecond(0);
const startingTime = dayjs(startTime).second(0).millisecond(0);
const isPast = currentTime.isAfter(startingTime);
const currentDifference = dayjs().diff(startingTime, "minutes");
const startDuration = dayjs(endTime).diff(startingTime, "minutes");
const [duration, setDuration] = useState(() => {
if (currentDifference >= 0 && isPast) {
return startDuration - currentDifference;
} else {
return startDuration;
}
});
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const now = dayjs();
const remainingMilliseconds = (60 - now.get("seconds")) * 1000 - now.get("milliseconds");
timeoutRef.current = setTimeout(() => {
const past = dayjs().isAfter(startingTime);
if (past) {
setDuration((prev) => prev - 1);
}
intervalRef.current = setInterval(() => {
if (dayjs().isAfter(startingTime)) {
setDuration((prev) => prev - 1);
}
}, 60000);
}, remainingMilliseconds);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prev = startDuration - duration;
const percentage = prev * (100 / startDuration);
return (
<div>
<p>
{duration} {t("minutes")}
</p>
<div className="relative h-2 max-w-xl overflow-hidden rounded-full">
<div className="absolute h-full w-full bg-gray-500/10" />
<div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} />
</div>
</div>
);
}
interface VideoMeetingInfo {
booking: PageProps["booking"];
}
export function VideoMeetingInfo(props: VideoMeetingInfo) {
const [open, setOpen] = useState(false);
const { booking } = props;
const { t } = useLocale();
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
return (
<>
<aside
className={classNames(
"no-scrollbar fixed left-0 top-0 z-30 flex h-full w-64 transform justify-between overflow-x-hidden overflow-y-scroll transition-all duration-300 ease-in-out",
open ? "translate-x-0" : "-translate-x-[232px]"
)}>
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden border-r border-gray-300/20 bg-black/80 p-4 text-white shadow-sm backdrop-blur-lg">
<h3>{t("what")}:</h3>
<p>{booking.title}</p>
<h3>{t("invitee_timezone")}:</h3>
<p>{booking.user?.timeZone}</p>
<h3>{t("when")}:</h3>
<p suppressHydrationWarning={true}>
{formatToLocalizedDate(startTime)} <br />
{formatToLocalizedTime(startTime)}
</p>
<h3>{t("time_left")}</h3>
<ProgressBar
key={String(open)}
endTime={endTime.toISOString()}
startTime={startTime.toISOString()}
/>
<h3>{t("who")}:</h3>
<p>
{booking?.user?.name} - {t("organizer")}:{" "}
<a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a>
</p>
{booking.attendees.length
? booking.attendees.map((attendee) => (
<p key={attendee.id}>
{attendee.name} – <a href={`mailto:${attendee.email}`}>{attendee.email}</a>
</p>
))
: null}
{booking.description && (
<>
<h3>{t("description")}:</h3>
<div
className="prose-sm prose prose-invert"
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(booking.description) }}
/>
</>
)}
</main>
<div className="flex items-center justify-center">
<button
aria-label={`${open ? "close" : "open"} booking description sidebar`}
className="h-20 w-6 rounded-r-md border border-l-0 border-gray-300/20 bg-black/60 text-white shadow-sm backdrop-blur-lg"
onClick={() => setOpen(!open)}>
<ChevronRight
aria-hidden
className={classNames(open && "rotate-180", "w-5 transition-all duration-300 ease-in-out")}
/>
</button>
</div>
</aside>
</>
);
}
+1
View File
@@ -155,6 +155,7 @@ const matcherConfigUserTypeEmbedRoute = {
/** @type {import("next").NextConfig} */
const nextConfig = {
experimental: {
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
serverComponentsExternalPackages: ["next-i18next"],
},
i18n: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.7.8",
"version": "3.7.14",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
-488
View File
@@ -1,488 +0,0 @@
import type { DehydratedState } from "@tanstack/react-query";
import classNames from "classnames";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { encode } from "querystring";
import { Toaster } from "react-hot-toast";
import type { z } from "zod";
import {
sdkActionManager,
useEmbedNonStylesConfig,
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
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, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { UserAvatar } from "@calcom/ui";
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
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={{
organizationId: profile.organization?.id,
name: profile.name,
username: profile.username,
}}
organization={
profile.organization?.id
? {
id: profile.organization.id,
slug: profile.organization.slug,
requestedSlug: null,
}
: null
}
/>
<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>
</>
);
}
UserPage.isBookingPage = true;
UserPage.PageWrapper = PageWrapper;
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) {
logger.error(parsedMetadata.error);
return eventTypes;
}
eventTypes.push({
...eventType,
metadata: parsedMetadata.data,
});
return eventTypes;
}, []);
};
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;
};
allowSEOIndexing: boolean;
username: string | null;
};
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
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 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;
const isDynamicGroup = usernameList.length > 1;
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) {
const redirect = await getTemporaryOrgRedirect({
slugs: usernameList,
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
}
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
username: true,
email: true,
name: true,
bio: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
avatarUrl: true,
organizationId: true,
organization: {
select: {
slug: true,
name: true,
metadata: true,
},
},
theme: true,
away: true,
verified: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
},
});
const users = usersWithoutAvatar.map((user) => ({
...user,
organization: {
...user.organization,
metadata: user.organization?.metadata ? teamMetadataSchema.parse(user.organization.metadata) : null,
},
avatar: `/${user.username}/avatar.png`,
}));
if (isDynamicGroup) {
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
logger.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
return {
redirect: {
permanent: false,
destination: destinationUrl,
},
} as {
redirect: {
permanent: false;
destination: string;
};
};
}
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
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: {
id: user.organizationId,
slug: user.organization?.slug ?? null,
requestedSlug: user.organization?.metadata?.requestedSlug ?? null,
},
};
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.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].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,
})),
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,
},
};
};
export default UserPage;
+13 -6
View File
@@ -1,11 +1,18 @@
import { type AppProps } from "@lib/app-providers";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
import PageWrapper, { type CalPageWrapper } from "@components/PageWrapper";
export { default } from "../[type]";
import TypePage from "~/users/views/users-type-public-view";
import { getServerSideProps as _getServerSideProps } from "~/users/views/users-type-public-view.getServerSideProps";
// Somehow these types don't accept the {notFound: true} return type.
// Probably still need to fix this. I don't know why this isn't allowed yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
const Type = TypePage as unknown as CalPageWrapper & {
isBookingPage: AppProps["Component"]["isBookingPage"];
};
Type.isBookingPage = true;
Type.PageWrapper = PageWrapper;
export default Type;
+16
View File
@@ -0,0 +1,16 @@
import { type AppProps } from "@lib/app-providers";
import PageWrapper, { type CalPageWrapper } from "@components/PageWrapper";
import TypePage from "~/users/views/users-type-public-view";
export { getServerSideProps } from "~/users/views/users-type-public-view.getServerSideProps";
const Type = TypePage as unknown as CalPageWrapper & {
isBookingPage: AppProps["Component"]["isBookingPage"];
};
Type.isBookingPage = true;
Type.PageWrapper = PageWrapper;
export default Type;
+13 -2
View File
@@ -1,7 +1,18 @@
import { type AppProps } from "@lib/app-providers";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[user]";
import PageWrapper, { type CalPageWrapper } from "@components/PageWrapper";
export { default } from "../[user]";
import User from "~/users/views/users-public-view";
import { getServerSideProps as _getServerSideProps } from "~/users/views/users-public-view.getServerSideProps";
const UserPage = User as unknown as CalPageWrapper & {
isBookingPage: AppProps["Component"]["isBookingPage"];
};
UserPage.isBookingPage = true;
UserPage.PageWrapper = PageWrapper;
export default UserPage;
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+16
View File
@@ -0,0 +1,16 @@
import { type AppProps } from "@lib/app-providers";
import PageWrapper, { type CalPageWrapper } from "@components/PageWrapper";
import User from "~/users/views/users-public-view";
export { getServerSideProps, type UserPageProps } from "~/users/views/users-public-view.getServerSideProps";
const UserPage = User as unknown as CalPageWrapper & {
isBookingPage: AppProps["Component"]["isBookingPage"];
};
UserPage.isBookingPage = true;
UserPage.PageWrapper = PageWrapper;
export default UserPage;
+2 -2
View File
@@ -76,8 +76,8 @@ class MyDocument extends Document<Props> {
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F9FAFC" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1F1F1F" />
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts
<script
+30 -1
View File
@@ -6,6 +6,7 @@ import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/featur
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
import { SchedulingType } from "@calcom/prisma/client";
import type { AppsStatus } from "@calcom/types/Calendar";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
@@ -30,7 +31,34 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
const numSlotsToCheckForAvailability = 2;
let thirdPartyRecurringEventId = null;
for (let key = 0; key < data.length; key++) {
// for round robin, the first slot needs to be handled first to define the lucky user
const firstBooking = data[0];
const isRoundRobin = firstBooking.schedulingType === SchedulingType.ROUND_ROBIN;
let luckyUsers = undefined;
if (isRoundRobin) {
const recurringEventReq: NextApiRequest & { userId?: number } = req;
recurringEventReq.body = {
...firstBooking,
appsStatus,
allRecurringDates,
isFirstRecurringSlot: true,
thirdPartyRecurringEventId,
numSlotsToCheckForAvailability,
currentRecurringIndex: 0,
noEmail: false,
};
const firstBookingResult = await handleNewBooking(recurringEventReq, {
isNotAnApiCall: true,
});
luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id);
}
for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) {
const booking = data[key];
// Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses
// It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email.
@@ -61,6 +89,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
numSlotsToCheckForAvailability,
currentRecurringIndex: key,
noEmail: key !== 0,
luckyUsers,
};
const promiseEachRecurringBooking: ReturnType<typeof handleNewBooking> = handleNewBooking(
+39
View File
@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FUTURE_ROUTES_OVERRIDE_COOKIE_NAME as COOKIE_NAME } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const session = await getServerSession({ req, res });
if (!session || !session.user || !session.user.email) {
res.status(401).json({ message: "Not authenticated" });
return;
}
let redirectUrl = "/";
// We take you back where you came from if possible
if (typeof req.headers["referer"] === "string") redirectUrl = req.headers["referer"];
/* Only admins can opt-in to future routes for now */
if (session.user.role !== "ADMIN") {
res.redirect(redirectUrl);
return;
}
// If has the cookie, Opt-out of V2
if (COOKIE_NAME in req.cookies && req.cookies[COOKIE_NAME] === "1") {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=0; Max-Age=0; Path=/`);
} else {
/* Opt-int to V2 */
res.setHeader("Set-Cookie", `${COOKIE_NAME}=1; Path=/`);
}
res.redirect(redirectUrl);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
+10
View File
@@ -8,6 +8,7 @@ import { UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@calcom/trpc/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import type { UserProfile } from "@calcom/types/UserProfile";
enum DirectAction {
ACCEPT = "accept",
@@ -46,7 +47,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
id: userId,
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
+32 -2
View File
@@ -3,17 +3,20 @@ import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getSubLogger({ prefix: ["recorded-daily-video"] });
const schema = z
.object({
version: z.string(),
@@ -65,6 +68,13 @@ const triggerWebhook = async ({
};
const webhooks = await getWebhooks(subscriberOptions);
log.debug(
"Webhooks:",
safeStringify({
webhooks,
})
);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
@@ -105,6 +115,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = schema.safeParse(req.body);
log.debug(
"Recording Request Body:",
safeStringify({
response,
})
);
if (!response.success || response.data.type !== "recording.ready-to-download") {
return res.status(400).send({
message: "Invalid Payload",
@@ -126,6 +143,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
});
if (!bookingReference || !bookingReference.bookingId) {
log.error(
"bookingReference:",
safeStringify({
bookingReference,
})
);
return res.status(404).send({ message: "Booking reference not found" });
}
@@ -158,7 +181,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
if (!booking || !(booking.location === DailyLocationType || booking?.location?.trim() === "")) {
if (!booking) {
log.error(
"Booking:",
safeStringify({
booking,
})
);
return res.status(404).send({
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
});
+19 -3
View File
@@ -42,14 +42,30 @@ async function getIdentityData(req: NextApiRequest) {
: null;
if (username) {
const user = await prisma.user.findFirst({
const users = await prisma.user.findMany({
where: {
username,
organization: orgQuery,
...(orgQuery
? {
profiles: {
some: {
username,
organization: orgQuery,
},
},
}
: {
username,
// If a user is moved, it isn't actually available outside of the organization. So, for non-org domain check for movedToProfileId
movedToProfileId: null,
}),
},
select: { avatar: true, email: true },
});
if (users.length > 1) {
throw new Error(`More than one user found for username "${username}"`);
}
const [user] = users;
return {
name: username,
email: user?.email,
+11 -3
View File
@@ -269,7 +269,7 @@ export default function Availability() {
name="name"
render={({ field }) => (
<input
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle focus:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
{...field}
/>
)}
@@ -295,7 +295,10 @@ export default function Availability() {
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
<Skeleton
as={Label}
htmlFor="timeZone-sm-viewport"
className="mb-0 inline-block leading-none">
{t("timezone")}
</Skeleton>
<Controller
@@ -304,6 +307,7 @@ export default function Availability() {
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-sm-viewport"
value={value}
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
onChange={(timezone) => onChange(timezone.value)}
@@ -382,7 +386,10 @@ export default function Availability() {
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
<Skeleton
as={Label}
htmlFor="timeZone-lg-viewport"
className="mb-0 inline-block leading-none">
{t("timezone")}
</Skeleton>
<Controller
@@ -390,6 +397,7 @@ export default function Availability() {
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-lg-viewport"
value={value}
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
onChange={(timezone) => onChange(timezone.value)}
+3 -4
View File
@@ -1,8 +1,7 @@
"use client";
import { getServerSideProps as _getServerSideProps } from "@lib/booking/[uid]/getServerSideProps";
import withEmbedSsr from "@lib/withEmbedSsr";
export { default } from "../[uid]";
import { getServerSideProps as _getServerSideProps } from "~/bookings/views/bookings-single-view.getServerSideProps";
export { default } from "~/bookings/views/bookings-single-view";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "~/bookings/views/bookings-single-view";
export { getServerSideProps } from "~/bookings/views/bookings-single-view.getServerSideProps";
+11 -252
View File
@@ -1,263 +1,22 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { GetStaticPaths } from "next";
import { Fragment, useState } from "react";
import React from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import { getLayout } from "@calcom/features/MainLayout";
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 { HorizontalTabs } from "@calcom/ui";
import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui";
import { Alert, Button, EmptyScreen } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { getStaticProps } from "@lib/bookings/[status]/getStaticProps";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import useMeQuery from "@lib/hooks/useMeQuery";
import PageWrapper from "@components/PageWrapper";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
type BookingListingStatus = z.infer<NonNullable<typeof filterQuerySchema>>["status"];
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsListingView from "~/bookings/views/bookings-listing-view";
type RecurringInfo = {
recurringEventId: string | null;
count: number;
firstDate: Date | null;
bookings: { [key: string]: Date[] };
};
export { getStaticProps } from "~/bookings/views/bookings-listing-view.getStaticProps";
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 validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
const BookingsListingPage = new Proxy<{
(): JSX.Element;
PageWrapper?: typeof PageWrapper;
getLayout?: typeof getLayout;
}>(BookingsListingView, {});
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>
);
}
Bookings.PageWrapper = PageWrapper;
Bookings.getLayout = getLayout;
BookingsListingPage.PageWrapper = PageWrapper;
BookingsListingPage.getLayout = getLayout;
export const getStaticPaths: GetStaticPaths = () => {
return {
@@ -269,4 +28,4 @@ export const getStaticPaths: GetStaticPaths = () => {
};
};
export { getStaticProps };
export default BookingsListingPage;
+8 -688
View File
@@ -1,694 +1,14 @@
/* 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 { getServerSideProps, type PageProps } from "@lib/event-types/[type]/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
// 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)
);
import EventTypePageWrapper from "~/event-types/views/event-types-single-view";
import { getServerSideProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
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;
beforeBufferTime: number;
afterBufferTime: 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"];
};
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,
}))
);
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 || [],
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,
},
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,
};
}, [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,
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
recurringEvent,
locations,
metadata,
customInputs,
children,
// 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: beforeBufferTime,
afterEventBuffer: afterBufferTime,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
metadata,
customInputs,
children,
});
};
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,
beforeBufferTime,
afterBufferTime,
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: beforeBufferTime,
afterEventBuffer: afterBufferTime,
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 = (props: PageProps) => {
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
if (!data) return null;
return <EventTypePage {...(data as EventTypeSetupProps)} />;
};
export type {
CustomInputParsed,
EventTypeSetup,
EventTypeSetupProps,
FormValues,
} from "~/event-types/views/event-types-single-view";
EventTypePageWrapper.PageWrapper = PageWrapper;
+2 -965
View File
@@ -1,975 +1,12 @@
"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 { getLayout } from "@calcom/features/MainLayout";
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 useMeQuery from "@lib/hooks/useMeQuery";
import PageWrapper from "@components/PageWrapper";
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;
import EventTypesPage from "~/event-types/views/event-types-listing-view";
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_link")}>
<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={`${bookerUrl}${teamId ? "/team" : ""}/${profile.slug}/avatar.png`}
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 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();
const orgBranding = useOrgBranding();
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 = () => {
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 { getServerSideProps } from "@lib/event-types/getServerSideProps";
EventTypesPage.getLayout = getLayout;
EventTypesPage.PageWrapper = PageWrapper;
export default EventTypesPage;
@@ -2,8 +2,8 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
import { getServerSideProps as _getServerSideProps } from ".";
export { default, type PageProps } from "../[type]";
export { default, type PageProps } from ".";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
@@ -2,10 +2,11 @@
import PageWrapper from "@components/PageWrapper";
import type { PageProps as UserTypePageProps } from "../../../[user]/[type]";
import UserTypePage from "../../../[user]/[type]";
import type { PageProps as TeamTypePageProps } from "../../../team/[slug]/[type]";
import TeamTypePage from "../../../team/[slug]/[type]";
import UserTypePage from "~/users/views/users-type-public-view";
import type { PageProps as UserTypePageProps } from "~/users/views/users-type-public-view.getServerSideProps";
import type { PageProps as TeamTypePageProps } from "../../../../team/[slug]/[type]";
import TeamTypePage from "../../../../team/[slug]/[type]";
export { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -2,8 +2,9 @@
import PageWrapper from "@components/PageWrapper";
import type { UserPageProps } from "../../../[user]";
import UserPage from "../../../[user]";
import UserPage from "~/users/views/users-public-view";
import { type UserPageProps } from "~/users/views/users-public-view.getServerSideProps";
import type { PageProps as TeamPageProps } from "../../../team/[slug]";
import TeamPage from "../../../team/[slug]";
@@ -230,7 +230,10 @@ const ProfileView = () => {
const defaultValues = {
username: user.username || "",
avatar: getUserAvatarUrl(user),
avatar: getUserAvatarUrl({
...user,
profile: user.profile,
}),
name: user.name || "",
email: user.email || "",
bio: user.bio || "",
@@ -443,20 +446,14 @@ const ProfileForm = ({
const organization =
userOrganization && userOrganization.id
? {
...(userOrganization as Ensure<typeof user.organization, "id">),
...(userOrganization as Ensure<NonNullable<typeof user.organization>, "id">),
slug: userOrganization.slug || null,
requestedSlug: userOrganization.metadata?.requestedSlug || null,
}
: null;
return (
<>
<UserAvatar
data-testid="profile-upload-avatar"
previewSrc={value}
size="lg"
user={user}
organization={organization}
/>
<UserAvatar data-testid="profile-upload-avatar" previewSrc={value} size="lg" user={user} />
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
<div className="flex gap-2">
+6 -246
View File
@@ -1,251 +1,11 @@
"use client";
import PageWrapper, { type CalPageWrapper } from "@components/PageWrapper";
import DailyIframe from "@daily-co/daily-js";
import Head from "next/head";
import { useState, useEffect, useRef } from "react";
import VideosSingleView from "~/videos/views/videos-single-view";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { ChevronRight } from "@calcom/ui/components/icon";
export { getServerSideProps, type PageProps } from "~/videos/views/videos-single-view.getServerSideProps";
import { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
const VideosSinglePage = VideosSingleView as unknown as CalPageWrapper;
import PageWrapper from "@components/PageWrapper";
VideosSinglePage.PageWrapper = PageWrapper;
export type JoinCallPageProps = Omit<inferSSRProps<typeof getServerSideProps>, "trpcState">;
export default function JoinCall(props: JoinCallPageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking } = props;
useEffect(() => {
const callFrame = DailyIframe.createFrame({
theme: {
colors: {
accent: "#FFF",
accentText: "#111111",
background: "#111111",
backgroundAccent: "#111111",
baseText: "#FFF",
border: "#292929",
mainAreaBg: "#111111",
mainAreaBgAccent: "#1A1A1A",
mainAreaText: "#FFF",
supportiveText: "#FFF",
},
},
showLeaveButton: true,
iframeStyle: {
position: "fixed",
width: "100%",
height: "100%",
},
url: meetingUrl,
...(typeof meetingPassword === "string" && { token: meetingPassword }),
});
callFrame.join();
return () => {
callFrame.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const title = `${APP_NAME} Video`;
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={t("quick_video_meeting")} />
<meta property="og:image" content={SEO_IMG_OGIMG_VIDEO} />
<meta property="og:type" content="website" />
<meta property="og:url" content={`${WEBSITE_URL}/video`} />
<meta property="og:title" content={`${APP_NAME} Video`} />
<meta property="og:description" content={t("quick_video_meeting")} />
<meta property="twitter:image" content={SEO_IMG_OGIMG_VIDEO} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={`${WEBSITE_URL}/video`} />
<meta property="twitter:title" content={`${APP_NAME} Video`} />
<meta property="twitter:description" content={t("quick_video_meeting")} />
</Head>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
left: 32,
}}
/>
) : (
<img
className="fixed z-10 hidden sm:inline-block"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Logo"
style={{
top: 32,
left: 32,
}}
/>
)}
</div>
<VideoMeetingInfo booking={booking} />
</>
);
}
interface ProgressBarProps {
startTime: string;
endTime: string;
}
function ProgressBar(props: ProgressBarProps) {
const { t } = useLocale();
const { startTime, endTime } = props;
const currentTime = dayjs().second(0).millisecond(0);
const startingTime = dayjs(startTime).second(0).millisecond(0);
const isPast = currentTime.isAfter(startingTime);
const currentDifference = dayjs().diff(startingTime, "minutes");
const startDuration = dayjs(endTime).diff(startingTime, "minutes");
const [duration, setDuration] = useState(() => {
if (currentDifference >= 0 && isPast) {
return startDuration - currentDifference;
} else {
return startDuration;
}
});
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const now = dayjs();
const remainingMilliseconds = (60 - now.get("seconds")) * 1000 - now.get("milliseconds");
timeoutRef.current = setTimeout(() => {
const past = dayjs().isAfter(startingTime);
if (past) {
setDuration((prev) => prev - 1);
}
intervalRef.current = setInterval(() => {
if (dayjs().isAfter(startingTime)) {
setDuration((prev) => prev - 1);
}
}, 60000);
}, remainingMilliseconds);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prev = startDuration - duration;
const percentage = prev * (100 / startDuration);
return (
<div>
<p>
{duration} {t("minutes")}
</p>
<div className="relative h-2 max-w-xl overflow-hidden rounded-full">
<div className="absolute h-full w-full bg-gray-500/10" />
<div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} />
</div>
</div>
);
}
interface VideoMeetingInfo {
booking: JoinCallPageProps["booking"];
}
export function VideoMeetingInfo(props: VideoMeetingInfo) {
const [open, setOpen] = useState(false);
const { booking } = props;
const { t } = useLocale();
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
return (
<>
<aside
className={classNames(
"no-scrollbar fixed left-0 top-0 z-30 flex h-full w-64 transform justify-between overflow-x-hidden overflow-y-scroll transition-all duration-300 ease-in-out",
open ? "translate-x-0" : "-translate-x-[232px]"
)}>
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden border-r border-gray-300/20 bg-black/80 p-4 text-white shadow-sm backdrop-blur-lg">
<h3>{t("what")}:</h3>
<p>{booking.title}</p>
<h3>{t("invitee_timezone")}:</h3>
<p>{booking.user?.timeZone}</p>
<h3>{t("when")}:</h3>
<p suppressHydrationWarning={true}>
{formatToLocalizedDate(startTime)} <br />
{formatToLocalizedTime(startTime)}
</p>
<h3>{t("time_left")}</h3>
<ProgressBar
key={String(open)}
endTime={endTime.toISOString()}
startTime={startTime.toISOString()}
/>
<h3>{t("who")}:</h3>
<p>
{booking?.user?.name} - {t("organizer")}:{" "}
<a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a>
</p>
{booking.attendees.length
? booking.attendees.map((attendee) => (
<p key={attendee.id}>
{attendee.name} – <a href={`mailto:${attendee.email}`}>{attendee.email}</a>
</p>
))
: null}
{booking.description && (
<>
<h3>{t("description")}:</h3>
<div
className="prose-sm prose prose-invert"
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(booking.description) }}
/>
</>
)}
</main>
<div className="flex items-center justify-center">
<button
aria-label={`${open ? "close" : "open"} booking description sidebar`}
className="h-20 w-6 rounded-r-md border border-l-0 border-gray-300/20 bg-black/60 text-white shadow-sm backdrop-blur-lg"
onClick={() => setOpen(!open)}>
<ChevronRight
aria-hidden
className={classNames(open && "rotate-180", "w-5 transition-all duration-300 ease-in-out")}
/>
</button>
</div>
</aside>
</>
);
}
export { getServerSideProps };
JoinCall.PageWrapper = PageWrapper;
export default VideosSinglePage;
+9 -63
View File
@@ -1,68 +1,14 @@
"use client";
import PageWrapper, { type CalPageWrapper } from "@components/PageWrapper";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import { Button, HeadSeo } from "@calcom/ui";
import { ArrowRight, Calendar, X } from "@calcom/ui/components/icon";
import MeetingEnded from "~/videos/views/videos-meeting-ended-single-view";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import { getServerSideProps } from "@lib/video/meeting-ended/[uid]/getServerSideProps";
export {
getServerSideProps,
type PageProps,
} from "~/videos/views/videos-meeting-ended-single-view.getServerSideProps";
import PageWrapper from "@components/PageWrapper";
const MeetingEndedPage = MeetingEnded as unknown as CalPageWrapper;
export default function MeetingUnavailable(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
MeetingEndedPage.PageWrapper = PageWrapper;
return (
<div>
<HeadSeo title="Meeting Unavailable" description="Meeting Unavailable" />
<main className="mx-auto my-24 max-w-3xl">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
<div
className="bg-default inline-block transform overflow-hidden rounded-lg px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<X className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-headline">
This meeting is in the past.
</h3>
</div>
<div className="mt-4 border-b border-t py-4">
<h2 className="font-cal text-default mb-2 text-center text-lg font-medium">
{props.booking.title}
</h2>
<p className="text-subtle text-center">
<Calendar className="-mt-1 mr-1 inline-block h-4 w-4" />
{dayjs(props.booking.startTime).format(`${detectBrowserTimeFormat}, dddd DD MMMM YYYY`)}
</p>
</div>
</div>
<div className="mt-5 text-center sm:mt-6">
<div className="mt-5">
<Button data-testid="return-home" href="/event-types" EndIcon={ArrowRight}>
{t("go_back")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
export { getServerSideProps };
MeetingUnavailable.PageWrapper = PageWrapper;
export default MeetingEndedPage;

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