Compare commits

...
160 Commits
Author SHA1 Message Date
Omar López b4f44e9a60 v3.3.4
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-09-28 12:39:00 -07:00
Omar López 9c448e3b87 Revert "fix: #10789 Prevent Double Booking (for requests that require confirmation)" (#11594) 2023-09-28 12:31:43 -07:00
5deda33712 fix: focus on textarea (#11522)
* Border change on hover on textarea

* Fixed focus on textarea

* Fixed focus on textarea

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Update packages/ui/components/editor/stylesEditor.css

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Update packages/ui/components/editor/Editor.tsx

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 18:50:07 +00:00
2335948099 fixed #11561 dropdown is too large (#11562)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 18:25:14 +00:00
James George 8bbb38d2be chore: cleanup .husky directory (#11583) 2023-09-28 19:13:05 +01:00
a4035aef98 test: Tests for AppCard component (#11500)
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: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-28 19:11:43 +01:00
Peer Richelsen c57de8f43f chore: more cal.ai changes (#11590) 2023-09-28 18:09:17 +00:00
Hariom BalharaandPeer Richelsen 2b1918f40a fix: Embed Snippet Generator, hard crash on reload (#11363)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 19:08:42 +01:00
Crowdin Bot fb2c966e6c New Crowdin translations by Github Action 2023-09-28 18:08:27 +00:00
Udit TakkarandPeer Richelsen 4f3cf4f948 refactor: Setting redesign (#11124)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 19:04:51 +01:00
Crowdin Bot 4613ac34c9 New Crowdin translations by Github Action 2023-09-28 16:24:28 +00:00
Crowdin Bot d69ba825fe New Crowdin translations by Github Action 2023-09-28 16:21:26 +00:00
Peer Richelsen 92a221197a added new logo and screenshot (#11585) 2023-09-28 12:18:27 -04:00
Hariom Balhara b54f716fd7 chore: Improved logging booking flow (#11543) 2023-09-28 13:01:24 +00:00
Somay ChauhanandUdit Takkar 57e712dd9e fix: event-type filters (#11552)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-28 13:33:31 +01:00
2021b641ce feat: Alby integration (#11495)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 12:03:01 +00:00
ef45cbfb3f refactor: event type settings (#11539)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 12:59:06 +01:00
Crowdin Bot ab17cb216f New Crowdin translations by Github Action 2023-09-28 11:33:29 +00:00
Aldrin b04c870a3d fix: remove cal.com before team name (#11544) 2023-09-28 12:30:32 +01:00
Crowdin Bot 09abb90c8b New Crowdin translations by Github Action 2023-09-28 09:54:13 +00:00
Hariom Balhara b8e3b549f8 Fix failing bookings (#11574) 2023-09-28 09:51:18 +00:00
alannnc 403d08de41 fix: update-stripe-presented-currency (#11431)
* Edit stripe currency

* add simple test for stripe change currency

* Undo import removed
2023-09-27 21:30:22 +00:00
d7c3132fa5 fix: #10789 Prevent Double Booking (for requests that require confirmation) (#10882)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-09-27 20:54:22 +00:00
Keith Williams a2df323cf8 fix: credential sync app lookup (#11530) 2023-09-27 17:50:16 +01:00
fe82d88adf fix: change localhost:3000 to Gitpod workspace URL (#10470)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-27 15:54:45 +00:00
Nafees Nazik 01e2ba3fd9 fix: app crashing due to invalid hex (#11542) 2023-09-27 10:58:52 -03:00
c9b87bbf85 feat: Paid and confirmed booking should be able to be rescheduled v1 (#11417)
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-27 13:49:13 +00:00
19e6dbb781 fix: removed duplicate account delete confirmation text (#11520)
Co-authored-by: Nitin Panghal <nitin.panghal@unthinkable.co>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-27 13:46:24 +00:00
Syed Ali Shahbaz a3a49f9069 add team availability doc (#11554) 2023-09-27 15:46:07 +04:00
Nafees NazikandUdit Takkar 468da1f35a feat: ability to reorder fields in multiple select in Routing Forms (#10927)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-27 12:22:57 +01:00
Alex van Andel ec4e2f5996 fix: Blank state wouldn't set 'schedule'; resulting toggle had no effect (#11335) 2023-09-27 11:26:55 +02:00
Denzil Samuel 8a413a97b4 fix: allow different casing for team (#11546) 2023-09-27 10:50:04 +02:00
Chiranjeev Vishnoi c275981be6 fix: profile slug added on create your first event type dialog (#11494) 2023-09-27 10:00:17 +02:00
Nafees NazikandHariom Balhara ce13350d5d fix: form input not being saved after "back" (#11540)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-09-27 04:39:37 +00:00
919066222b fix: Dynamic events prevent fetching all users that share a username [CAL-2409] (#11288)
* For dynamic search users based on org

* Type fix

---------

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-26 22:50:10 +01:00
Carina WollendorferandCarinaWolli 79b60adf9b fix: timezone of default schedule should change if profile timezone is updated (#11533)
* change tz of default schedule

* make sure user has at least one schedule

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-26 20:41:16 +01:00
Peer Richelsen 1d882e6b3b Update semantic-pull-requests.yml 2023-09-26 17:51:02 +01:00
Peer Richelsen 7f7841fabc Update welcome-new-contributors.yml 2023-09-26 17:49:49 +01:00
Peer Richelsen 260064c836 Rename merge-conflict to merge-conflict.yml 2023-09-26 17:47:20 +01:00
Richard Poelderl 8c8be1efa3 add preview token to website (#11535) 2023-09-26 15:28:57 +00:00
Crowdin Bot 9084cc6677 New Crowdin translations by Github Action 2023-09-26 15:05:48 +00:00
Richard Poelderlandsajanlamsal fda3192d53 fix: changed Japanese translations on booking page to better match the scenario. (#10416) (#11529)
Co-authored-by: sajanlamsal <saznlamsal@gmail.com>
2023-09-26 16:01:06 +01:00
Hariom Balhara 4acfb4b28c fix: Prefill issue when name isn't present and add unit tests (#11418)
* Fix yarn.lock

* Add tests for getBookingResponsesSchema

* Fix prefill failing if name isnt provided

* More tests and another fix
2023-09-26 16:16:31 +05:30
Udit Takkar a0642598ab test: private team test (#11523) 2023-09-26 10:48:43 +02:00
alannnc 20282a16f7 fix: verify-rerender-checkout-stripe-session (#11493) 2023-09-25 10:57:02 -07:00
Shivam Kalra 69120095e4 fix: username change test (#11511) 2023-09-25 13:35:02 +00:00
Peer Richelsen bfc137c349 fix: icons for dark mode (#11309) 2023-09-25 15:20:28 +02:00
Udit TakkarandAlex van Andel 13caf6f2cd fix: add a default timezone (#11475)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-25 15:08:41 +02:00
Alex van Andel 1d4eeab33e fix: Salesforce by wrapping the refresh logic in a try/catch (#11508) 2023-09-25 11:33:53 +00:00
Peer Richelsen 3bee2d705f chore: removed ring from org avatar (#11498) 2023-09-25 10:50:08 +00:00
Alex van Andel 9c621bf726 Update the oauth imports to _utils/oauth (#11507) 2023-09-25 09:50:17 +00:00
70461b2718 feat: Zoho Calendar (#10429)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: aar2dee2 <85004512+aar2dee2@users.noreply.github.com>
2023-09-25 14:04:37 +05:30
Crowdin Bot efb04d04ab New Crowdin translations by Github Action 2023-09-25 05:20:27 +00:00
538e3bf07c chore: refactor app list into common component (#9754)
Co-authored-by: Ty Kerr <tykerr@Tys-MacBook-Pro.local>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-25 10:47:21 +05:30
AldrinandUdit Takkar 798707a553 fix: incorrect client_id parameter assignment in generating access token for webex (#11472)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-22 19:25:00 -04:00
harsh11101andUdit Takkar 5940f9bccd fix: Tickets are being shown in user preferred time format (#11349)
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-22 19:24:37 -04:00
5e449670ea feat: Cal AI V1.1.0 (#11446)
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: tedspare <ted.spare@gmail.com>
2023-09-22 19:23:19 -04:00
GitStart-Cal.comandgitstart-calcom 82d7a3db92 fix: The clickable area of the 'x' in add guests section is not user friendly (#11434)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-09-22 19:15:14 -04:00
Vijay 0b7e3510a5 fix: #11397 request for reschedule did not cancel initial meeting (#11411) 2023-09-22 19:13:17 -04:00
e0c87cf664 feat: Add ability to edit avatar (#11399)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-22 19:12:35 -04:00
Leo Giovanetti 0d653afd0c fix: remove stripe metadata and more logos (#11489) 2023-09-22 14:44:45 -03:00
Richard Poelderl f414428dfe fix: auto-approve crowdin translations uploaded from GitHub (#11490) 2023-09-22 16:33:37 +00:00
2a171e9284 Implementation (#11481)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
2023-09-22 11:14:04 +01:00
Udit Takkar faf974ca4c chore: convert isCreateTeamButtonDisabled to bool (#11473)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-22 10:36:52 +01:00
Crowdin Bot a78a7a1fa0 New Crowdin translations by Github Action 2023-09-22 07:52:43 +00:00
Udit Takkar 5943763726 fix: server side error on /apps (#11485) 2023-09-22 13:19:30 +05:30
Hitesh Sisara 2648e2770f Update workflow.tsx to add Loading/Progress indicator while saving the workflow (#11484)
add Loading/Progress indicator while saving the workflow
2023-09-22 11:16:45 +05:30
Crowdin Bot 4e7ab391ff New Crowdin translations by Github Action 2023-09-21 21:11:13 +00:00
Leo Giovanetti 578495de87 chore: reduce team data footprint (#11478) 2023-09-21 18:08:34 -03:00
Udit TakkarandCarinaWolli 86b3a33ff6 fix: disable create team button for non admins (#11470)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-21 06:36:40 +00:00
Crowdin Bot 94476565ad New Crowdin translations by Github Action 2023-09-21 06:25:11 +00:00
Carina WollendorferandCarinaWolli 3b50fe075d fix: remove kyc and make text messages to attendee org only (#11389)
* add orgs upgrade badge to select

* don't allow updating to if no teams or orgs plan

* make sure only paying users can request verification codes

* add new action helper function

* remove kyc code

* code clean up

* fix verify button UI

* fix type errors

* add eventTypeRequiresConfirmation everywhere

* address feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-20 23:22:05 -07:00
Udit TakkarandLeo Giovanetti 96263b0cf7 fix: team members not showing up in org members list (#11456)
* fix: team members not showing up in org members list

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use upsert

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Update apps/web/pages/api/auth/signup.ts

Co-authored-by: Leo Giovanetti <hello@leog.me>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-09-20 22:02:10 -03:00
Alex van Andel 3a3d8db489 v3.3.3
release.yml / release (push) Canceled after 0s
2023-09-20 21:58:01 +01:00
Crowdin Bot de04b1a561 New Crowdin translations by Github Action 2023-09-20 20:28:04 +00:00
Leo Giovanetti ca946a9822 chore: avoid i18n strings in org profile (#11469)
* chore: avoid i18n strings in org profile

* Making sure is an org profile not a subteam
2023-09-20 20:25:38 +00:00
Alex van Andel a5c05bc550 chore: Add login to rewrites (#11466) 2023-09-20 18:56:48 +00:00
Alex van Andel fd02cc44fa feat: Add 300s accelerate cache to feature flags (#11464) 2023-09-20 19:50:28 +01:00
Joe Au-Yeung 5ee962948f feat: Readd app syncing (#11453)
* Revert "Revert "feat: Sync app credentials between Cal.com & self-hosted plat… (#11450)"

This reverts commit 2565d0915a.

* Add typing to `parseRefreshTokenResponse`

* Add typing

* Return safeParse data

* Type fix and refactor in Zoom
2023-09-20 19:01:54 +01:00
Crowdin Bot c350f099f7 New Crowdin translations by Github Action 2023-09-20 17:48:11 +00:00
Leo Giovanetti 07860c238a chore: reducing team next data (#11463)
* chore: reducing team next data

* Removing console.log
2023-09-20 14:45:14 -03:00
Omar López 2565d0915a Revert "feat: Sync app credentials between Cal.com & self-hosted plat… (#11450) 2023-09-19 14:45:16 -07:00
Leo Giovanetti 3fdc41968f fix: subteam invite org acceptance (#11449) 2023-09-19 14:42:37 -07:00
Omar López 37f01db0da v3.3.2
release.yml / release (push) Canceled after 0s
2023-09-19 14:03:20 -07:00
Omar López 560ec244e8 feat: Setups prisma accelerate (#11324) 2023-09-19 21:02:57 +00:00
Crowdin Bot 21a0b67b5a New Crowdin translations by Github Action 2023-09-19 20:49:32 +00:00
824145b0e2 feat: Sync app credentials between Cal.com & self-hosted platforms (#11059)
* Add credential sync .env variables

* Add webhook to send app credentials

* Upsert credentials when webhook called

* Refresh oauth token from a specific endpoint

* Pass appSlug

* Add credential encryption

* Move oauth helps into a folder

* Create parse token response wrapper

* Add OAuth helpers to apps

* Clean up

* Refactor `appDirName` to `appSlug`

* Address feedback

* Change to safe parse

* Remove console.log

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2023-09-19 20:46:50 +00:00
Leo Giovanetti bc89fe00ea fix: unpublished org/team avatar and i18n (#11429)
* fix: unpublished org/team avatar and i18n

* Fixing e2e
2023-09-20 01:45:39 +05:30
Crowdin Bot 64850b54f0 New Crowdin translations by Github Action 2023-09-19 20:04:11 +00:00
405a4cc850 fix: Time Buttons Should be Scroll not the Whole Right Side (#11412)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-09-19 22:01:26 +02:00
Crowdin Bot eccab37607 New Crowdin translations by Github Action 2023-09-19 14:24:18 +00:00
786c1c2ba3 feat: monthly email digest (#10621)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-19 16:19:36 +02:00
Udit TakkarandLeo Giovanetti 50970dc249 fix: org invite (#11356)
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-09-19 15:46:33 +05:30
Crowdin Bot 45cded6b48 New Crowdin translations by Github Action 2023-09-19 10:02:00 +00:00
Crowdin Bot b139ec8994 New Crowdin translations by Github Action 2023-09-19 09:58:12 +00:00
Peer Richelsen d058d322bc chore: slow caldav hint in description (#11438) 2023-09-19 11:54:51 +02:00
Leo Giovanetti 72907e5e1b feat: resend invitation (#11428) 2023-09-19 00:24:43 +00:00
Kevin Crimi d9c3c184c4 fix: Return all responses in booking api (#11284) 2023-09-18 16:39:25 -07:00
Crowdin Bot b1fff71c91 New Crowdin translations by Github Action 2023-09-18 23:38:32 +00:00
alannnc 56b1243e59 fix: re-render on insights with orgs (#11395) 2023-09-18 16:36:07 -07:00
Crowdin Bot a3413216f6 New Crowdin translations by Github Action 2023-09-18 19:19:24 +00:00
Shivam KalraandSyed Ali Shahbaz fd507f1d12 test: e2e oidc (#11129)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2023-09-18 21:16:32 +02:00
Peer RichelsenandLeo Giovanetti c820a3937a chore: send comment about merge conflict (#11345)
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-09-18 21:16:22 +02:00
aar2dee2andPeer Richelsen 5c24ed2ef7 chore: app contribution guidelines (#11416)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-18 21:16:02 +02:00
GitStart-Cal.comandgitstart-calcom 8d9d7bbb25 fix: Required "Add guests" button isn't required on attendee's end (#11381)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-09-18 21:15:40 +02:00
Crowdin Bot 837bf5d604 New Crowdin translations by Github Action 2023-09-18 16:01:33 +00:00
Hariom Balhara c9c634cceb Support firstName and lastName query params (#11420) 2023-09-18 21:28:07 +05:30
Hariom BalharaandJoe Au-Yeung 8ba09ce04d Fix messaging apps listing in installed (#11167)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-09-18 21:26:49 +05:30
Crowdin Bot 65d4a4f40e New Crowdin translations by Github Action 2023-09-18 07:55:08 +00:00
Crowdin Bot 581d8ff4ae New Crowdin translations by Github Action 2023-09-18 07:51:56 +00:00
Pradip Chaudhary c562f45904 fix: "scrollbar-gutter: stable" on html to prevent layout shift (#11413) 2023-09-18 09:49:01 +02:00
zomars b413f0a609 fix: add missing await 2023-09-15 17:22:50 -07:00
Crowdin Bot ffcf9b770d New Crowdin translations by Github Action 2023-09-15 19:27:35 +00:00
Hariom Balhara ab77531167 fix: Email Embed slots not showing up (#11379) 2023-09-15 12:24:20 -07:00
Crowdin Bot f59f4ca26c New Crowdin translations by Github Action 2023-09-15 16:22:47 +00:00
Crowdin Bot ca13eec4c9 New Crowdin translations by Github Action 2023-09-15 16:18:56 +00:00
Crowdin Bot bf1ff9bd71 New Crowdin translations by Github Action 2023-09-15 16:14:12 +00:00
Crowdin Bot caefa93e14 New Crowdin translations by Github Action 2023-09-15 16:10:36 +00:00
Leo Giovanetti 7154349e9e fix: org username check for dynamic and signup (#11390) 2023-09-15 13:06:56 -03:00
Crowdin Bot 7beb9568ba New Crowdin translations by Github Action 2023-09-15 15:56:09 +00:00
Crowdin Bot 2251d5a4be New Crowdin translations by Github Action 2023-09-15 15:53:15 +00:00
Crowdin Bot f1bdff9679 New Crowdin translations by Github Action 2023-09-15 15:50:25 +00:00
Crowdin Bot c433e0c74c New Crowdin translations by Github Action 2023-09-15 15:46:48 +00:00
Crowdin Bot fb940a1023 New Crowdin translations by Github Action 2023-09-15 15:43:05 +00:00
Crowdin Bot 73366e4152 New Crowdin translations by Github Action 2023-09-15 15:39:48 +00:00
Crowdin Bot 9d7da10744 New Crowdin translations by Github Action 2023-09-15 15:36:02 +00:00
Crowdin Bot c4f091debf New Crowdin translations by Github Action 2023-09-15 15:32:25 +00:00
Alex van Andel 29923fe304 v3.3.1
release.yml / release (push) Canceled after 0s
2023-09-15 16:28:09 +01:00
Richard PoelderlandAlex van Andel e8998414fc feat(i18n): changed Japanese translations on booking page (#11388)
* regen yarn.lock?

* chore: enable i18n-unused GH action in `website`

* Generated yarn.lock with submodules

* Added yarn.lock auth

* add datocms webhook secret

* update crowdin to latest version

* cherry-picked https://github.com/calcom/cal.com/pull/10416/files

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-15 16:25:49 +01:00
Richard PoelderlandAlex van Andel 2e58e73e95 chore: Update GH action from crowdin to latest version (#11387)
* regen yarn.lock?

* chore: enable i18n-unused GH action in `website`

* Generated yarn.lock with submodules

* Added yarn.lock auth

* add datocms webhook secret

* update crowdin to latest version

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-15 14:43:49 +00:00
Crowdin Bot b7168948ca New Crowdin translations by Github Action 2023-09-15 14:01:21 +00:00
sean-brydon c188a91d64 fix: Safari overflowing issues (#11385) 2023-09-15 15:57:49 +02:00
Crowdin Bot 4724b1312c New Crowdin translations by Github Action 2023-09-15 13:57:43 +00:00
Hariom Balhara d349f6536a fix: Embed - app.cal.com instead of cal.com (#11384)
* Fix yarn.lock

* Force app.cal.com because Safari and rewrites using referrer

* Add comment
2023-09-15 14:54:25 +01:00
Crowdin Bot f35f506ddf New Crowdin translations by Github Action 2023-09-15 13:43:44 +00:00
Crowdin Bot 46d0b67f81 New Crowdin translations by Github Action 2023-09-15 13:40:27 +00:00
Crowdin Bot 7000cf7581 New Crowdin translations by Github Action 2023-09-15 13:36:53 +00:00
Crowdin Bot 1c0faf4eb6 New Crowdin translations by Github Action 2023-09-15 13:33:26 +00:00
Alex van Andel 6d0f0d6bdd fix: Include returns all credential fields, select excludes (#11383) 2023-09-15 13:29:44 +00:00
Crowdin Bot cb4a3dd2a6 New Crowdin translations by Github Action 2023-09-15 13:25:56 +00:00
Crowdin Bot fc623fc511 New Crowdin translations by Github Action 2023-09-15 13:22:33 +00:00
Crowdin Bot 9db0834b50 New Crowdin translations by Github Action 2023-09-15 13:18:41 +00:00
Crowdin Bot 7a3cc7d263 New Crowdin translations by Github Action 2023-09-15 13:14:24 +00:00
Crowdin Bot f1381d8a82 New Crowdin translations by Github Action 2023-09-15 13:11:06 +00:00
Crowdin Bot 5c136df1bb New Crowdin translations by Github Action 2023-09-15 13:07:33 +00:00
Crowdin Bot e18f08249b New Crowdin translations by Github Action 2023-09-15 13:04:29 +00:00
Crowdin Bot 5b8b165a3b New Crowdin translations by Github Action 2023-09-15 13:01:21 +00:00
Somay ChauhanandShivam Kalra ab39c8f581 fix: a new email can signin with any password once it's invited to a team (#11359)
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-09-15 12:57:41 +00:00
Crowdin Bot abc077bfb6 New Crowdin translations by Github Action 2023-09-15 12:55:16 +00:00
Crowdin Bot bb167e7763 New Crowdin translations by Github Action 2023-09-15 12:52:03 +00:00
Crowdin Bot 3b92ff607a New Crowdin translations by Github Action 2023-09-15 12:47:56 +00:00
Crowdin Bot 129d00a69d New Crowdin translations by Github Action 2023-09-15 12:44:42 +00:00
Crowdin Bot c86e766aee New Crowdin translations by Github Action 2023-09-15 12:41:46 +00:00
Leo Giovanetti a2873c740b fix: team page data (#11382) 2023-09-15 13:38:55 +01:00
Crowdin Bot 6e3a1369f8 New Crowdin translations by Github Action 2023-09-15 11:55:51 +00:00
Chiranjeev Vishnoi e44e24ca42 fix: 2FA password visibility toggle show/hide state (#11361) 2023-09-15 17:22:58 +05:30
Somay Chauhan 768a3132fb fix: settings/organization/members table filtering issue (#11313) 2023-09-15 17:20:40 +05:30
Crowdin Bot 41812bb280 New Crowdin translations by Github Action 2023-09-15 10:19:46 +00:00
alannnc cb1d3487af chore: edit and update paypal setup instructions (#11325) 2023-09-15 15:46:28 +05:30
Omar López c95917cceb feat: Calendar Cache (#11185) 2023-09-14 15:19:39 -07:00
423 changed files with 13113 additions and 4123 deletions
+1
View File
@@ -125,4 +125,5 @@ SALESFORCE_CONSUMER_SECRET=""
ZOHOCRM_CLIENT_ID=""
ZOHOCRM_CLIENT_SECRET=""
# *********************************************************************************************************
+28
View File
@@ -229,3 +229,31 @@ AUTH_BEARER_TOKEN_VERCEL=
# Used for E2E tests on Apple Calendar
E2E_TEST_APPLE_CALENDAR_EMAIL=""
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
# - APP CREDENTIAL SYNC ***********************************************************************************
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
# You can use: `openssl rand -base64 32` to generate one
CALCOM_WEBHOOK_SECRET=""
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
CALCOM_WEBHOOK_HEADER_NAME="calcom-webhook-secret"
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
# Key should match on Cal.com and your application
# must be 32 bytes for AES256 encryption algorithm
# You can use: `openssl rand -base64 24` to generate one
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY=""
# - OIDC E2E TEST *******************************************************************************************
# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list
E2E_TEST_SAML_ADMIN_EMAIL=
E2E_TEST_SAML_ADMIN_PASSWORD=
E2E_TEST_OIDC_CLIENT_ID=
E2E_TEST_OIDC_CLIENT_SECRET=
E2E_TEST_OIDC_PROVIDER_DOMAIN=
E2E_TEST_OIDC_USER_EMAIL=
E2E_TEST_OIDC_USER_PASSWORD=
# ***********************************************************************************************************
@@ -0,0 +1,33 @@
name: Cron - monthlyDigestEmail
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs on the 28th, 29th, 30th and 31st of every month (see https://crontab.guru)
- cron: "59 23 28-31 * *"
jobs:
cron-monthlyDigestEmail:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: Check if today is the last day of the month
id: check-last-day
run: |
LAST_DAY=$(date -d tomorrow +%d)
if [ "$LAST_DAY" == "01" ]; then
echo "::set-output name=is_last_day::true"
else
echo "::set-output name=is_last_day::false"
fi
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY && steps.check-last-day.outputs.is_last_day == 'true' }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/monthlyDigestEmail \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail
+10 -2
View File
@@ -19,12 +19,20 @@ jobs:
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: crowdin action
uses: crowdin/github-action@1.5.1
uses: crowdin/github-action@v1.13.0
with:
# upload sources
upload_sources: true
# upload translations (& auto-approve 'em)
upload_translations_args: '--auto-approve-imported'
upload_translations: true
download_translations: true
push_translations: true
# download translations
download_translations: true
# GH config
commit_message: "New Crowdin translations by Github Action"
localization_branch_name: main
create_pull_request: false
+18
View File
@@ -0,0 +1,18 @@
name: Auto Comment Merge Conflicts
on: push
permissions:
pull-requests: write
jobs:
auto-comment-merge-conflicts:
runs-on: ubuntu-latest
steps:
- uses: codytseng/auto-comment-merge-conflicts@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
comment-body: "Hey there, there is a merge conflict, can you take a look?"
wait-ms: 3000
max-retries: 5
label-name: "🚨 merge conflict"
ignore-authors: dependabot,otherAuthor
+2 -1
View File
@@ -44,4 +44,5 @@ jobs:
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions! 🙏
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
@@ -22,6 +22,6 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
Feel free to join the conversation at [discord](https://go.cal.com/discord)
Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
issue-message: |
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
+3 -1
View File
@@ -5,7 +5,9 @@ tasks:
next_auth_secret=$(openssl rand -base64 32) &&
calendso_encryption_key=$(openssl rand -base64 24) &&
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" \
-e "s|http://localhost:3000|https://localhost:3000|" \
-e "s|localhost:3000|3000-$GITPOD_WORKSPACE_ID.$GITPOD_WORKSPACE_CLUSTER_HOST|" .env
command: yarn dx
ports:
-1
View File
@@ -1 +0,0 @@
_
+1
View File
@@ -135,3 +135,4 @@ If you get errors, be sure to fix them before committing.
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Be sure to fill the PR Template accordingly.
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
+3
View File
@@ -504,6 +504,9 @@ For example, `Cal.com (support@cal.com)`.
9. Click the "Save"/ "UPDATE" button at the bottom footer.
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
### Obtaining Zoho Calendar Client ID and Secret
[Follow these steps](./packages/app-store/zohocalendar/)
### Obtaining Zoho Bigin Client ID and Secret
[Follow these steps](./packages/app-store/zoho-bigin/)
+3
View File
@@ -1,4 +1,7 @@
BACKEND_URL=http://localhost:3002/api
# BACKEND_URL=https://api.cal.com/v1
FRONTEND_URL=http://localhost:3000
# FRONTEND_URL=https://cal.com
APP_ID=cal-ai
APP_URL=http://localhost:3000/apps/cal-ai
+2 -2
View File
@@ -1,6 +1,6 @@
# Cal.com Email Assistant
Welcome to the first stage of Cal AI!
Welcome to the first stage of Cal.ai!
This app lets you chat with your calendar via email:
@@ -27,7 +27,7 @@ Before running the app, please see [env.mjs](./src/env.mjs) for all required env
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
- A default sender email (for example, `ai@cal.dev`)
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.0.1",
"version": "1.1.0",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
+3 -3
View File
@@ -18,21 +18,21 @@ export const POST = async (request: NextRequest) => {
const json = await request.json();
const { apiKey, userId, message, subject, user, replyTo } = json;
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
if ((!message && !subject) || !user) {
return new NextResponse("Missing fields", { status: 400 });
}
try {
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
// Send response to user
await sendEmail({
subject: `Re: ${subject}`,
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
to: user.email,
from: replyTo,
from: agentEmail,
});
return new NextResponse("ok");
+11 -6
View File
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import { fetchAvailability } from "../../../tools/getAvailability";
import { fetchEventTypes } from "../../../tools/getEventTypes";
import { extractUsers } from "../../../utils/extractUsers";
import getHostFromHeaders from "../../../utils/host";
import now from "../../../utils/now";
import sendEmail from "../../../utils/sendEmail";
@@ -45,6 +46,7 @@ export const POST = async (request: NextRequest) => {
select: {
email: true,
id: true,
username: true,
timeZone: true,
credentials: {
select: {
@@ -53,15 +55,15 @@ export const POST = async (request: NextRequest) => {
},
},
},
where: { email: envelope.from },
where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } },
});
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
subject: `Re: ${body.subject}`,
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
from: aiEmail,
});
@@ -76,9 +78,9 @@ export const POST = async (request: NextRequest) => {
const url = env.APP_URL;
await sendEmail({
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${body.subject}`,
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
to: envelope.from,
from: aiEmail,
});
@@ -89,7 +91,7 @@ export const POST = async (request: NextRequest) => {
const { apiKey } = credential as { apiKey: string };
// Pre-fetch data relevant to most bookings.
const [eventTypes, availability] = await Promise.all([
const [eventTypes, availability, users] = await Promise.all([
fetchEventTypes({
apiKey,
}),
@@ -99,6 +101,7 @@ export const POST = async (request: NextRequest) => {
dateFrom: now(user.timeZone),
dateTo: now(user.timeZone),
}),
extractUsers(`${parsed.text} ${parsed.subject}`),
]);
if ("error" in availability) {
@@ -138,9 +141,11 @@ export const POST = async (request: NextRequest) => {
user: {
email: user.email,
eventTypes,
username: user.username,
timeZone: user.timeZone,
workingHours,
},
users,
}),
headers: {
"Content-Type": "application/json",
+2
View File
@@ -17,6 +17,7 @@ export const env = createEnv({
*/
runtimeEnv: {
BACKEND_URL: process.env.BACKEND_URL,
FRONTEND_URL: process.env.FRONTEND_URL,
APP_ID: process.env.APP_ID,
APP_URL: process.env.APP_URL,
PARSE_KEY: process.env.PARSE_KEY,
@@ -32,6 +33,7 @@ export const env = createEnv({
*/
server: {
BACKEND_URL: z.string().url(),
FRONTEND_URL: z.string().url(),
APP_ID: z.string().min(1),
APP_URL: z.string().url(),
PARSE_KEY: z.string().min(1),
@@ -1,6 +1,8 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import type { UserList } from "~/src/types/user";
import { env } from "../env.mjs";
/**
@@ -9,21 +11,23 @@ import { env } from "../env.mjs";
const createBooking = async ({
apiKey,
userId,
users,
eventTypeId,
start,
end,
timeZone,
language,
responses,
invite,
}: {
apiKey: string;
userId: number;
users: UserList;
eventTypeId: number;
start: string;
end: string;
timeZone: string;
language: string;
responses: { name?: string; email?: string; location?: string };
invite: number;
title?: string;
status?: string;
}): Promise<string | Error | { error: string }> => {
@@ -36,6 +40,18 @@ const createBooking = async ({
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
const user = users.find((u) => u.id === invite);
if (!user) {
return { error: `User with id ${invite} not found to invite` };
}
const responses = {
id: invite,
name: user.username,
email: user.email,
};
const response = await fetch(url, {
body: JSON.stringify({
end,
@@ -66,19 +82,19 @@ const createBooking = async ({
return "Booking created";
};
const createBookingTool = (apiKey: string, userId: number) => {
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
return new DynamicStructuredTool({
description:
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
description: "Creates a booking on the primary user's calendar.",
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
return JSON.stringify(
await createBooking({
apiKey,
userId,
users,
end,
eventTypeId,
language,
responses,
invite,
start,
status,
timeZone,
@@ -86,19 +102,14 @@ const createBookingTool = (apiKey: string, userId: number) => {
})
);
},
name: "createBookingIfAvailable",
name: "createBooking",
schema: z.object({
end: z
.string()
.describe("This should correspond to the event type's length, unless otherwise specified."),
eventTypeId: z.number(),
language: z.string(),
responses: z
.object({
email: z.string().optional(),
name: z.string().optional(),
})
.describe("External invited user. Not the user making the request."),
invite: z.number().describe("External user id to invite."),
start: z.string(),
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
timeZone: z.string(),
+15 -20
View File
@@ -12,13 +12,11 @@ export const fetchAvailability = async ({
userId,
dateFrom,
dateTo,
eventTypeId,
}: {
apiKey: string;
userId: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
}): Promise<Partial<Availability> | { error: string }> => {
const params: { [k: string]: string } = {
apiKey,
@@ -27,8 +25,6 @@ export const fetchAvailability = async ({
dateTo,
};
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
@@ -51,30 +47,29 @@ export const fetchAvailability = async ({
};
};
const getAvailabilityTool = (apiKey: string, userId: number) => {
const getAvailabilityTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get availability within range.",
func: async ({ dateFrom, dateTo, eventTypeId }) => {
description: "Get availability of users within range.",
func: async ({ userIds, dateFrom, dateTo }) => {
return JSON.stringify(
await fetchAvailability({
apiKey,
userId,
dateFrom,
dateTo,
eventTypeId,
})
await Promise.all(
userIds.map(
async (userId) =>
await fetchAvailability({
userId: userId,
apiKey,
dateFrom,
dateTo,
})
)
)
);
},
name: "getAvailability",
schema: z.object({
userIds: z.array(z.number()).describe("The users to fetch availability for."),
dateFrom: z.string(),
dateTo: z.string(),
eventTypeId: z
.number()
.optional()
.describe(
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
),
}),
});
};
+1 -1
View File
@@ -60,7 +60,7 @@ const fetchBookings = async ({
const getBookingsTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Get bookings for a user between two dates.",
description: "Get bookings for the primary user between two dates.",
func: async ({ from, to }) => {
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
},
+13 -5
View File
@@ -7,11 +7,15 @@ import type { EventType } from "../types/eventType";
/**
* Fetches event types by user ID.
*/
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
const params = {
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
const params: Record<string, string> = {
apiKey,
};
if (userId) {
params["userId"] = userId.toString();
}
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
@@ -28,6 +32,7 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
return data.event_types.map((eventType: EventType) => ({
id: eventType.id,
slug: eventType.slug,
length: eventType.length,
title: eventType.title,
}));
@@ -35,16 +40,19 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
const getEventTypesTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
func: async () => {
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
func: async ({ userId }) => {
return JSON.stringify(
await fetchEventTypes({
apiKey,
userId,
})
);
},
name: "getEventTypes",
schema: z.object({}),
schema: z.object({
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
}),
});
};
+81
View File
@@ -0,0 +1,81 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "~/src/env.mjs";
import type { User, UserList } from "~/src/types/user";
import sendEmail from "~/src/utils/sendEmail";
export const sendBookingLink = async ({
user,
agentEmail,
subject,
to,
message,
eventTypeSlug,
date,
}: {
apiKey: string;
user: User;
users: UserList;
agentEmail: string;
subject: string;
to: string[];
message: string;
eventTypeSlug: string;
date: string;
}) => {
const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
await sendEmail({
subject,
to,
cc: user.email,
from: agentEmail,
text: message.split("[[[Booking Link]]]").join(url),
html: message
.split("\n")
.join("<br>")
.split("[[[Booking Link]]]")
.join(`<a href="${url}">Booking Link</a>`),
});
return "Booking link sent";
};
const sendBookingLinkTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
return new DynamicStructuredTool({
description: "Send a booking link via email. Useful for scheduling with non cal users.",
func: async ({ message, subject, to, eventTypeSlug, date }) => {
return JSON.stringify(
await sendBookingLink({
apiKey,
user,
users,
agentEmail,
subject,
to,
message,
eventTypeSlug,
date,
})
);
},
name: "sendBookingLink",
schema: z.object({
message: z
.string()
.describe(
"Make sure to nicely format the message and introduce yourself as the primary user's booking assistant. Make sure to include a spot for the link using: [[[Booking Link]]]"
),
subject: z.string(),
to: z
.array(z.string())
.describe("array of emails to send the booking link to. Primary user is automatically CC'd"),
eventTypeSlug: z.string().describe("the slug of the event type to book"),
date: z.string().describe("the date (yyyy-mm-dd) to suggest for the booking"),
}),
});
};
export default sendBookingLinkTool;
+9
View File
@@ -2,8 +2,17 @@ import type { EventType } from "./eventType";
import type { WorkingHours } from "./workingHours";
export type User = {
id: number;
email: string;
username: string;
timeZone: string;
eventTypes: EventType[];
workingHours: WorkingHours[];
};
export type UserList = {
id?: number;
email?: string;
username?: string;
type: "fromUsername" | "fromEmail";
}[];
+56 -23
View File
@@ -2,30 +2,40 @@ import { initializeAgentExecutorWithOptions } from "langchain/agents";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { env } from "../env.mjs";
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
import createBookingIfAvailable from "../tools/createBooking";
import deleteBooking from "../tools/deleteBooking";
import getAvailability from "../tools/getAvailability";
import getBookings from "../tools/getBookings";
import sendBookingLink from "../tools/sendBookingLink";
import updateBooking from "../tools/updateBooking";
import type { EventType } from "../types/eventType";
import type { User } from "../types/user";
import type { User, UserList } from "../types/user";
import type { WorkingHours } from "../types/workingHours";
import now from "./now";
const gptModel = "gpt-4";
/**
* Core of the Cal AI booking agent: a LangChain Agent Executor.
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
* Uses a toolchain to book meetings, list available slots, etc.
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
*/
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
const agent = async (
input: string,
user: User,
users: UserList,
apiKey: string,
userId: number,
agentEmail: string
) => {
const tools = [
createBookingIfAvailable(apiKey, userId),
getAvailability(apiKey, userId),
// getEventTypes(apiKey),
getAvailability(apiKey),
getBookings(apiKey, userId),
createBookingIfAvailable(apiKey, userId, users),
updateBooking(apiKey, userId),
deleteBooking(apiKey),
sendBookingLink(apiKey, user, users, agentEmail),
];
const model = new ChatOpenAI({
@@ -39,24 +49,47 @@ const agent = async (input: string, user: User, apiKey: string, userId: number)
*/
const executor = await initializeAgentExecutorWithOptions(tools, model, {
agentArgs: {
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
The current time in the user's timezone is: ${now(user.timeZone)}
The user's time zone is: ${user.timeZone}
The user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
.join("\n")}
The user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime}`
)
.join("\n")}
The primary user's id is: ${userId}
The primary user's username is: ${user.username}
The current time in the primary user's timezone is: ${now(user.timeZone)}
The primary user's time zone is: ${user.timeZone}
The primary user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
.join("\n")}
The primary user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime};`
)
.join("\n")}
${
users.length
? `The email references the following @usernames and emails: ${users
.map(
(u) =>
(u.id ? `, id: ${u.id}` : "id: (non user)") +
(u.username
? u.type === "fromUsername"
? `, username: @${u.username}`
: ", username: REDACTED"
: ", (no username)") +
(u.email
? u.type === "fromEmail"
? `, email: ${u.email}`
: ", email: REDACTED"
: ", (no email)") +
";"
)
.join("\n")}`
: ""
}
`,
},
agentType: "openai-functions",
+1
View File
@@ -0,0 +1 @@
export const context = { apiKey: "", userId: "" };
+79
View File
@@ -0,0 +1,79 @@
/*
* Extracts usernames (@Example) and emails (hi@example.com) from a string
*/
import type { UserList } from "../types/user";
export const extractUsers = async (text: string) => {
const usernames = text.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)?.map((username) => username.slice(1));
const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g);
const dbUsersFromUsernames = usernames
? await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
},
where: {
username: {
in: usernames,
},
},
})
: [];
const usersFromUsernames = usernames
? usernames.map((username) => {
const user = dbUsersFromUsernames.find((u) => u.username === username);
return user
? {
username,
id: user.id,
email: user.email,
type: "fromUsername",
}
: {
username,
id: null,
email: null,
type: "fromUsername",
};
})
: [];
const dbUsersFromEmails = emails
? await prisma.user.findMany({
select: {
id: true,
email: true,
username: true,
},
where: {
email: {
in: emails,
},
},
})
: [];
const usersFromEmails = emails
? emails.map((email) => {
const user = dbUsersFromEmails.find((u) => u.email === email);
return user
? {
email,
id: user.id,
username: user.username,
type: "fromEmail",
}
: {
email,
id: null,
username: null,
type: "fromEmail",
};
})
: [];
return [...usersFromUsernames, ...usersFromEmails] as UserList;
};
+5 -2
View File
@@ -8,12 +8,14 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
const send = async ({
subject,
to,
cc,
from,
text,
html,
}: {
subject: string;
to: string;
to: string | string[];
cc?: string | string[];
from: string;
text: string;
html?: string;
@@ -22,9 +24,10 @@ const send = async ({
const msg = {
to,
cc,
from: {
email: from,
name: "Cal AI",
name: "Cal.ai",
},
text,
html,
+3 -3
View File
@@ -1,7 +1,7 @@
import { PrismaClient } from "@prisma/client";
import type { NextMiddleware } from "next-api-middleware";
import { CONSOLE_URL } from "@calcom/lib/constants";
import { customPrisma } from "@calcom/prisma";
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
@@ -12,7 +12,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
} = req;
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
if (!key) {
req.prisma = new PrismaClient();
req.prisma = customPrisma();
await next();
return;
}
@@ -26,7 +26,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
return;
}
req.prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
/* @note:
In order to skip verifyApiKey for customPrisma requests,
we pass isAdmin true, and userId 0, if we detect them later,
+1
View File
@@ -58,6 +58,7 @@ export const schemaBookingReadPublic = Booking.extend({
})
)
.optional(),
responses: z.record(z.any()),
}).pick({
id: true,
userId: true,
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Session } from "next-auth";
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
export type * from "next/types";
+88 -17
View File
@@ -10,9 +10,9 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
/**
* @swagger
* /availability:
* /teams/{teamId}/availability:
* get:
* summary: Find user or team availability
* summary: Find team availability
* parameters:
* - in: query
* name: apiKey
@@ -21,25 +21,14 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* type: string
* example: "1234abcd5678efgh"
* description: Your API key
* - in: query
* name: userId
* schema:
* type: integer
* example: 101
* description: ID of the user to fetch the availability for
* - in: query
* - in: path
* name: teamId
* required: true
* schema:
* type: integer
* example: 123
* description: ID of the team to fetch the availability for
* - in: query
* name: username
* schema:
* type: string
* example: "alice"
* description: username of the user to fetch the availability for
* - in: query
* name: dateFrom
* schema:
* type: string
@@ -59,7 +48,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* type: integer
* example: 123
* description: Event Type ID of the event type to fetch the availability for
* operationId: availability
* operationId: team-availability
* tags:
* - availability
* responses:
@@ -96,7 +85,89 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: User not found | Team not found | Team has no members
* description: Team not found | Team has no members
*
* /availability:
* get:
* summary: Find user availability
* parameters:
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* example: "1234abcd5678efgh"
* description: Your API key
* - in: query
* name: userId
* schema:
* type: integer
* example: 101
* description: ID of the user to fetch the availability for
* - in: query
* name: username
* schema:
* type: string
* example: "alice"
* description: username of the user to fetch the availability for
* - in: query
* name: dateFrom
* schema:
* type: string
* format: date
* example: "2023-05-14 00:00:00"
* description: Start Date of the availability query
* - in: query
* name: dateTo
* schema:
* type: string
* format: date
* example: "2023-05-20 00:00:00"
* description: End Date of the availability query
* - in: query
* name: eventTypeId
* schema:
* type: integer
* example: 123
* description: Event Type ID of the event type to fetch the availability for
* operationId: user-availability
* tags:
* - availability
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* example:
* busy:
* - start: "2023-05-14T10:00:00.000Z"
* end: "2023-05-14T11:00:00.000Z"
* title: "Team meeting between Alice and Bob"
* - start: "2023-05-15T14:00:00.000Z"
* end: "2023-05-15T15:00:00.000Z"
* title: "Project review between Carol and Dave"
* - start: "2023-05-16T09:00:00.000Z"
* end: "2023-05-16T10:00:00.000Z"
* - start: "2023-05-17T13:00:00.000Z"
* end: "2023-05-17T14:00:00.000Z"
* timeZone: "America/New_York"
* workingHours:
* - days: [1, 2, 3, 4, 5]
* startTime: 540
* endTime: 1020
* userId: 101
* dateOverrides:
* - date: "2023-05-15"
* startTime: 600
* endTime: 960
* userId: 101
* currentSeats: 4
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: User not found
*/
interface MemberRoles {
[userId: number | string]: MembershipRole;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
+2 -2
View File
@@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
}, []);
return (
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
<div className="flex items-center gap-x-3 px-5 py-4">
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
{logo ? (
<img
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useState } from "react";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import type { App } from "@calcom/types/App";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuTrigger,
List,
showToast,
Button,
DropdownMenuItem,
Alert,
} from "@calcom/ui";
import { MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon";
import AppListCard from "@components/AppListCard";
interface AppListProps {
variant?: AppCategories;
data: RouterOutputs["viewer"]["integrations"];
handleDisconnect: (credentialId: number) => void;
listClassName?: string;
}
export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => {
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const utils = trpc.useContext();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const onSuccessCallback = useCallback(() => {
setBulkUpdateModal(true);
showToast("Default app updated successfully", "success");
}, []);
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
});
const ChildAppCard = ({
item,
}: {
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
credentialOwner?: CredentialOwner;
};
}) => {
const appSlug = item?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
return (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
credentialOwner={item?.credentialOwner}
actions={
!item.credentialOwner?.readOnly ? (
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
setBulkUpdateModal(true);
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
/>
</DropdownMenuContent>
</Dropdown>
</div>
) : null
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
};
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
const appCards = [];
if (app.userCredentialIds.length) {
appCards.push(<ChildAppCard item={app} />);
}
for (const team of app.teams) {
if (team) {
appCards.push(
<ChildAppCard
item={{
...app,
credentialOwner: {
name: team.name,
avatar: team.logo,
teamId: team.teamId,
credentialId: team.credentialId,
readOnly: !team.isAdmin,
},
}}
/>
);
}
}
return appCards;
});
const { t } = useLocale();
return (
<>
<List className={listClassName}>
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
if (!item.teams.length) return <ChildAppCard item={item} />;
})}
</List>
{locationType && (
<AppSetDefaultLinkDialog
locationType={locationType}
setLocationType={() => setLocationType(undefined)}
onSuccess={onSuccessCallback}
/>
)}
{bulkUpdateModal && (
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
)}
</>
);
};
function ConnectOrDisconnectIntegrationMenuItem(props: {
credentialId: number;
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
invalidCredentialIds?: number[];
teamId?: number;
handleDisconnect: (credentialId: number, teamId?: number) => void;
}) {
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
const { t } = useLocale();
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.viewer.integrations.invalidate();
};
if (credentialId || type === "stripe_payment" || isGlobal) {
return (
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)}
disabled={isGlobal}
StartIcon={Trash}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
);
}
if (!installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
return (
<InstallAppButton
type={type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("install")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}
+1
View File
@@ -89,6 +89,7 @@ export const AppPage = ({
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
{ appType: type },
{
@@ -76,6 +76,7 @@ export const InstallAppButtonChild = ({
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
className="max-w-min"
onInteractOutside={(event) => {
if (mutation.isLoading) event.preventDefault();
}}>
+148 -112
View File
@@ -34,7 +34,7 @@ import {
TextField,
Tooltip,
} from "@calcom/ui";
import { Copy, Edit } from "@calcom/ui/components/icon";
import { Copy, Edit, Info } from "@calcom/ui/components/icon";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const setEventName = (value: string) => formMethods.setValue("eventName", value);
return (
<div className="flex flex-col space-y-8">
<div className="flex flex-col space-y-4">
{/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attendee calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<div className="flex justify-between">
<Label>{t("add_to_calendar")}</Label>
<Link
href="/apps/categories/calendar"
target="_blank"
className="hover:text-emphasis text-default text-sm">
{t("add_another_calendar")}
</Link>
<div className="border-subtle space-y-6 rounded-md border p-6">
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<div className="flex justify-between">
<Label className="font-medium">{t("add_to_calendar")}</Label>
<Link
href="/apps/categories/calendar"
target="_blank"
className="hover:text-emphasis text-default text-sm">
{t("add_another_calendar")}
</Link>
</div>
<div className="-mt-1 w-full">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
destinationCalendar={eventType.destinationCalendar}
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
</div>
<div className="-mt-1 w-full">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
destinationCalendar={eventType.destinationCalendar}
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
<p className="text-default text-sm">{t("select_which_cal")}</p>
)}
<div className="w-full">
<TextField
label={t("event_name_in_calendar")}
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Edit className="h-4 w-4" />
</Button>
}
/>
</div>
)}
<div className="w-full">
<TextField
label={t("event_name_in_calendar")}
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Edit className="h-4 w-4" />
</Button>
}
</div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
<div className="border-subtle space-y-6 rounded-md border p-6">
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
},
}}
/>
</div>
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
<div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
</div>
<hr className="border-subtle" />
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
},
}}
/>
<hr className="border-subtle" />
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
@@ -204,13 +206,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
requiresConfirmation={requiresConfirmation}
onRequiresConfirmation={setRequiresConfirmation}
/>
<hr className="border-subtle" />
<Controller
name="requiresBookerEmailVerification"
control={formMethods.control}
defaultValue={eventType.requiresBookerEmailVerification}
render={({ field: { value, onChange } }) => (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("requires_booker_email_verification")}
{...shouldLockDisableProps("requiresBookerEmailVerification")}
description={t("description_requires_booker_email_verification")}
@@ -219,13 +223,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
)}
/>
<hr className="border-subtle" />
<Controller
name="hideCalendarNotes"
control={formMethods.control}
defaultValue={eventType.hideCalendarNotes}
render={({ field: { value, onChange } }) => (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("disable_notes")}
{...shouldLockDisableProps("hideCalendarNotes")}
description={t("disable_notes_description")}
@@ -234,13 +240,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
)}
/>
<hr className="border-subtle" />
<Controller
name="successRedirectUrl"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
redirectUrlVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("redirect_success_booking")}
{...successRedirectUrlLocked}
description={t("redirect_url_description")}
@@ -249,8 +261,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
setRedirectUrlVisible(e);
onChange(e ? value : "");
}}>
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
<div className="lg:-mb-2 lg:-ml-2">
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<TextField
className="w-full"
label={t("redirect_success_booking")}
@@ -274,10 +285,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</>
)}
/>
<hr className="border-subtle" />
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
hashedLinkVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="hashedLinkCheck"
title={t("private_link")}
Badge={
<a
target="_blank"
rel="noreferrer"
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
</a>
}
{...shouldLockDisableProps("hashedLinkCheck")}
description={t("private_link_description", { appName: APP_NAME })}
checked={hashedLinkVisible}
@@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
setHashedLinkVisible(e);
}}>
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
<div className="lg:-ml-2">
<div className="border-subtle rounded-b-md border border-t-0 p-6">
{!IS_VISUAL_REGRESSION_TESTING && (
<TextField
disabled
@@ -321,7 +345,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
</div>
</SettingsToggle>
<hr className="border-subtle" />
<Controller
name="seatsPerTimeSlotEnabled"
control={formMethods.control}
@@ -329,6 +353,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
value && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="offer-seats-toggle"
title={t("offer_seats")}
{...seatsLocked}
@@ -349,45 +379,49 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}
onChange(e);
}}>
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div className="lg:-ml-2">
<TextField
required
name="seatsPerTimeSlot"
labelSrOnly
label={t("number_of_seats")}
type="number"
disabled={seatsLocked.disabled}
defaultValue={value || 2}
min={1}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
/>
<div className="mt-2">
<CheckboxField
description={t("show_attendees")}
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div className="lg:-ml-2">
<TextField
required
name="seatsPerTimeSlot"
labelSrOnly
label={t("number_of_seats")}
type="number"
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
defaultValue={value || 2}
min={1}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
/>
<div className="mt-2">
<CheckboxField
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
/>
</div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) =>
formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)
}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
/>
</div>
</div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
/>
</div>
</div>
)}
/>
)}
/>
</div>
</SettingsToggle>
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
</>
@@ -395,13 +429,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
{allowDisablingAttendeeConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.attendee"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("disable_attendees_confirmation_emails")}
description={t("disable_attendees_confirmation_emails_description")}
checked={value || false}
@@ -417,7 +452,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
{allowDisablingHostConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.host"
control={formMethods.control}
@@ -425,6 +459,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("disable_host_confirmation_emails")}
description={t("disable_host_confirmation_emails_description")}
checked={value || false}
@@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</div>
</div>
{!shouldLockDisableProps("apps").disabled && (
<div className="bg-muted rounded-md p-8">
<div className="bg-muted mt-6 rounded-md p-8">
{!isLoading && notInstalledApps?.length ? (
<>
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
@@ -166,7 +166,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</h2>
<p className="text-default mb-6 text-sm font-normal">
<Trans i18nKey="available_apps_desc">
You have no apps installed. View popular apps below and explore more in our &nbsp;
View popular apps below and explore more in our &nbsp;
<Link className="cursor-pointer underline" href="/apps">
App Store
</Link>
@@ -1,5 +1,5 @@
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
import { useState, memo } from "react";
import { useState, memo, useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
@@ -98,42 +98,43 @@ const EventTypeScheduleDetails = memo(
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
return (
<div className="border-default space-y-4 rounded border px-6 pb-4">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32 ",
!isAvailable ? "text-subtle line-through" : "text-default"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="text-default flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ms-4">-</span>
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
<hr className="border-subtle" />
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
<div>
<div className="border-subtle space-y-4 border-x p-6">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32 ",
!isAvailable ? "text-subtle line-through" : "text-default"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="text-default flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ms-4">-</span>
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
</div>
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
@@ -164,9 +165,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const { watch } = useFormContext<FormValues>();
const { watch, setValue, getValues } = useFormContext<FormValues>();
const watchSchedule = watch("schedule");
const formMethods = useFormContext<FormValues>();
const [options, setOptions] = useState<AvailabilityOption[]>([]);
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
@@ -214,7 +214,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
setOptions(options);
const scheduleId = formMethods.getValues("schedule");
const scheduleId = getValues("schedule");
const value = options.find((option) =>
scheduleId
? option.value === scheduleId
@@ -223,15 +223,20 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
);
formMethods.setValue("availability", value);
setValue("availability", value);
},
});
const availabilityValue = formMethods.watch("availability");
const availabilityValue = watch("availability");
useEffect(() => {
if (!availabilityValue?.value) return;
setValue("schedule", availabilityValue.value);
}, [availabilityValue, setValue]);
return (
<div className="space-y-4">
<div>
<div>
<div className="border-subtle rounded-t-md border p-6">
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
{t("availability")}
{shouldLockIndicator("availability")}
@@ -248,7 +253,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
isSearchable={false}
onChange={(selected) => {
field.onChange(selected?.value || null);
if (selected?.value) formMethods.setValue("availability", selected);
if (selected?.value) setValue("availability", selected);
}}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
value={availabilityValue}
@@ -276,7 +281,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const { resetField, setValue } = useFormContext<FormValues>();
const { setValue } = useFormContext<FormValues>();
return (
<Controller
name="metadata.config.useHostSchedulesForTeamEvent"
@@ -285,9 +290,7 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
checked={!value}
onCheckedChange={(checked) => {
onChange(!checked);
if (checked) {
resetField("schedule");
} else {
if (!checked) {
setValue("schedule", null);
}
}}
+203 -158
View File
@@ -17,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
import type { PeriodType } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
import { Plus, Trash } from "@calcom/ui/components/icon";
import { Plus, Trash2 } from "@calcom/ui/components/icon";
const MinimumBookingNoticeInput = React.forwardRef<
HTMLInputElement,
@@ -83,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
type="number"
placeholder="0"
min={0}
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
className="mb-0 h-9 rounded-[4px] ltr:mr-2 rtl:ml-2"
/>
<input type="hidden" ref={ref} {...passThroughProps} />
</div>
<Select
isSearchable={false}
isDisabled={passThroughProps.disabled}
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
defaultValue={durationTypeOptions.find(
(option) => option.value === minimumBookingNoticeDisplayValues.type
)}
@@ -170,8 +170,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
return (
<div className="space-y-8">
<div className="space-y-4 lg:space-y-8">
<div>
<div className="border-subtle space-y-6 rounded-md 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">
@@ -295,159 +295,195 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
</div>
</div>
<hr className="border-subtle" />
<Controller
name="bookingLimits"
control={formMethods.control}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", {});
}
}}>
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
</SettingsToggle>
)}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", {});
}
}}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0">
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
</div>
</SettingsToggle>
);
}}
/>
<hr className="border-subtle" />
<Controller
name="durationLimits"
control={formMethods.control}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
}
}}>
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
</SettingsToggle>
)}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
}
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
</div>
</SettingsToggle>
);
}}
/>
<hr className="border-subtle" />
<Controller
name="periodType"
control={formMethods.control}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={value && value !== "UNLIMITED"}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"text-default mb-2 flex flex-wrap items-center text-sm",
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
<TextField
labelSrOnly
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
}
/>
</div>
)}
{period.type === "RANGE" && (
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
render={({ field: { value } }) => {
const isChecked = value && value !== "UNLIMITED";
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={isChecked}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"text-default mb-2 flex flex-wrap items-center text-sm",
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
<TextField
labelSrOnly
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
endDate,
});
}}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
)}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
}
/>
</div>
)}
{period.type === "RANGE" && (
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
endDate,
});
}}
/>
)}
/>
</div>
)}
{period.suffix ? <span className="me-2 ms-2">&nbsp;{period.suffix}</span> : null}
</div>
)}
{period.suffix ? <span className="me-2 ms-2">&nbsp;{period.suffix}</span> : null}
</div>
);
})}
</RadioGroup.Root>
</SettingsToggle>
)}
);
})}
</RadioGroup.Root>
</div>
</SettingsToggle>
);
}}
/>
<hr className="border-subtle" />
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
offsetToggle && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
@@ -458,18 +494,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("offsetStart", 0);
}
}}>
<TextField
required
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<TextField
required
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
</div>
</SettingsToggle>
</div>
);
@@ -509,19 +547,19 @@ const IntervalLimitItem = ({
onIntervalSelect,
}: IntervalLimitItemProps) => {
return (
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
<div className="mb-4 flex max-h-9 items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
<TextField
required
type="number"
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
className="mb-0 !h-auto"
className="mb-0"
placeholder={`${value}`}
disabled={disabled}
min={step}
step={step}
defaultValue={value}
addOnSuffix={textFieldSuffix}
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
/>
<Select
options={selectOptions}
@@ -529,9 +567,16 @@ const IntervalLimitItem = ({
isDisabled={disabled}
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
onChange={onIntervalSelect}
className="w-36"
/>
{hasDeleteButton && !disabled && (
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
<Button
variant="icon"
StartIcon={Trash2}
color="destructive"
className="border-none"
onClick={() => onDelete(limitKey)}
/>
)}
</div>
);
+171 -164
View File
@@ -387,178 +387,185 @@ export const EventSetupTab = (
return (
<div>
<div className="space-y-8">
<TextField
required
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
<div>
<Label>
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
</div>
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
{urlPrefix}/
{!isManagedEventType
? team
? (orgBranding ? "" : "team/") + team.slug
: eventType.users[0].username
: t("username_placeholder")}
/
</>
}
{...formMethods.register("slug", {
setValueAs: (v) => slugify(v),
})}
/>
{multipleDuration ? (
<div className="space-y-4">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("available_durations")}
</Skeleton>
<Select
isMulti
defaultValue={selectedMultipleDuration}
name="metadata.multipleDuration"
isSearchable={false}
className="h-auto !min-h-[36px] text-sm"
options={multipleDurationOptions}
value={selectedMultipleDuration}
onChange={(options) => {
let newOptions = [...options];
newOptions = newOptions.sort((a, b) => {
return a?.value - b?.value;
});
const values = newOptions.map((opt) => opt.value);
setMultipleDuration(values);
setSelectedMultipleDuration(newOptions);
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
if (newOptions.length > 0) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
} else {
setDefaultDuration(null);
}
}
if (newOptions.length === 1 && defaultDuration === null) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
}
formMethods.setValue("metadata.multipleDuration", values);
}}
/>
</div>
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("default_duration")}
{shouldLockIndicator("length")}
</Skeleton>
<Select
value={defaultDuration}
isSearchable={false}
name="length"
className="text-sm"
isDisabled={lengthLockedProps.disabled}
noOptionsMessage={() => t("default_duration_no_options")}
options={selectedMultipleDuration}
onChange={(option) => {
setDefaultDuration(
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
);
if (option) formMethods.setValue("length", option.value);
}}
/>
</div>
</div>
) : (
<div className="space-y-4">
<div className="border-subtle space-y-6 rounded-md border p-6">
<TextField
required
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
min={1}
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
)}
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
}}
<div>
<Label>
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
</div>
)}
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
{shouldLockIndicator("locations")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
{urlPrefix}/
{!isManagedEventType
? team
? (orgBranding ? "" : "team/") + team.slug
: eventType.users[0].username
: t("username_placeholder")}
/
</>
}
{...formMethods.register("slug", {
setValueAs: (v) => slugify(v),
})}
/>
</div>
</div>
<div className="border-subtle rounded-md border p-6">
{multipleDuration ? (
<div className="space-y-6">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("available_durations")}
</Skeleton>
<Select
isMulti
defaultValue={selectedMultipleDuration}
name="metadata.multipleDuration"
isSearchable={false}
className="h-auto !min-h-[36px] text-sm"
options={multipleDurationOptions}
value={selectedMultipleDuration}
onChange={(options) => {
let newOptions = [...options];
newOptions = newOptions.sort((a, b) => {
return a?.value - b?.value;
});
const values = newOptions.map((opt) => opt.value);
setMultipleDuration(values);
setSelectedMultipleDuration(newOptions);
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
if (newOptions.length > 0) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
} else {
setDefaultDuration(null);
}
}
if (newOptions.length === 1 && defaultDuration === null) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
}
formMethods.setValue("metadata.multipleDuration", values);
}}
/>
</div>
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("default_duration")}
{shouldLockIndicator("length")}
</Skeleton>
<Select
value={defaultDuration}
isSearchable={false}
name="length"
className="text-sm"
isDisabled={lengthLockedProps.disabled}
noOptionsMessage={() => t("default_duration_no_options")}
options={selectedMultipleDuration}
onChange={(option) => {
setDefaultDuration(
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
);
if (option) formMethods.setValue("length", option.value);
}}
/>
</div>
</div>
) : (
<TextField
required
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
min={1}
/>
)}
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
}}
/>
</div>
)}
</div>
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
<div className="border-subtle rounded-md border p-6">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
{shouldLockIndicator("locations")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
/>
</div>
</div>
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
</div>
</div>
);
};
@@ -1,5 +1,7 @@
import type { Webhook } from "@prisma/client";
import { Webhook as TbWebhook } from "lucide-react";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useState } from "react";
@@ -8,6 +10,7 @@ import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
@@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
)}
{webhooks.length ? (
<>
<div className="mb-2 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
<div className="border-subtle mb-2 rounded-md border p-8">
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
<div className="border-subtle mt-8 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
</div>
<p className="text-default mt-8 text-sm font-normal">
<Trans i18nKey="edit_or_manage_webhooks">
If you wish to edit or manage your web hooks, please head over to &nbsp;
<Link
className="cursor-pointer font-semibold underline"
href="/settings/developer/webhooks">
webhooks settings
</Link>
</Trans>
</p>
</div>
<NewWebhookButton />
</>
) : (
<EmptyScreen
@@ -3,6 +3,7 @@ import { useState } from "react";
import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
@@ -47,6 +48,12 @@ export default function RecurringEventController({
) : (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
recurringEventState !== null && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("recurring_event")}
{...recurringLocked}
description={t("recurring_event_description")}
@@ -66,68 +73,70 @@ export default function RecurringEventController({
setRecurringEventState(newVal);
}
}}>
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="mb-0"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
isDisabled={recurringLocked.disabled}
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="mb-0"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
isDisabled={recurringLocked.disabled}
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
defaultValue={recurringEventState.count}
className="mb-0"
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
{t("events", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
<div className="mt-4 flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
defaultValue={recurringEventState.count}
className="mb-0"
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
{t("events", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
)}
)}
</div>
</SettingsToggle>
</>
)}
@@ -67,6 +67,12 @@ export default function RequiresConfirmationController({
control={formMethods.control}
render={() => (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
requiresConfirmation && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("requires_confirmation")}
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
@@ -77,107 +83,111 @@ export default function RequiresConfirmationController({
formMethods.setValue("requiresConfirmation", val);
onRequiresConfirmation(val);
}}>
<RadioGroup.Root
defaultValue={
requiresConfirmation
? requiresConfirmationSetup === undefined
? "always"
: "notice"
: undefined
}
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
);
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<RadioGroup.Root
defaultValue={
requiresConfirmation
? requiresConfirmationSetup === undefined
? "always"
: "notice"
: undefined
}
}}>
<div className="flex flex-col flex-wrap justify-start gap-y-2">
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
label={t("always_requires_confirmation")}
disabled={requiresConfirmationLockedProps.disabled}
id="always"
value="always"
/>
)}
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.time",
val
);
}}
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
className="ml-2"
onChange={(opt) => {
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
);
}
}}>
<div className="flex flex-col flex-wrap justify-start gap-y-2">
{(requiresConfirmationSetup === undefined ||
!requiresConfirmationLockedProps.disabled) && (
<RadioField
label={t("always_requires_confirmation")}
disabled={requiresConfirmationLockedProps.disabled}
id="always"
value="always"
/>
)}
{(requiresConfirmationSetup !== undefined ||
!requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
"metadata.requiresConfirmationThreshold.time",
val
);
}}
defaultValue={defaultValue}
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
)}
</div>
</RadioGroup.Root>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
onChange={(opt) => {
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
);
}}
defaultValue={defaultValue}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
)}
</div>
</RadioGroup.Root>
</div>
</SettingsToggle>
)}
/>
@@ -1,9 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
@@ -22,7 +23,7 @@ const UserSettings = (props: IUserSettingsProps) => {
const { nextStep } = props;
const [user] = trpc.viewer.me.useSuspenseQuery();
const { t } = useLocale();
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const telemetry = useTelemetry();
const userSettingsSchema = z.object({
name: z
@@ -173,7 +173,6 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
<div className="mb-4">
<PasswordField
label={t("password")}
type="password"
name="password"
id="password"
required
+6 -5
View File
@@ -9,8 +9,7 @@ import { Avatar } from "@calcom/ui";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = MembersType[number] & { safeBio: string | null };
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const routerQuery = useRouterQuery();
@@ -50,7 +49,9 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
}
return (
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
<section
data-testid="team-members-container"
className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
{members.map((member) => {
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
})}
@@ -58,10 +59,10 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
);
};
const Team = ({ team }: { team: TeamTypeWithSafeHtml }) => {
const Team = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
return (
<div>
<Members members={team.members} teamName={team.name} />
<Members members={members} teamName={teamName} />
</div>
);
};
+2 -1
View File
@@ -26,7 +26,8 @@ const initClock = () => {
// This only sets browser locale if there's no preference on localStorage.
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
timeOptions.inviteeTimeZone =
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London";
};
const is24h = (is24hClock?: boolean) => {
+4
View File
@@ -226,6 +226,10 @@ const nextConfig = {
},
async rewrites() {
const beforeFiles = [
{
source: "/login",
destination: "/auth/login",
},
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
...(isOrganizationsEnabled
? [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.3.0",
"version": "3.3.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -5
View File
@@ -250,11 +250,8 @@ export default function Custom404() {
) : IS_CALCOM ? (
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
<strong className="text-blue-500">
{new URL(WEBSITE_URL).hostname}
{username}
</strong>{" "}
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
<span className="text-blue-500">{t("register_now")}</span>.
</a>
) : (
<span className="mt-2 inline-block text-lg">
+1 -1
View File
@@ -154,7 +154,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
<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} />
<EventTypeDescription eventType={type} isPublic={true} shortenDescription />
</Link>
</div>
</div>
+2 -3
View File
@@ -10,8 +10,7 @@ import {
getMultipleDurationValue,
} from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
@@ -152,7 +151,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug),
},
select: {
away: true,
+5 -1
View File
@@ -29,7 +29,11 @@ class MyDocument extends Document<Props> {
const asPath = ctx.asPath || "";
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
const parsedUrl = new URL(asPath, "https://dummyurl");
const isEmbed = parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null;
const isEmbedSnippetGeneratorPath = parsedUrl.pathname.startsWith("/event-types");
// FIXME: Revisit this logic to remove embedType query param check completely. Ideally, /embed should always be there at the end of the URL. Test properly and then remove it.
const isEmbed =
(parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null) &&
!isEmbedSnippetGeneratorPath;
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
const initialProps = await Document.getInitialProps(ctx);
return { isEmbed, embedColorScheme, nonce, ...initialProps };
+28 -20
View File
@@ -10,6 +10,7 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -89,6 +90,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
// Identify the org id in an org context signup, either the invited team is an org
// or has a parentId, otherwise parentId will be null, making orgId null
const orgId = teamMetadata?.isOrganization ? team.id : team.parentId;
const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
@@ -96,49 +101,52 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
organizationId: orgId,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
organizationId: orgId,
},
});
if (teamMetadata?.isOrganization) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await prisma.membership.update({
const membership = await prisma.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
data: {
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
accepted: true,
role: MembershipRole.MEMBER,
},
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
// Accept any child team invites for orgs and create a membership for the org itself
if (team.parentId) {
// Join ORG
await prisma.user.update({
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
await prisma.membership.upsert({
where: {
id: user.id,
userId_teamId: { userId: user.id, teamId: team.parentId },
},
data: {
organizationId: team.parentId,
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.parentId,
accepted: true,
role: MembershipRole.MEMBER,
},
});
/** We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG. */
// We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG
await prisma.membership.updateMany({
where: {
userId: user.id,
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const deleted = await prisma.calendarCache.deleteMany({
where: {
// Delete all cache entries that expired before now
expiresAt: {
lte: new Date(Date.now()),
},
},
});
res.json({ ok: true, count: deleted.count });
}
@@ -0,0 +1,340 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
import { EventsInsights } from "@calcom/features/insights/server/events";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(req.query);
const firstDateOfMonth = new Date();
firstDateOfMonth.setDate(1);
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
createdAt: {
// created before or on the first day of this month
lte: firstDateOfMonth,
},
},
select: {
id: true,
createdAt: true,
members: true,
name: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
for (const team of teams) {
const EventData: {
Created: number;
Completed: number;
Rescheduled: number;
Cancelled: number;
mostBookedEvents: {
eventTypeId?: number | null;
eventTypeName?: string | null;
count?: number | null;
}[];
membersWithMostBookings: {
userId: number | null;
user: {
id: number;
name: string | null;
email: string;
avatar: string | null;
username: string | null;
};
count: number;
}[];
admin: { email: string; name: string };
team: {
name: string;
id: number;
};
} = {
Created: 0,
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
mostBookedEvents: [],
membersWithMostBookings: [],
admin: { email: "", name: "" },
team: { name: team.name, id: team.id },
};
const userIdsFromTeams = team.members.map((u) => u.userId);
// Booking Events
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
OR: [
{
teamId: team.id,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
],
};
const promisesResult = await Promise.all([
EventsInsights.getCreatedEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getCompletedEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getRescheduledEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getCancelledEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
]);
EventData["Created"] = promisesResult[0];
EventData["Completed"] = promisesResult[1];
EventData["Rescheduled"] = promisesResult[2];
EventData["Cancelled"] = promisesResult[3];
// Most Booked Event Type
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
createdAt: {
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
lte: dayjs(new Date()).endOf("day").toDate(),
},
OR: [
{
teamId: team.id,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
],
};
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const eventTypeIds = bookingsFromSelected
.filter((booking) => typeof booking.eventTypeId === "number")
.map((booking) => booking.eventTypeId);
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
id: {
in: eventTypeIds as number[],
},
};
const eventTypesFrom = await prisma.eventType.findMany({
select: {
id: true,
title: true,
teamId: true,
userId: true,
slug: true,
users: {
select: {
username: true,
},
},
team: {
select: {
slug: true,
},
},
},
where: eventTypeWhereConditional,
});
const eventTypeHashMap: Map<
number,
Prisma.EventTypeGetPayload<{
select: {
id: true;
title: true;
teamId: true;
userId: true;
slug: true;
users: {
select: {
username: true;
};
};
team: {
select: {
slug: true;
};
};
};
}>
> = new Map();
eventTypesFrom.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType);
});
EventData["mostBookedEvents"] = bookingsFromSelected.map((booking) => {
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
if (!eventTypeSelected) {
return {};
}
let eventSlug = "";
if (eventTypeSelected.userId) {
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
}
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
}
return {
eventTypeId: booking.eventTypeId,
eventTypeName: eventSlug,
count: booking._count.id,
};
});
// Most booked members
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const userIds = bookingsFromTeam
.filter((booking) => typeof booking.userId === "number")
.map((booking) => booking.userId);
if (userIds.length === 0) {
EventData["membersWithMostBookings"] = [];
} else {
const teamUsers = await prisma.user.findMany({
where: {
id: {
in: userIds as number[],
},
},
select: { id: true, name: true, email: true, avatar: true, username: true },
});
const userHashMap = new Map();
teamUsers.forEach((user) => {
userHashMap.set(user.id, user);
});
EventData["membersWithMostBookings"] = bookingsFromTeam.map((booking) => {
return {
userId: booking.userId,
user: userHashMap.get(booking.userId),
count: booking._count.id,
};
});
}
// Send mail to all Owners and Admins
const mailReceivers = team?.members?.filter(
(member) => member.role === "OWNER" || member.role === "ADMIN"
);
const mailsToSend = mailReceivers.map(async (receiver) => {
const owner = await prisma.user.findUnique({
where: {
id: receiver?.userId,
},
});
if (owner) {
const t = await getTranslation(owner?.locale ?? "en", "common");
// Only send email if user has allowed to receive monthly digest emails
if (owner.receiveMonthlyDigestEmail) {
await sendMonthlyDigestEmails({
...EventData,
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
language: t,
});
}
}
});
await Promise.all(mailsToSend);
await delay(100); // Adjust the delay as needed to avoid rate limiting
}
pageNumber++;
}
res.json({ ok: true });
}
+43 -7
View File
@@ -13,14 +13,50 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("VerifyAccountEmail", {
renderEmail("MonthlyDigestEmail", {
language: t,
user: {
name: "Pro Example",
email: "pro@example.com",
},
verificationEmailLink:
"http://localhost:3000/api/auth/verify-email?token=b91af0eee5a9a24a8d83a3d3d6a58c1606496e94ced589441649273c66100f5b",
Created: 12,
Completed: 13,
Rescheduled: 14,
Cancelled: 16,
mostBookedEvents: [
{
eventTypeId: 3,
eventTypeName: "Test1",
count: 3,
},
{
eventTypeId: 4,
eventTypeName: "Test2",
count: 5,
},
],
membersWithMostBookings: [
{
userId: 4,
user: {
id: 4,
name: "User1 name",
email: "email.com",
avatar: "none",
username: "User1",
},
count: 4,
},
{
userId: 6,
user: {
id: 6,
name: "User2 name",
email: "email2.com",
avatar: "none",
username: "User2",
},
count: 8,
},
],
admin: { email: "admin.com", name: "admin" },
team: { name: "Team1", id: 4 },
})
);
res.end();
@@ -0,0 +1 @@
export { default, config } from "@calcom/app-store/alby/api/webhook";
@@ -1 +1 @@
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
export { default, config } from "@calcom/app-store/paypal/api/webhook";
@@ -1,4 +0,0 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router";
export default createNextApiHandler(kycVerificationRouter);
@@ -0,0 +1,88 @@
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
const appCredentialWebhookRequestBodySchema = z.object({
// UserId of the cal.com user
userId: z.number().int(),
appSlug: z.string(),
// Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
keys: z.string(),
});
/** */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!APP_CREDENTIAL_SHARING_ENABLED) {
return res.status(403).json({ message: "Credential sharing is not enabled" });
}
if (
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
process.env.CALCOM_WEBHOOK_SECRET
) {
return res.status(403).json({ message: "Invalid webhook secret" });
}
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
if (!user) {
return res.status(404).json({ message: "User not found" });
}
const app = await prisma.app.findUnique({
where: { slug: reqBody.appSlug },
select: { dirName: true },
});
if (!app) {
return res.status(404).json({ message: "App not found" });
}
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
if (!appMetadata) {
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
}
const keys = JSON.parse(
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
// INFO: Can't use prisma upsert as we don't know the id of the credential
const appCredential = await prisma.credential.findFirst({
where: {
userId: reqBody.userId,
appId: appMetadata.slug,
},
select: {
id: true,
},
});
if (appCredential) {
await prisma.credential.update({
where: {
id: appCredential.id,
},
data: {
key: keys,
},
});
return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` });
} else {
await prisma.credential.create({
data: {
key: keys,
userId: reqBody.userId,
appId: appMetadata.slug,
type: appMetadata.type,
},
});
return res.status(200).json({ message: `Credentials created for userId: ${reqBody.userId}` });
}
}
+4 -11
View File
@@ -1,14 +1,14 @@
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const searchParams = useSearchParams();
const router = useRouter();
const slug = searchParams?.get("slug") as string;
@@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
SetupInformation.PageWrapper = PageWrapper;
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: "blocking",
};
};
export { getStaticProps };
export { getServerSideProps };
+6 -234
View File
@@ -1,35 +1,13 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useReducer, useState } from "react";
import z from "zod";
import { useReducer } from "react";
import { z } from "zod";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import type { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
import {
Alert,
Button,
EmptyScreen,
List,
AppSkeletonLoader as SkeletonLoader,
ShellSubHeading,
DropdownMenuTrigger,
DropdownMenuContent,
Dropdown,
DropdownMenuItem,
DropdownItem,
showToast,
} from "@calcom/ui";
import { Button, EmptyScreen, AppSkeletonLoader as SkeletonLoader, ShellSubHeading } from "@calcom/ui";
import type { LucideIcon } from "@calcom/ui/components/icon";
import {
BarChart,
@@ -38,231 +16,24 @@ import {
CreditCard,
Grid,
Mail,
MoreHorizontal,
Plus,
Share2,
Trash,
Video,
} from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import AppListCard from "@components/AppListCard";
import PageWrapper from "@components/PageWrapper";
import { AppList } from "@components/apps/AppList";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
function ConnectOrDisconnectIntegrationMenuItem(props: {
credentialId: number;
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
invalidCredentialIds?: number[];
teamId?: number;
handleDisconnect: (credentialId: number, teamId?: number) => void;
}) {
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
const { t } = useLocale();
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.viewer.integrations.invalidate();
};
if (credentialId || type === "stripe_payment" || isGlobal) {
return (
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)}
disabled={isGlobal}
StartIcon={Trash}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
);
}
if (!installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
return (
<InstallAppButton
type={type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("install")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}
interface IntegrationsContainerProps {
variant?: AppCategories;
exclude?: AppCategories[];
handleDisconnect: (credentialId: number) => void;
}
interface IntegrationsListProps {
variant?: IntegrationsContainerProps["variant"];
data: RouterOutputs["viewer"]["integrations"];
handleDisconnect: (credentialId: number) => void;
}
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const utils = trpc.useContext();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const onSuccessCallback = useCallback(() => {
setBulkUpdateModal(true);
showToast("Default app updated successfully", "success");
}, []);
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
});
const ChildAppCard = ({
item,
}: {
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
credentialOwner?: CredentialOwner;
};
}) => {
const appSlug = item?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
return (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
credentialOwner={item?.credentialOwner}
actions={
!item.credentialOwner?.readOnly ? (
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
setBulkUpdateModal(true);
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
/>
</DropdownMenuContent>
</Dropdown>
</div>
) : null
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
};
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
const appCards = [];
if (app.userCredentialIds.length) {
appCards.push(<ChildAppCard item={app} />);
}
for (const team of app.teams) {
if (team) {
appCards.push(
<ChildAppCard
item={{
...app,
credentialOwner: {
name: team.name,
avatar: team.logo,
teamId: team.teamId,
credentialId: team.credentialId,
readOnly: !team.isAdmin,
},
}}
/>
);
}
}
return appCards;
});
const { t } = useLocale();
return (
<>
<List>
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
if (!item.teams.length) return <ChildAppCard item={item} />;
})}
</List>
{locationType && (
<AppSetDefaultLinkDialog
locationType={locationType}
setLocationType={() => setLocationType(undefined)}
onSuccess={onSuccessCallback}
/>
)}
{bulkUpdateModal && (
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
)}
</>
);
};
const IntegrationsContainer = ({
variant,
exclude,
@@ -329,7 +100,8 @@ const IntegrationsContainer = ({
</Button>
}
/>
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
</div>
);
}}
+14 -8
View File
@@ -60,10 +60,16 @@ export default function Verify() {
const routerQuery = useRouterQuery();
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
const [secondsLeft, setSecondsLeft] = useState(30);
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({
stripeCustomerId,
checkoutSessionId: sessionId,
});
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
{
stripeCustomerId,
checkoutSessionId: sessionId,
},
{
enabled: !!stripeCustomerId || !!sessionId,
staleTime: Infinity,
}
);
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
// to avoid refresh to skip waiting 30 seconds to re-send email
@@ -123,12 +129,12 @@ export default function Verify() {
{hasPaymentFailed ? (
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
) : sessionId ? (
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight dark:text-white" />
) : (
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
)}
</div>
<h3 className="font-cal my-6 text-3xl font-normal">
<h3 className="font-cal text-inverted my-6 text-3xl font-normal dark:text-white">
{hasPaymentFailed
? "Your payment failed"
: sessionId
@@ -138,7 +144,7 @@ export default function Verify() {
{hasPaymentFailed && (
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
)}
<p>
<p className="text-inverted dark:text-white">
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
{hasPaymentFailed &&
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
@@ -158,7 +164,7 @@ export default function Verify() {
e.preventDefault();
setSecondsLeft(30);
// Update query params with t:timestamp, shallow: true doesn't re-render the page
const _searchParams = new URLSearchParams(searchParams);
const _searchParams = new URLSearchParams(searchParams.toString());
_searchParams.set("t", `${Date.now()}`);
router.replace(`${pathname}?${_searchParams.toString()}`);
return await sendVerificationLogin(customer.email, customer.username);
+15 -10
View File
@@ -23,6 +23,7 @@ import {
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
@@ -93,7 +94,7 @@ const querySchema = z.object({
});
export default function Success(props: SuccessProps) {
const { t, i18n } = useLocale();
const { t } = useLocale();
const router = useRouter();
const routerQuery = useRouterQuery();
const pathname = usePathname();
@@ -132,7 +133,9 @@ export default function Success(props: SuccessProps) {
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
const [is24h, setIs24h] = useState(isBrowserLocale24h());
const [is24h, setIs24h] = useState(
props?.userTimeFormat ? props.userTimeFormat === 24 : isBrowserLocale24h()
);
const { data: session } = useSession();
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
@@ -214,8 +217,10 @@ export default function Success(props: SuccessProps) {
confirmed: !needsConfirmation,
// TODO: Add payment details
});
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
setIs24h(!!getIs24hClockFromLocalStorage());
setDate(
date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London")
);
setIs24h(props?.userTimeFormat ? props.userTimeFormat === 24 : !!getIs24hClockFromLocalStorage());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventType, needsConfirmation]);
@@ -353,10 +358,10 @@ export default function Success(props: SuccessProps) {
<img src={giphyImage} alt="Gif from Giphy" />
)}
{!giphyImage && !needsConfirmation && !isCancelled && (
<Check className="h-5 w-5 text-green-600" />
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
)}
{needsConfirmation && !isCancelled && <Calendar className="text-emphasis h-5 w-5" />}
{isCancelled && <X className="h-5 w-5 text-red-600" />}
{isCancelled && <X className="h-5 w-5 text-red-600 dark:text-red-200" />}
</div>
<div className="mb-8 mt-6 text-center last:mb-0">
<h3
@@ -486,10 +491,7 @@ export default function Success(props: SuccessProps) {
: t("payment")}
</div>
<div className="col-span-2 mb-2 mt-3">
{new Intl.NumberFormat(i18n.language, {
style: "currency",
currency: props.paymentStatus.currency,
}).format(props.paymentStatus.amount / 100.0)}
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
</div>
</>
)}
@@ -1006,10 +1008,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const session = await getServerSession(context);
let tz: string | null = null;
let userTimeFormat: number | null = null;
if (session) {
const user = await ssr.viewer.me.fetch();
tz = user.timeZone;
userTimeFormat = user.timeFormat;
}
const parsedQuery = querySchema.safeParse(context.query);
@@ -1150,6 +1154,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingInfo,
paymentStatus: payment,
...(tz && { tz }),
userTimeFormat,
},
};
}
+2 -1
View File
@@ -449,7 +449,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
availability={availability}
isUpdateMutationLoading={updateMutation.isLoading}
formMethods={formMethods}
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
disableBorder={true}
currentUserMembership={currentUserMembership}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
<Form
+10 -6
View File
@@ -127,7 +127,7 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
readOnly={events[0].metadata.readOnly}
/>
) : (
<CreateFirstEventTypeView />
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
)}
</div>
);
@@ -363,7 +363,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
}, []);
if (!types.length) {
return group.teamId ? <EmptyEventTypeList group={group} /> : <CreateFirstEventTypeView />;
return group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
);
}
const firstItem = types[0];
@@ -764,7 +768,7 @@ const EventTypeListHeading = ({
);
};
const CreateFirstEventTypeView = () => {
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
const { t } = useLocale();
return (
@@ -774,7 +778,7 @@ const CreateFirstEventTypeView = () => {
description={t("new_event_type_description")}
className="mb-16"
buttonRaw={
<Button href="?dialog=new" variant="button">
<Button href={`?dialog=new&eventPage=${slug}`} variant="button">
{t("create")}
</Button>
}
@@ -915,7 +919,7 @@ const Main = ({
) : group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView />
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
)}
</div>
))
@@ -931,7 +935,7 @@ const Main = ({
/>
)
)}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
<EventTypeEmbedDialog />
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
</>
-17
View File
@@ -20,19 +20,6 @@ import { RefreshCcw, UserPlus, Users } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
const Heading = () => {
const { t } = useLocale();
return (
<div className="min-w-52 hidden md:block">
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 text-emphasis truncate text-xl font-semibold tracking-wide xl:max-w-full">
{t("insights")}
</h3>
<p className="text-default mt-2 hidden text-sm md:block">{t("insights_subtitle")}</p>
</div>
);
};
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
@@ -79,10 +66,6 @@ export default function InsightsPage() {
<></>
) : (
<FiltersProvider>
<div className="ml-auto mt-0">
<Heading />
</div>
<Filters />
<div className="mb-4 space-y-6">
@@ -1,11 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "@components/auth/layouts/AdminLayout";
import KYCVerificationView from "./kycVerificationView";
const KYCVerificationPage = () => <KYCVerificationView />;
KYCVerificationPage.getLayout = getLayout;
KYCVerificationPage.PageWrapper = PageWrapper;
export default KYCVerificationPage;
@@ -1,80 +0,0 @@
import { useForm } from "react-hook-form";
import { trpc } from "@calcom/trpc";
import { Meta, Form, Button, TextField, showToast } from "@calcom/ui";
type FormValues = {
name?: string | null;
};
export default function KYCVerificationView() {
const teamForm = useForm<FormValues>();
const userForm = useForm<FormValues>();
const mutation = trpc.viewer.kycVerification.verify.useMutation({
onSuccess: async (data) => {
showToast(`Successfully verified ${data.isTeam ? "team" : "user"} ${data.name}`, "success");
},
onError: (error) => {
showToast(`Verification failed: ${error.message}`, "error");
},
});
return (
<div>
<Meta
title="KYC Verification"
description="Here you can verify users and teams. This verification is needed for sending sms/whatsapp messages to attendees."
/>
<div>
<div className="mb-2 font-medium">Verify Team</div>
<Form
form={teamForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name || "",
isTeam: true,
});
}}>
<div className="flex space-x-2">
<TextField
{...teamForm.register("name")}
label=""
type="text"
id="name"
placeholder="team slug"
className="-mt-2 "
required
/>
<Button type="submit">Verify</Button>
</div>
</Form>
</div>
<div>
<div className="mb-2 mt-6 font-medium">Verify User</div>
<Form
form={userForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name || "",
isTeam: false,
});
}}>
<div className="flex space-x-2">
<TextField
{...userForm.register("name")}
label=""
type="text"
id="name"
placeholder="user name"
className="-mt-2"
required
/>
<Button type="submit">Verify</Button>
</div>
</Form>
</div>
</div>
);
}
+5 -4
View File
@@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
<>
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
<div>
<h2 className="font-medium">{title}</h2>
<h2 className="text-base font-semibold">{title}</h2>
<p>{description}</p>
</div>
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
</section>
<hr className="border-subtle" />
</>
);
};
@@ -45,14 +44,16 @@ const BillingView = () => {
return (
<>
<Meta title={t("billing")} description={t("manage_billing_description")} />
<div className="space-y-6 text-sm sm:space-y-8">
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
{t("billing_portal")}
</Button>
</CtaRow>
<hr className="border-subtle" />
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
<Button color="secondary" onClick={onContactSupportClick}>
{t("contact_support")}
+53 -32
View File
@@ -14,12 +14,25 @@ import {
DialogContent,
EmptyScreen,
Meta,
AppSkeletonLoader as SkeletonLoader,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
const ApiKeysView = () => {
const { t } = useLocale();
@@ -39,49 +52,57 @@ const ApiKeysView = () => {
setApiKeyToEdit(undefined);
setApiKeyModal(true);
}}>
{t("new_api_key")}
{t("add")}
</Button>
);
};
if (isLoading || !data) {
return (
<SkeletonLoader
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
/>
);
}
return (
<>
<Meta
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
CTA={<NewApiKeyButton />}
borderInShellHeader={true}
/>
<LicenseRequired>
<>
{isLoading && <SkeletonLoader />}
<div>
{isLoading ? null : data?.length ? (
<>
<div className="border-subtle mb-8 mt-6 rounded-md border">
{data.map((apiKey, index) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
lastItem={data.length === index + 1}
onEditClick={() => {
setApiKeyToEdit(apiKey);
setApiKeyModal(true);
}}
/>
))}
</div>
<NewApiKeyButton />
</>
) : (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_first_api_key")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
buttonRaw={<NewApiKeyButton />}
/>
)}
</div>
</>
<div>
{data?.length ? (
<>
<div className="border-subtle rounded-b-md border border-t-0">
{data.map((apiKey, index) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
lastItem={data.length === index + 1}
onEditClick={() => {
setApiKeyToEdit(apiKey);
setApiKeyModal(true);
}}
/>
))}
</div>
</>
) : (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_first_api_key")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
className="rounded-b-md rounded-t-none border-t-0"
buttonRaw={<NewApiKeyButton />}
/>
)}
</div>
</LicenseRequired>
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
+250 -183
View File
@@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form";
import type { z } from "zod";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { APP_NAME } from "@calcom/lib/constants";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
@@ -12,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
Alert,
Button,
@@ -22,7 +25,7 @@ import {
SkeletonButton,
SkeletonContainer,
SkeletonText,
Switch,
SettingsToggle,
UpgradeTeamsBadge,
} from "@calcom/ui";
@@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 mt-6 space-y-6">
<div className="flex items-center">
<Meta title={title} description={description} borderInShellHeader={false} />
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
<div className="flex items-center justify-center">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
@@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
const AppearanceView = () => {
const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
const AppearanceView = ({
user,
hasPaidPlan,
}: {
user: RouterOutputs["viewer"]["me"];
hasPaidPlan: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [darkModeError, setDarkModeError] = useState(false);
const [lightModeError, setLightModeError] = useState(false);
const [isCustomBrandColorChecked, setIsCustomBranColorChecked] = useState(
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
);
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
const formMethods = useForm({
const userThemeFormMethods = useForm({
defaultValues: {
theme: user?.theme,
brandColor: user?.brandColor || "#292929",
darkBrandColor: user?.darkBrandColor || "#fafafa",
hideBranding: user?.hideBranding,
metadata: user?.metadata as z.infer<typeof userMetadata>,
theme: user.theme,
},
});
const selectedTheme = formMethods.watch("theme");
const {
formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty },
reset: resetUserThemeReset,
} = userThemeFormMethods;
const bookerLayoutFormMethods = useForm({
defaultValues: {
metadata: user.metadata as z.infer<typeof userMetadata>,
},
});
const {
formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty },
reset: resetBookerLayoutThemeReset,
} = bookerLayoutFormMethods;
const brandColorsFormMethods = useForm({
defaultValues: {
brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
},
});
const {
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
reset: resetBrandColorsThemeReset,
} = brandColorsFormMethods;
const selectedTheme = userThemeFormMethods.watch("theme");
const selectedThemeIsDark =
selectedTheme === "dark" ||
(selectedTheme === "" &&
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark"));
const {
formState: { isSubmitting, isDirty },
reset,
} = formMethods;
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (data) => {
await utils.viewer.me.invalidate();
showToast(t("settings_updated_successfully"), "success");
reset(data);
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
resetBookerLayoutThemeReset({ metadata: data.metadata });
resetUserThemeReset({ theme: data.theme });
},
onError: (error) => {
if (error.message) {
@@ -97,136 +134,180 @@ const AppearanceView = () => {
},
});
if (isLoading || isTeamPlanStatusLoading)
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
if (!user) return null;
const isDisabled = isSubmitting || !isDirty;
return (
<Form
form={formMethods}
handleSubmit={(values) => {
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
mutation.mutate({
...values,
// Radio values don't support null as values, therefore we convert an empty string
// back to null here.
theme: values.theme || null,
});
}}>
<Meta title={t("appearance")} description={t("appearance_description")} />
<div className="mb-6 flex items-center text-sm">
<div>
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<div>
<p className="text-default font-semibold">{t("theme")}</p>
<p className="text-default text-base font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
</div>
</div>
<div className="flex flex-col justify-between sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={user.theme === null}
register={formMethods.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.theme === "light"}
register={formMethods.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.theme === "dark"}
register={formMethods.register}
/>
</div>
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
<BookerLayoutSelector
isDark={selectedThemeIsDark}
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
/>
<hr className="border-subtle my-8 border" />
<div className="mb-6 flex items-center text-sm">
<div>
<p className="text-default font-semibold">{t("custom_brand_colors")}</p>
<p className="text-default mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
<Form
form={userThemeFormMethods}
handleSubmit={(values) => {
mutation.mutate({
// Radio values don't support null as values, therefore we convert an empty string
// back to null here.
theme: values.theme || null,
});
}}>
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={user.theme === null}
register={userThemeFormMethods.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.theme === "light"}
register={userThemeFormMethods.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.theme === "dark"}
register={userThemeFormMethods.register}
/>
</div>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
disabled={isUserThemeSubmitting || !isUserThemeDirty}
type="submit"
data-testid="update-theme-btn"
color="primary">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
<div className="block justify-between sm:flex">
<Controller
name="brandColor"
control={formMethods.control}
defaultValue={user.brandColor}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
<Form
form={bookerLayoutFormMethods}
handleSubmit={(values) => {
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
showToast(t(layoutError), "error");
return;
} else {
mutation.mutate(values);
}
}}>
<BookerLayoutSelector
isDark={selectedThemeIsDark}
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
/>
</Form>
<Form
form={brandColorsFormMethods}
handleSubmit={(values) => {
mutation.mutate(values);
}}>
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("custom_brand_colors")}
description={t("customize_your_brand_colors")}
checked={isCustomBrandColorChecked}
onCheckedChange={(checked) => {
setIsCustomBranColorChecked(checked);
if (!checked) {
mutation.mutate({
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
});
}
}}
childrenClassName="lg:ml-0"
switchContainerClassName={classNames(
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
isCustomBrandColorChecked && "rounded-b-none"
)}>
<div className="border-subtle flex flex-col gap-6 border-x p-6">
<Controller
name="brandColor"
control={brandColorsFormMethods.control}
defaultValue={user.brandColor}
resetDefaultValue="#292929"
onChange={(value) => {
if (!checkWCAGContrastColor("#ffffff", value)) {
setLightModeError(true);
} else {
setLightModeError(false);
}
formMethods.setValue("brandColor", value, { shouldDirty: true });
}}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={user.brandColor}
resetDefaultValue="#292929"
onChange={(value) => {
try {
checkWCAGContrastColor("#ffffff", value);
setLightModeError(false);
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
} catch (err) {
setLightModeError(false);
}
}}
/>
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
</div>
)}
/>
<Controller
name="darkBrandColor"
control={formMethods.control}
defaultValue={user.darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
<Controller
name="darkBrandColor"
control={brandColorsFormMethods.control}
defaultValue={user.darkBrandColor}
resetDefaultValue="#fafafa"
onChange={(value) => {
if (!checkWCAGContrastColor("#101010", value)) {
setDarkModeError(true);
} else {
setDarkModeError(false);
}
formMethods.setValue("darkBrandColor", value, { shouldDirty: true });
}}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={user.darkBrandColor}
resetDefaultValue="#fafafa"
onChange={(value) => {
try {
checkWCAGContrastColor("#101010", value);
setDarkModeError(false);
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
} catch (err) {
setDarkModeError(true);
}
}}
/>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
</div>
)}
/>
</div>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
<SectionBottomActions align="end">
<Button
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
color="primary"
type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</SettingsToggle>
</div>
) : null}
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</Form>
{/* TODO future PR to preview brandColors */}
{/* <Button
color="secondary"
@@ -235,51 +316,37 @@ const AppearanceView = () => {
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
Preview
</Button> */}
<hr className="border-subtle my-8 border" />
<Controller
name="hideBranding"
control={formMethods.control}
defaultValue={user.hideBranding}
render={({ field: { value } }) => (
<>
<div className="flex w-full text-sm">
<div className="mr-1 flex-grow">
<div className="flex items-center">
<p className="text-default font-semibold ltr:mr-2 rtl:ml-2">
{t("disable_cal_branding", { appName: APP_NAME })}
</p>
<UpgradeTeamsBadge />
</div>
<p className="text-default mt-0.5">{t("removes_cal_branding", { appName: APP_NAME })}</p>
</div>
<div className="flex-none">
<Switch
id="hideBranding"
disabled={!hasPaidPlan}
onCheckedChange={(checked) =>
formMethods.setValue("hideBranding", checked, { shouldDirty: true })
}
checked={hasPaidPlan ? value : false}
/>
</div>
</div>
</>
)}
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("disable_cal_branding", { appName: APP_NAME })}
disabled={!hasPaidPlan || mutation?.isLoading}
description={t("removes_cal_branding", { appName: APP_NAME })}
checked={hasPaidPlan ? hideBrandingValue : false}
Badge={<UpgradeTeamsBadge />}
onCheckedChange={(checked) => {
setHideBrandingValue(checked);
mutation.mutate({ hideBranding: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<Button
disabled={isDisabled}
type="submit"
loading={mutation.isLoading}
color="primary"
className="mt-8"
data-testid="update-theme-btn">
{t("update")}
</Button>
</Form>
</div>
);
};
AppearanceView.getLayout = getLayout;
AppearanceView.PageWrapper = PageWrapper;
const AppearanceViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
export default AppearanceView;
const { t } = useLocale();
if (isLoading || isTeamPlanStatusLoading || !user)
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
return <AppearanceView user={user} hasPaidPlan={hasPaidPlan} />;
};
AppearanceViewWrapper.getLayout = getLayout;
AppearanceViewWrapper.PageWrapper = PageWrapper;
export default AppearanceViewWrapper;
@@ -1,11 +1,12 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Fragment } from "react";
import { Fragment, useState, useEffect } from "react";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="mb-8 mt-6 space-y-6">
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
@@ -65,6 +66,21 @@ const CalendarsView = () => {
const utils = trpc.useContext();
const query = trpc.viewer.connectedCalendars.useQuery();
const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{
integration: string;
externalId: string;
} | null>(null);
useEffect(() => {
if (query?.data?.destinationCalendar) {
setSelectedDestinationCalendar({
integration: query.data.destinationCalendar.integration,
externalId: query.data.destinationCalendar.externalId,
});
}
}, [query?.isLoading, query?.data?.destinationCalendar]);
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
async onSettled() {
await utils.viewer.connectedCalendars.invalidate();
@@ -79,43 +95,58 @@ const CalendarsView = () => {
return (
<>
<Meta title={t("calendars")} description={t("calendars_description")} CTA={<AddCalendarButton />} />
<Meta
title={t("calendars")}
description={t("calendars_description")}
CTA={<AddCalendarButton />}
borderInShellHeader={false}
/>
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
const isDestinationUpdateBtnDisabled =
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
return data.connectedCalendars.length ? (
<div>
<div className="bg-muted border-subtle mt-4 flex space-x-4 rounded-md p-2 sm:mx-0 sm:p-10 md:border md:p-6 xl:mt-0">
<div className=" bg-default border-subtle flex h-9 w-9 items-center justify-center rounded-md border-2 p-[6px]">
<Calendar className="text-default h-6 w-6" />
</div>
<div className="flex w-full flex-col space-y-3">
<div>
<h4 className=" text-emphasis pb-2 text-base font-semibold leading-5">
{t("add_to_calendar")}
</h4>
<p className=" text-default text-sm leading-5">
<Trans i18nKey="add_to_calendar_description">
Where to add events when you re booked. You can override this on a per-event basis in
advanced settings in the event type.
</Trans>
</p>
</div>
<DestinationCalendarSelector
hidePlaceholder
value={data.destinationCalendar?.externalId}
onChange={mutation.mutate}
isLoading={mutation.isLoading}
/>
</div>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
{t("add_to_calendar")}
</h2>
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
</div>
<h4 className="text-emphasis mt-12 text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
<List className="flex flex-col gap-6" noBorderTreatment>
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
<DestinationCalendarSelector
hidePlaceholder
value={selectedDestinationCalendarOption?.externalId}
onChange={(option) => {
setSelectedDestinationCalendar(option);
}}
isLoading={mutation.isLoading}
/>
</div>
<SectionBottomActions align="end">
<Button
loading={mutation.isLoading}
disabled={isDestinationUpdateBtnDisabled}
color="primary"
onClick={() => {
if (selectedDestinationCalendarOption) mutation.mutate(selectedDestinationCalendarOption);
}}>
{t("update")}
</Button>
</SectionBottomActions>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<h4 className="text-emphasis text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
</div>
<List
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
noBorderTreatment>
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && item.error.message && (
@@ -207,6 +238,7 @@ const CalendarsView = () => {
description={t("no_calendar_installed_description")}
buttonText={t("add_a_calendar")}
buttonOnClick={() => router.push("/apps/categories/calendar")}
className="mt-6"
/>
);
}}
@@ -1,39 +1,22 @@
import { useCallback, useState } from "react";
import { useReducer } from "react";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuTrigger,
List,
Meta,
showToast,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { AlertCircle, MoreHorizontal, Trash, Video, Plus } from "@calcom/ui/components/icon";
import { Button, EmptyScreen, Meta, SkeletonContainer, SkeletonText } from "@calcom/ui";
import { Calendar, Plus } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import AppListCard from "@components/AppListCard";
import PageWrapper from "@components/PageWrapper";
import { AppList } from "@components/apps/AppList";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="divide-subtle mb-8 mt-6 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
@@ -45,162 +28,93 @@ const AddConferencingButton = () => {
const { t } = useLocale();
return (
<>
<Button color="secondary" StartIcon={Plus} href="/apps/categories/video">
{t("add_conferencing_app")}
</Button>
</>
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
{t("add")}
</Button>
);
};
type ModalState = {
isOpen: boolean;
credentialId: null | number;
};
const ConferencingLayout = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: defaultConferencingApp, isLoading: defaultConferencingAppLoading } =
trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const [modal, updateModal] = useReducer(
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
{
isOpen: false,
credentialId: null,
}
);
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery({
const query = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: true,
});
const deleteAppMutation = trpc.viewer.deleteCredential.useMutation({
onSuccess: () => {
showToast("Integration deleted successfully", "success");
utils.viewer.integrations.invalidate({ variant: "conferencing", onlyInstalled: true });
setDeleteAppModal(false);
},
onError: () => {
showToast("Error deleting app", "error");
setDeleteAppModal(false);
},
});
const onSuccessCallback = useCallback(() => {
setBulkUpdateModal(true);
showToast("Default app updated successfully", "success");
}, []);
const handleModelClose = () => {
updateModal({ isOpen: false, credentialId: null });
};
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: async () => {
await utils.viewer.getUsersDefaultConferencingApp.invalidate();
onSuccessCallback();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
});
const [deleteAppModal, setDeleteAppModal] = useState(false);
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const [deleteCredentialId, setDeleteCredentialId] = useState<number>(0);
if (isLoading || defaultConferencingAppLoading)
return <SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />;
const handleDisconnect = (credentialId: number) => {
updateModal({ isOpen: true, credentialId });
};
return (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
/>
<List>
{apps?.items &&
apps.items
.map((app) => ({ ...app, title: app.title || app.name }))
.map((app) => {
const appSlug = app?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug); // Default to cal video if the user doesnt have it set (we do this on new account creation but not old)
<>
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
borderInShellHeader={true}
/>
<QueryCell
query={query}
customLoader={
<SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />
}
success={({ data }) => {
console.log(data);
if (!data.items.length) {
return (
<AppListCard
description={app.description}
title={app.title}
logo={app.logo}
key={app.title}
isDefault={appIsDefault} // @TODO: Handle when a user doesnt have this value set
actions={
<div>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(
app?.locationOption?.value ?? ""
);
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
disabled={app.isGlobal}
StartIcon={Trash}
onClick={() => {
setDeleteCredentialId(app.userCredentialIds[0]);
setDeleteAppModal(true);
}}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
<EmptyScreen
Icon={Calendar}
headline={t("no_category_apps", {
category: t("conferencing").toLowerCase(),
})}
description={t("no_category_apps_description_conferencing")}
buttonRaw={
<Button
color="secondary"
data-testid="connect-conferencing-apps"
href="/apps/categories/conferencing">
{t("connect_conference_apps")}
</Button>
}
/>
);
})}
</List>
<Dialog open={deleteAppModal} onOpenChange={setDeleteAppModal}>
<DialogContent
title={t("Remove app")}
description={t("are_you_sure_you_want_to_remove_this_app")}
type="confirmation"
Icon={AlertCircle}>
<DialogFooter>
<Button color="primary" onClick={() => deleteAppMutation.mutate({ id: deleteCredentialId })}>
{t("yes_remove_app")}
</Button>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
{locationType && (
<AppSetDefaultLinkDialog
locationType={locationType}
setLocationType={setLocationType}
onSuccess={onSuccessCallback}
}
return (
<AppList
listClassName="rounded-xl rounded-t-none border-t-0"
handleDisconnect={handleDisconnect}
data={data}
variant="conferencing"
/>
);
}}
/>
)}
{bulkUpdateModal && (
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
)}
</div>
</div>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
isOpen={modal.isOpen}
credentialId={modal.credentialId}
/>
</>
);
};
+149 -131
View File
@@ -1,6 +1,8 @@
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localeOptions } from "@calcom/lib/i18n";
@@ -13,12 +15,12 @@ import {
Label,
Meta,
Select,
SettingsToggle,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
TimezoneSelect,
SettingsToggle,
} from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 mt-6 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
@@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
const utils = trpc.useContext();
const { t } = useLocale();
const { update } = useSession();
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(false);
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
@@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
},
onSettled: async () => {
await utils.viewer.me.invalidate();
setIsUpdateBtnLoading(false);
},
});
@@ -105,8 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
value: user.weekStart,
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
},
allowDynamicBooking: user.allowDynamicBooking ?? true,
allowSEOIndexing: user.allowSEOIndexing ?? true,
},
});
const {
@@ -116,134 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState(
!!user.allowDynamicBooking
);
const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing);
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
!!user.receiveMonthlyDigestEmail
);
return (
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta title={t("general")} description={t("general_description")} />
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="mt-8">
<Controller
name="allowDynamicBooking"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
checked={formMethods.getValues("allowDynamicBooking")}
onCheckedChange={(checked) => {
formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<div>
<Form
form={formMethods}
handleSubmit={(values) => {
setIsUpdateBtnLoading(true);
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta title={t("general")} description={t("general_description")} borderInShellHeader={true} />
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
</div>
<div className="mt-8">
<Controller
name="allowSEOIndexing"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
checked={formMethods.getValues("allowSEOIndexing")}
onCheckedChange={(checked) => {
formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<SectionBottomActions align="end">
<Button loading={isUpdateBtnLoading} disabled={isDisabled} color="primary" type="submit">
<>{t("update")}</>
</Button>
</SectionBottomActions>
</Form>
<Button
loading={mutation.isLoading}
disabled={isDisabled}
color="primary"
type="submit"
className="mt-8">
<>{t("update")}</>
</Button>
</Form>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
disabled={mutation.isLoading}
checked={isAllowDynamicBookingChecked}
onCheckedChange={(checked) => {
setIsAllowDynamicBookingChecked(checked);
mutation.mutate({ allowDynamicBooking: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
disabled={mutation.isLoading}
checked={isAllowSEOIndexingChecked}
onCheckedChange={(checked) => {
setIsAllowSEOIndexingChecked(checked);
mutation.mutate({ allowSEOIndexing: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
disabled={mutation.isLoading}
checked={isReceiveMonthlyDigestEmailChecked}
onCheckedChange={(checked) => {
setIsReceiveMonthlyDigestEmailChecked(checked);
mutation.mutate({ receiveMonthlyDigestEmail: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
</div>
);
};
+114 -68
View File
@@ -7,8 +7,10 @@ import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
@@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
@@ -69,18 +71,30 @@ interface DeleteAccountValues {
type FormValues = {
username: string;
avatar: string;
avatar: string | null;
name: string;
email: string;
bio: string;
};
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
};
const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { update } = useSession();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>();
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
onSuccess: (userData) => {
fetch(userData.avatar).then((res) => {
if (res.url) setFetchedImgSrc(res.url);
});
},
});
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
@@ -204,7 +218,7 @@ const ProfileView = () => {
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
if (isLoading || !user)
if (isLoading || !user || !fetchedImgSrc)
return (
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
);
@@ -219,11 +233,17 @@ const ProfileView = () => {
return (
<>
<Meta title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
<Meta
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}
/>
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading}
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
userAvatar={user.avatar}
userOrganization={user.organization}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProvider) {
@@ -238,7 +258,7 @@ const ProfileView = () => {
}
}}
extraField={
<div className="mt-8">
<div className="mt-6">
<UsernameAvailabilityField
onSuccessMutation={async () => {
showToast(t("settings_updated_successfully"), "success");
@@ -252,16 +272,19 @@ const ProfileView = () => {
}
/>
<hr className="border-subtle my-6" />
<Label>{t("danger_zone")}</Label>
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
</div>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
</SectionBottomActions>
<DialogContent
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
@@ -269,9 +292,7 @@ const ProfileView = () => {
Icon={AlertTriangle}>
<>
<div className="mb-10">
<p className="text-default mb-4">
{t("delete_account_confirmation_message", { appName: APP_NAME })}
</p>
<p className="text-default mb-4">{t("delete_account_confirmation_message")}</p>
{isCALIdentityProvider && (
<PasswordField
data-testid="password"
@@ -366,12 +387,16 @@ const ProfileForm = ({
onSubmit,
extraField,
isLoading = false,
isFallbackImg,
userAvatar,
userOrganization,
}: {
defaultValues: FormValues;
onSubmit: (values: FormValues) => void;
extraField?: React.ReactNode;
isLoading: boolean;
isFallbackImg: boolean;
userAvatar: string;
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => {
const { t } = useLocale();
@@ -379,7 +404,7 @@ const ProfileForm = ({
const profileFormSchema = z.object({
username: z.string(),
avatar: z.string(),
avatar: z.string().nullable(),
name: z
.string()
.trim()
@@ -404,56 +429,77 @@ const ProfileForm = ({
return (
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
/>
</div>
</>
)}
/>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
return (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
<div className="flex gap-2">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
/>
{showRemoveAvatarButton && (
<Button
color="secondary"
onClick={() => {
formMethods.setValue("avatar", null, { shouldDirty: true });
}}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>
</div>
{extraField}
<div className="mt-6">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-6">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-6">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
</div>
{extraField}
<div className="mt-8">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-8">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<Button loading={isLoading} disabled={isDisabled} color="primary" className="mt-8" type="submit">
{t("update")}
</Button>
<SectionBottomActions align="end">
<Button loading={isLoading} disabled={isDisabled} color="primary" type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
);
};
@@ -1,23 +1,34 @@
import type { GetServerSidePropsContext } from "next";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Form, Label, Meta, showToast, Skeleton, Switch } from "@calcom/ui";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Meta, showToast, SettingsToggle, SkeletonContainer, SkeletonText } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
const ProfileImpersonationView = () => {
const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"] }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user } = trpc.viewer.me.useQuery();
const [disableImpersonation, setDisableImpersonation] = useState<boolean | undefined>(
user?.disableImpersonation
);
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => {
showToast(t("profile_updated_successfully"), "success");
reset(getValues());
},
onSettled: () => {
utils.viewer.me.invalidate();
@@ -26,83 +37,54 @@ const ProfileImpersonationView = () => {
await utils.viewer.me.cancel();
const previousValue = utils.viewer.me.getData();
if (previousValue && disableImpersonation) {
utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation });
}
setDisableImpersonation(disableImpersonation);
return { previousValue };
},
onError: (error, variables, context) => {
if (context?.previousValue) {
utils.viewer.me.setData(undefined, context.previousValue);
setDisableImpersonation(context.previousValue?.disableImpersonation);
}
showToast(`${t("error")}, ${error.message}`, "error");
},
});
const formMethods = useForm<{ disableImpersonation: boolean }>({
defaultValues: {
disableImpersonation: user?.disableImpersonation,
},
});
const {
formState: { isSubmitting, isDirty },
setValue,
reset,
getValues,
watch,
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
return (
<>
<Meta title={t("impersonation")} description={t("impersonation_description")} />
<Form
form={formMethods}
handleSubmit={({ disableImpersonation }) => {
mutation.mutate({ disableImpersonation });
}}>
<div className="flex space-x-3">
<Switch
onCheckedChange={(e) => {
setValue("disableImpersonation", !e, { shouldDirty: true });
}}
fitToHeight={true}
checked={!watch("disableImpersonation")}
/>
<div className="flex flex-col">
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
{t("user_impersonation_heading")}
</Skeleton>
<Skeleton as="p" className="text-default -mt-2 text-sm leading-normal">
{t("user_impersonation_description")}
</Skeleton>
</div>
</div>
<Button
color="primary"
loading={mutation.isLoading}
className="mt-8"
type="submit"
disabled={isDisabled}>
{t("update")}
</Button>
</Form>
<Meta
title={t("impersonation")}
description={t("impersonation_description")}
borderInShellHeader={true}
/>
<div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("user_impersonation_heading")}
description={t("user_impersonation_description")}
checked={!disableImpersonation}
onCheckedChange={(checked) => {
mutation.mutate({ disableImpersonation: !checked });
}}
disabled={mutation.isLoading}
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
/>
</div>
</>
);
};
ProfileImpersonationView.getLayout = getLayout;
ProfileImpersonationView.PageWrapper = PageWrapper;
const ProfileImpersonationViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { t } = useLocale();
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
return {
props: {
trpcState: ssr.dehydrate(),
},
};
if (isLoading || !user)
return <SkeletonLoader title={t("impersonation")} description={t("impersonation_description")} />;
return <ProfileImpersonationView user={user} />;
};
export default ProfileImpersonationView;
ProfileImpersonationViewWrapper.getLayout = getLayout;
ProfileImpersonationViewWrapper.PageWrapper = PageWrapper;
export default ProfileImpersonationViewWrapper;
+174 -84
View File
@@ -1,13 +1,29 @@
import { signOut, useSession } from "next-auth/react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { IdentityProvider } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui";
import {
Alert,
Button,
Form,
Meta,
PasswordField,
Select,
SettingsToggle,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = {
apiError: string;
};
const PasswordView = () => {
interface PasswordViewProps {
user: RouterOutputs["viewer"]["me"];
}
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="ml-auto h-8 w-20 rounded-md" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
const PasswordView = ({ user }: PasswordViewProps) => {
const { data } = useSession();
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user } = trpc.viewer.me.useQuery();
const metadata = userMetadata.safeParse(user?.metadata);
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
const metadata = userMetadataSchema.safeParse(user?.metadata);
const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
const [sessionTimeout, setSessionTimeout] = useState<number | undefined>(initialSessionTimeout);
const sessionMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => {
onSuccess: (data) => {
showToast(t("session_timeout_changed"), "success");
formMethods.reset(formMethods.getValues());
setSessionTimeout(data.metadata?.sessionTimeout);
},
onSettled: () => {
utils.viewer.me.invalidate();
},
onMutate: async () => {
await utils.viewer.me.cancel();
const previousValue = utils.viewer.me.getData();
const previousMetadata = userMetadata.parse(previousValue?.metadata);
const previousValue = await utils.viewer.me.getData();
const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata);
if (previousValue && sessionTimeout) {
if (previousValue && sessionTimeout && previousMetadata.success) {
utils.viewer.me.setData(undefined, {
...previousValue,
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout },
});
return { previousValue };
}
return { previousValue };
},
onError: (error, _, context) => {
if (context?.previousValue) {
@@ -84,20 +124,30 @@ const PasswordView = () => {
defaultValues: {
oldPassword: "",
newPassword: "",
sessionTimeout,
},
});
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
const { oldPassword, newPassword } = values;
if (!oldPassword.length) {
formMethods.setError(
"oldPassword",
{ type: "required", message: t("error_required_field") },
{ shouldFocus: true }
);
}
if (!newPassword.length) {
formMethods.setError(
"newPassword",
{ type: "required", message: t("error_required_field") },
{ shouldFocus: true }
);
}
if (oldPassword && newPassword) {
passwordMutation.mutate({ oldPassword, newPassword });
}
if (sessionTimeout !== newSessionTimeout) {
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
}
};
const timeoutOptions = [5, 10, 15].map((mins) => ({
@@ -112,7 +162,7 @@ const PasswordView = () => {
return (
<>
<Meta title={t("password")} description={t("password_description")} />
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
{user && user.identityProvider !== IdentityProvider.CAL ? (
<div>
<div className="mt-6">
@@ -130,87 +180,127 @@ const PasswordView = () => {
</div>
) : (
<Form form={formMethods} handleSubmit={handleSubmit}>
{formMethods.formState.errors.apiError && (
<div className="pb-6">
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
</div>
)}
<div className="max-w-[38rem] sm:grid sm:grid-cols-2 sm:gap-x-4">
<div>
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
</div>
<div>
<PasswordField
{...formMethods.register("newPassword", {
minLength: {
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
value: passwordMinLength,
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
<div className="border-x px-4 py-6 sm:px-6">
{formMethods.formState.errors.apiError && (
<div className="pb-6">
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
</div>
)}
<div className="w-full sm:grid sm:grid-cols-2 sm:gap-x-6">
<div>
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
</div>
<div>
<PasswordField
{...formMethods.register("newPassword", {
minLength: {
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
value: passwordMinLength,
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
</div>
</div>
<p className="text-default mt-4 w-full text-sm">
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
</p>
</div>
<p className="text-default mt-4 max-w-[38rem] text-sm">
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
</p>
<div className="border-subtle mt-8 border-t py-8">
<SectionBottomActions align="end">
<Button
color="primary"
type="submit"
loading={passwordMutation.isLoading}
onClick={() => formMethods.clearErrors("apiError")}
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
{t("update")}
</Button>
</SectionBottomActions>
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("session_timeout")}
description={t("session_timeout_description")}
checked={sessionTimeoutWatch !== undefined}
checked={sessionTimeout !== undefined}
data-testid="session-check"
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true });
setSessionTimeout(undefined);
if (metadata.success) {
sessionMutation.mutate({
metadata: { ...metadata.data, sessionTimeout: undefined },
});
}
} else {
formMethods.setValue("sessionTimeout", 10, { shouldDirty: true });
setSessionTimeout(10);
}
}}
/>
{sessionTimeoutWatch && (
<div className="mt-4 text-sm">
<div className="flex items-center">
<p className="text-default ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
<Select
options={timeoutOptions}
defaultValue={
sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1]
}
isSearchable={false}
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
onChange={(event) => {
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
}}
/>
childrenClassName="lg:ml-0"
switchContainerClassName={classNames(
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
!!sessionTimeout && "rounded-b-none"
)}>
<>
<div className="border-subtle border-x p-6 pb-8">
<div className="flex flex-col">
<p className="text-default mb-2 font-medium">{t("session_timeout_after")}</p>
<Select
options={timeoutOptions}
defaultValue={
sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1]
}
isSearchable={false}
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
onChange={(event) => {
setSessionTimeout(event?.value);
}}
/>
</div>
</div>
</div>
)}
<SectionBottomActions align="end">
<Button
color="primary"
loading={sessionMutation.isLoading}
onClick={() => {
sessionMutation.mutate({
metadata: { ...metadata, sessionTimeout },
});
formMethods.clearErrors("apiError");
}}
disabled={
initialSessionTimeout === sessionTimeout ||
passwordMutation.isLoading ||
sessionMutation.isLoading
}>
{t("update")}
</Button>
</SectionBottomActions>
</>
</SettingsToggle>
</div>
{/* TODO: Why is this Form not submitting? Hacky fix but works */}
<Button
color="primary"
className="mt-8"
type="submit"
loading={passwordMutation.isLoading || sessionMutation.isLoading}
onClick={() => formMethods.clearErrors("apiError")}
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
{t("update")}
</Button>
</Form>
)}
</>
);
};
PasswordView.getLayout = getLayout;
PasswordView.PageWrapper = PageWrapper;
const PasswordViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { t } = useLocale();
if (isLoading || !user)
return <SkeletonLoader title={t("password")} description={t("password_description")} />;
export default PasswordView;
return <PasswordView user={user} />;
};
PasswordViewWrapper.getLayout = getLayout;
PasswordViewWrapper.PageWrapper = PageWrapper;
export default PasswordViewWrapper;
@@ -3,15 +3,24 @@ import { useState } from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Meta, Switch, SkeletonButton, SkeletonContainer, SkeletonText, Alert } from "@calcom/ui";
import {
Badge,
Meta,
SkeletonButton,
SkeletonContainer,
SkeletonText,
Alert,
SettingsToggle,
} from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal";
import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal";
const SkeletonLoader = () => {
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="mb-8 mt-6 space-y-6">
<div className="flex items-center">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
@@ -28,36 +37,34 @@ const TwoFactorAuthView = () => {
const { t } = useLocale();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [enableModalOpen, setEnableModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [enableModalOpen, setEnableModalOpen] = useState<boolean>(false);
const [disableModalOpen, setDisableModalOpen] = useState<boolean>(false);
if (isLoading) return <SkeletonLoader />;
if (isLoading)
return <SkeletonLoader title={t("2fa")} description={t("set_up_two_factor_authentication")} />;
const isCalProvider = user?.identityProvider === "CAL";
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
return (
<>
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} />
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
{canSetupTwoFactor && <Alert severity="neutral" message={t("2fa_disabled")} />}
<div className="mt-6 flex items-start space-x-4">
<Switch
data-testid="two-factor-switch"
disabled={canSetupTwoFactor}
checked={user?.twoFactorEnabled}
onCheckedChange={() =>
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
}
/>
<div className="!mx-4">
<div className="flex">
<p className="text-default font-semibold">{t("two_factor_auth")}</p>
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
</Badge>
</div>
<p className="text-default text-sm">{t("add_an_extra_layer_of_security")}</p>
</div>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
data-testid="two-factor-switch"
title={t("two_factor_auth")}
description={t("add_an_extra_layer_of_security")}
checked={user?.twoFactorEnabled ?? false}
onCheckedChange={() =>
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
}
Badge={
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
</Badge>
}
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
/>
<EnableTwoFactorModal
open={enableModalOpen}
+67 -21
View File
@@ -34,7 +34,25 @@ type FormValues = z.infer<typeof signupSchema>;
type SignupProps = inferSSRProps<typeof getServerSideProps>;
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
return username;
};
function addOrUpdateQueryParam(url: string, key: string, value: string) {
const separator = url.includes("?") ? "&" : "?";
const param = `${key}=${encodeURIComponent(value)}`;
return `${url}${separator}${param}`;
}
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
const searchParams = useSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
@@ -56,6 +74,8 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
}
};
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
const signUp: SubmitHandler<FormValues> = async (data) => {
await fetch("/api/auth/signup", {
body: JSON.stringify({
@@ -72,13 +92,17 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
.then(async () => {
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
const callBackUrl = `${
searchParams?.get("callbackUrl")
? isOrgInviteByLink
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup")
: `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup`
}`;
await signIn<"credentials">("credentials", {
...data,
callbackUrl: `${
searchParams?.get("callbackUrl")
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: `${WEBAPP_URL}/${verifyOrGettingStarted}`
}?from=signup`,
callbackUrl: callBackUrl,
});
})
.catch((err) => {
@@ -118,21 +142,31 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
if (methods.formState?.errors?.apiError) {
methods.clearErrors("apiError");
}
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
methods.setValue(
"username",
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
);
}
methods.handleSubmit(signUp)(event);
}}
className="bg-default space-y-6">
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
{}
<div className="space-y-4">
<TextField
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
disabled={!!orgSlug}
required
/>
{!isOrgInviteByLink && (
<TextField
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
disabled={!!orgSlug}
required
/>
)}
<EmailField
{...register("email")}
disabled={prepopulateFormValues?.email}
@@ -213,6 +247,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
parent: {
select: {
slug: true,
metadata: true,
},
},
slug: true,
@@ -267,7 +302,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const isOrganization = tokenTeam.metadata?.isOrganization || tokenTeam?.parentId !== null;
// If we are dealing with an org, the slug may come from the team itself or its parent
const orgSlug = isOrganization
? tokenTeam.slug || tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug
? tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug || tokenTeam.slug
: null;
// Org context shouldn't check if a username is premium
@@ -278,15 +313,26 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
username = available ? username : suggestion || username;
}
const isValidEmail = checkValidEmail(verificationToken.identifier);
const isOrgInviteByLink = isOrganization && !isValidEmail;
const parentMetaDataForSubteam = tokenTeam?.parent?.metadata
? teamMetadataSchema.parse(tokenTeam.parent.metadata)
: null;
return {
props: {
...props,
token,
prepopulateFormValues: {
email: verificationToken.identifier,
username: slugify(username),
},
prepopulateFormValues: !isOrgInviteByLink
? {
email: verificationToken.identifier,
username: slugify(username),
}
: null,
orgSlug,
orgAutoAcceptEmail: isOrgInviteByLink
? tokenTeam?.metadata?.orgAutoAcceptEmail ?? parentMetaDataForSubteam?.orgAutoAcceptEmail ?? null
: null,
},
};
};
+88 -58
View File
@@ -8,12 +8,13 @@ import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
@@ -65,9 +66,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
// slug is a route parameter, we don't want to forward it to the next route
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery;
const EventTypes = () => (
const EventTypes = ({ eventTypes }: { eventTypes: NonNullable<(typeof team)["eventTypes"]> }) => (
<ul className="border-subtle rounded-md border">
{team.eventTypes.map((type, index) => (
{eventTypes.map((type, index) => (
<li
key={index}
className={classNames(
@@ -115,40 +116,45 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
const SubTeams = () =>
team.children.length ? (
<ul className="divide-subtle border-subtle bg-default !static w-full divide-y rounded-md border">
{team.children.map((ch, i) => (
<li key={i} className="hover:bg-muted w-full">
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
<div className="flex items-center px-5 py-5">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(ch?.logo, ch?.name as string)}
alt="Team Logo"
className="inline-flex justify-center"
/>
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{ch.name}</span>
<span className="text-subtle block text-xs">
{t("number_member", {
count: ch.members.filter((mem) => mem.user.username !== null).length,
})}
</span>
{team.children.map((ch, i) => {
const memberCount = team.members.filter(
(mem) => mem.subteams?.includes(ch.slug) && mem.accepted
).length;
return (
<li key={i} className="hover:bg-muted w-full">
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
<div className="flex items-center px-5 py-5">
<Avatar
size="md"
imageSrc={`/team/${ch.slug}/avatar.png`}
alt="Team Logo"
className="inline-flex justify-center"
/>
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{ch.name}</span>
<span className="text-subtle block text-xs">
{t("number_member", {
count: memberCount,
})}
</span>
</div>
</div>
</div>
<AvatarGroup
className="mr-6"
size="sm"
truncateAfter={4}
items={ch.members
.filter((mem) => mem.user.username !== null)
.map(({ user: member }) => ({
alt: member.name || "",
image: `/${member.username}/avatar.png`,
title: member.name || "",
}))}
/>
</Link>
</li>
))}
<AvatarGroup
className="mr-6"
size="sm"
truncateAfter={4}
items={team.members
.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)
.map((member) => ({
alt: member.name || "",
image: `/${member.username}/avatar.png`,
title: member.name || "",
}))}
/>
</Link>
</li>
);
})}
</ul>
) : (
<div className="space-y-6" data-testid="event-types">
@@ -168,7 +174,10 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
description={teamName}
meeting={{
title: markdownStrippedBio,
profile: { name: `${team.name}`, image: getPlaceholderAvatar(team.logo, team.name) },
profile: {
name: `${team.name}`,
image: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`,
},
}}
/>
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pb-12 pt-12">
@@ -176,7 +185,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<div className="relative">
<Avatar
alt={teamName}
imageSrc={getPlaceholderAvatar(team.parent ? team.parent.logo : team.logo, team.name)}
imageSrc={`${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${
team.slug
}/avatar.png`}
size="lg"
/>
</div>
@@ -197,17 +208,19 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<SubTeams />
) : (
<>
{(showMembers.isOn || !team.eventTypes.length) &&
{(showMembers.isOn || !team.eventTypes?.length) &&
(team.isPrivate ? (
<div className="w-full text-center">
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
<h2 data-testid="you-cannot-see-team-members" className="text-emphasis font-semibold">
{t("you_cannot_see_team_members")}
</h2>
</div>
) : (
<Team team={team} />
<Team members={team.members} teamName={team.name} />
))}
{!showMembers.isOn && team.eventTypes.length > 0 && (
{!showMembers.isOn && team.eventTypes && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes />
<EventTypes eventTypes={team.eventTypes} />
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
{!team.hideBookATeamMember && !team.isPrivate && (
@@ -227,6 +240,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<Button
color="minimal"
EndIcon={ArrowRight}
data-testid="book-a-team-member-btn"
className="dark:hover:bg-darkgray-200"
href={{
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
@@ -251,20 +265,25 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const flags = await getFeatureFlagMap(prisma);
const team = await getTeamWithMembers({ slug, orgSlug: currentOrgDomain });
const team = await getTeamWithMembers({
slug: slugify(slug ?? ""),
orgSlug: currentOrgDomain,
isTeamView: true,
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
});
const ssr = await ssrInit(context);
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
console.warn("gSSP, team/[slug] - ", {
console.info("gSSP, team/[slug] - ", {
isValidOrgDomain,
currentOrgDomain,
ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES,
flags: JSON.stringify,
flags: JSON.stringify(flags),
});
// Taking care of sub-teams and orgs
if (
@@ -300,20 +319,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} as const;
}
team.eventTypes = team.eventTypes.map((type) => ({
...type,
users: type.users.map((user) => ({
...user,
avatar: "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
}));
team.eventTypes =
team.eventTypes?.map((type) => ({
...type,
users: type.users.map((user) => ({
...user,
avatar: "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
})) ?? null;
const safeBio = markdownToSafeHTML(team.bio) || "";
const members = team.members.map((member) => {
return { ...member, safeBio: markdownToSafeHTML(member.bio || "") };
});
const members = !team.isPrivate
? team.members.map((member) => {
return {
name: member.name,
id: member.id,
bio: member.bio,
subteams: member.subteams,
username: member.username,
accepted: member.accepted,
safeBio: markdownToSafeHTML(member.bio || ""),
};
})
: [];
const markdownStrippedBio = stripMarkdown(team?.bio || "");
+15
View File
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { getLayout } from "@calcom/features/MainLayout";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { TeamsListing } from "@calcom/features/ee/teams/components";
import { ShellMain } from "@calcom/features/shell/Shell";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -41,6 +42,20 @@ function Teams() {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
const session = await getServerSession({ req: context.req, res: context.res });
const token = Array.isArray(context.query?.token) ? context.query.token[0] : context.query?.token;
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
if (!session) {
return {
redirect: {
destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login",
permanent: false,
},
props: {},
};
}
return { props: { trpcState: ssr.dehydrate() } };
};
+17
View File
@@ -252,6 +252,23 @@ test.describe("prefill", () => {
});
});
test("Persist the field values when going back and coming back to the booking form", async ({
page,
users,
}) => {
await page.goto("/pro/30min");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "John Doe");
await page.fill('[name="email"]', "john@example.com");
await page.fill('[name="notes"]', "Test notes");
await page.click('[data-testid="back"]');
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator('[name="name"]')).toHaveValue("John Doe");
await expect(page.locator('[name="email"]')).toHaveValue("john@example.com");
await expect(page.locator('[name="notes"]')).toHaveValue("Test notes");
});
test("logged out", async ({ page, users }) => {
await page.goto("/pro/30min");
+2 -3
View File
@@ -22,8 +22,7 @@ test.describe("Change username on settings", () => {
await users.deleteAll();
});
/** TODO: Find out why it's timing out */
test.fixme("User can change username", async ({ page, users, prisma }) => {
test("User can change username", async ({ page, users, prisma }) => {
const user = await users.create();
await user.apiLogin();
@@ -36,7 +35,7 @@ test.describe("Change username on settings", () => {
await page.click("[data-testid=update-username-btn]");
await Promise.all([
page.click("[data-testid=save-username]"),
page.waitForResponse("**/viewer.updateProfile*"),
page.getByTestId("toast-success").waitFor(),
]);
const newUpdatedUser = await prisma.user.findUniqueOrThrow({
+4 -2
View File
@@ -438,7 +438,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
_bookings?: PrismaType.BookingCreateInput[];
};
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name";
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
timeZone?: TimeZoneEnum;
eventTypes?: SupportedTestEventTypes[];
@@ -457,7 +457,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
return {
username: uname,
name: opts?.name,
email: `${uname}@example.com`,
email: opts?.email ?? `${uname}@example.com`,
password: hashPassword(uname),
emailVerified: new Date(),
completedOnboarding: opts?.completedOnboarding ?? true,
@@ -556,6 +556,8 @@ export async function apiLogin(
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
await page.locator("div > .ml-auto").first().click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("select-option-usd").click();
await page.getByPlaceholder("Price").fill("100");
await page.getByTestId("update-eventtype").click();
}
+61 -2
View File
@@ -1,8 +1,8 @@
import { expect } from "@playwright/test";
import type Prisma from "@prisma/client";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures";
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
@@ -143,7 +143,66 @@ test.describe("Stripe integration", () => {
expect(await paidBadge.innerText()).toBe("Paid");
});
test("Paid and confirmed booking should be able to be rescheduled", async ({ page, users }) => {
await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.click('[data-testid="confirm-reschedule-button"]');
await expect(page.getByText("This meeting is scheduled")).toBeVisible();
});
todo("Payment should trigger a BOOKING_PAID webhook");
todo("Paid and confirmed booking should be able to be rescheduled");
});
test.describe("Change stripe presented currency", () => {
test("Should be able to change currency", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await user.getPaymentCredential();
// Edit currency inside event type page
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
// Enable Stripe
await page.locator("#event-type-form").getByRole("switch").click();
// Set price
await page.getByTestId("price-input-stripe").fill("200");
// Select currency in dropdown
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-input").fill("mexi");
await page.locator("#react-select-2-option-81").click();
await page.getByTestId("update-eventtype").click();
// Book event
await page.goto(`${user.username}/${eventType?.slug}`);
// Confirm MXN currency it's displayed use expect
await expect(await page.getByText("MX$200.00")).toBeVisible();
await selectFirstAvailableTimeSlotNextMonth(page);
// Confirm again in book form page
await expect(await page.getByText("MX$200.00")).toBeVisible();
// --- fill form
await page.fill('[name="name"]', "Stripe Stripeson");
await page.fill('[name="email"]', "stripe@example.com");
// Confirm booking
await page.click('[data-testid="confirm-book-button"]');
// wait for url to be payment
await page.waitForURL("/payment/*");
// Confirm again in book form page
await expect(await page.getByText("MX$200.00")).toBeVisible();
});
});
});
@@ -153,6 +153,25 @@ test.describe("Manage Booking Questions", () => {
});
});
});
await test.step("Verify that we can prefill name field with firstName,lastName query params", async () => {
const searchParams = new URLSearchParams();
searchParams.append("firstName", "John");
searchParams.append("lastName", "Doe");
await doOnFreshPreviewWithSearchParams(searchParams, page, context, async (page) => {
await selectFirstAvailableTimeSlotNextMonth(page);
await expectSystemFieldsToBeThereOnBookingPage({
page,
isFirstAndLastNameVariant: true,
values: {
name: {
firstName: "John",
lastName: "Doe",
},
},
});
});
});
});
});
+71
View File
@@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { test } from "./lib/fixtures";
const SAML_DATABASE_URL = process.env.SAML_DATABASE_URL!;
const SAML_ADMINS = process.env.SAML_ADMINS!;
const SAML_ADMIN_EMAIL = process.env.E2E_TEST_SAML_ADMIN_EMAIL!;
const SAML_ADMIN_PASSWORD = process.env.E2E_TEST_SAML_ADMIN_PASSWORD!;
const OIDC_CLIENT_ID = process.env.E2E_TEST_OIDC_CLIENT_ID!;
const OIDC_CLIENT_SECRET = process.env.E2E_TEST_OIDC_CLIENT_SECRET!;
const OIDC_PROVIDER_DOMAIN = process.env.E2E_TEST_OIDC_PROVIDER_DOMAIN!;
const OIDC_USER_EMAIL = process.env.E2E_TEST_OIDC_USER_EMAIL!;
const OIDC_USER_PASSWORD = process.env.E2E_TEST_OIDC_USER_PASSWORD!;
const SHOULD_SKIP_TESTS =
!SAML_DATABASE_URL ||
!SAML_ADMINS ||
!SAML_ADMIN_EMAIL ||
!SAML_ADMIN_PASSWORD ||
!OIDC_CLIENT_ID ||
!OIDC_CLIENT_SECRET ||
!OIDC_PROVIDER_DOMAIN ||
!OIDC_USER_EMAIL ||
!OIDC_USER_PASSWORD;
test.afterEach(({ users }) => users.deleteAll());
// TODO: Cleanup the OIDC connection after the tests with fixtures
test.describe("OIDC", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(SHOULD_SKIP_TESTS, "Skipping due to missing the testing variables");
test("Setup with SAML admin and login", async ({ page, users }) => {
// Add the admin user provided in the environment variables to the db
const samlAdminUser = await users.create({ email: SAML_ADMIN_EMAIL, password: SAML_ADMIN_PASSWORD });
await samlAdminUser.apiLogin();
await test.step("Connect with OIDC Provider", async () => {
await page.goto("/settings/security/sso");
await page.click('[data-testid="sso-oidc-configure"]');
await page.fill('[data-testid="sso-oidc-client-id"]', OIDC_CLIENT_ID);
await page.fill('[data-testid="sso-oidc-client-secret"]', OIDC_CLIENT_SECRET);
await page.fill(
'[data-testid="sso-oidc-well-known-url"]',
`https://${OIDC_PROVIDER_DOMAIN}/.well-known/openid-configuration`
);
await page.click('[data-testid="sso-oidc-save"]');
await page.waitForSelector('[data-testid="toast-success"]');
});
// Logout the SAML Admin
await samlAdminUser.logout();
await test.step("Login using the OIDC provider", async () => {
// Login a user using the OIDC provider.
// The credentials are handled by the provider, so we don't need to create a user in the db.
await page.goto("/auth/login");
await page.click('[data-testid="saml"]');
// Redirected outide of the app, the user would be redirected to the OIDC provider.
await page.waitForURL(/https:\/\/[^/]+\/oauth2\/v1\/authorize\?.*/);
await page.getByRole("textbox", { name: "Username" }).fill(OIDC_USER_EMAIL);
await page.getByRole("textbox", { name: "Password" }).fill(OIDC_USER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
// The user is redirected back to the app.
await page.waitForURL("getting-started", { waitUntil: "load" });
});
// Logout the user.
await page.goto("/auth/logout");
await test.step("Disconnect OIDC Provider", async () => {
samlAdminUser.apiLogin();
await page.goto("/settings/security/sso", { waitUntil: "load" });
await page.getByTestId("delete-oidc-sso-connection").click();
await page.getByRole("button", { name: "Yes, delete OIDC configuration" }).click();
await page.waitForSelector('[data-testid="toast-success"]');
});
});
});
+203
View File
@@ -0,0 +1,203 @@
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Payment app", () => {
test("Should be able to edit alby price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "alby_payment",
userId: user.id,
key: {
account_id: "random",
account_email: "random@example.com",
webhook_endpoint_id: "ep_randomString",
webhook_endpoint_secret: "whsec_randomString",
account_lightning_address: "random@getalby.com",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("200");
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 200 sats to be displayed in page
expect(await page.locator("text=200 sats").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
expect(await page.locator("text=200 sats").first()).toBeTruthy();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
expect(await page.locator("text=200 sats").first()).toBeTruthy();
});
test("Should be able to edit stripe price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "stripe_payment",
userId: user.id,
key: {
scope: "read_write",
livemode: false,
token_type: "bearer",
access_token: "sk_test_randomString",
refresh_token: "rt_randomString",
stripe_user_id: "acct_randomString",
default_currency: "usd",
stripe_publishable_key: "pk_test_randomString",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("select-option-usd").click();
await page.getByTestId("price-input-stripe").click();
await page.getByTestId("price-input-stripe").fill("350");
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 200 sats to be displayed in page
expect(await page.locator("text=350").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
expect(await page.locator("text=350").first()).toBeTruthy();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
expect(await page.locator("text=350").first()).toBeTruthy();
});
test("Should be able to edit paypal price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "paypal_payment",
userId: user.id,
key: {
client_id: "randomString",
secret_key: "randomString",
webhook_id: "randomString",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("150");
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-13").click();
await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 150 to be displayed in page
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
// expect 150 to be displayed in page
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
// go to /event-types and check if the price is 150
await page.goto(`event-types/`);
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
});
test("Should display App is not setup already for alby", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "alby_payment",
userId: user.id,
key: {},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
await page.getByRole("button", { name: "Setup" }).click();
// Expect "Connect with Alby" to be displayed
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
});
test("Should display App is not setup already for paypal", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "paypal_payment",
userId: user.id,
key: {},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
await page.getByRole("button", { name: "Setup" }).click();
// Expect "Getting started with Paypal APP" to be displayed
expect(await page.locator("text=Getting started with Paypal APP").first()).toBeTruthy();
});
});
+32
View File
@@ -176,6 +176,38 @@ test.describe("Teams", () => {
await prisma.team.delete({ where: { id: team?.id } });
});
});
test("Can create a private team", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
await owner.apiLogin();
const { team } = await owner.getTeam();
// Mark team as private
await page.goto(`/settings/teams/${team.id}/members`);
await page.click("[data-testid=make-team-private-check]");
await expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible();
// Go to Team's page
await page.goto(`/team/${team.slug}`);
await expect(page.locator('[data-testid="book-a-team-member-btn"]')).toBeHidden();
// Go to members page
await page.goto(`/team/${team.slug}?members=1`);
await expect(page.locator('[data-testid="you-cannot-see-team-members"]')).toBeVisible();
await expect(page.locator('[data-testid="team-members-container"]')).toBeHidden();
});
todo("Create a Round Robin with different leastRecentlyBooked hosts");
todo("Reschedule a Collective EventType booking");
+6 -6
View File
@@ -9,7 +9,7 @@ test.describe.configure({ mode: "parallel" });
const title = (name: string) => `${name} is unpublished`;
const description = (entity: string) =>
`This ${entity} link is currently not available. Please contact the ${entity} owner or ask them to publish it.`;
const avatar = (slug: string) => `/team/${slug}/avatar.png`;
const avatar = (slug: string, entity = "team") => `/${entity}/${slug}/avatar.png`;
test.afterAll(async ({ users }) => {
await users.deleteAll();
@@ -52,7 +52,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
});
test("Organization sub-team", async ({ users, page }) => {
@@ -70,7 +70,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
});
test("Organization sub-team event-type", async ({ users, page }) => {
@@ -90,7 +90,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
});
test("Organization user", async ({ users, page }) => {
@@ -102,7 +102,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
});
test("Organization user event-type", async ({ users, page }) => {
@@ -115,6 +115,6 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
});
});
+119 -1
View File
@@ -17,9 +17,15 @@
"verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم",
"verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني",
"verify_email_email_button": "تأكيد البريد الإلكتروني",
"copy_somewhere_safe": "احتفظ بمفتاح API هذا في مكانٍ آمن، لأنك لن تتمكن من الاطلاع عليه هنا ثانيةً.",
"verify_email_email_body": "الرجاء تأكيد عنوان بريدك الإلكتروني بالنقر فوق الزر أدناه.",
"verify_email_by_code_email_body": "الرجاء تأكيد عنوان بريدك الإلكتروني عبر استخدام الرمز أدناه.",
"verify_email_email_link_text": "إليك الرابط في حال لم تكن ترغب بالنقر فوق أي أزرار:",
"email_verification_code": "الرجاء إدخال رمز التأكيد",
"email_verification_code_placeholder": "الرجاء إدخال رمز التأكيد الذي أُرسل إلى بريدك الإلكتروني",
"incorrect_email_verification_code": "رمز التأكيد غير صحيح.",
"email_sent": "تم إرسال الرسالة الإلكترونية بنجاح",
"email_not_sent": "حدث خطأ أثناء إرسال الرسالة الإلكترونية",
"event_declined_subject": "تم الرفض: {{title}} في {{date}}",
"event_cancelled_subject": "تم الإلغاء: {{title}} في {{date}}",
"event_request_declined": "تم رفض طلب الحدث الخاص بك",
@@ -78,6 +84,7 @@
"event_awaiting_approval_recurring": "الحدث المتكرر في انتظار موافقتك",
"someone_requested_an_event": "طلب شخص ما جدولة حدث في تقويمك.",
"someone_requested_password_reset": "طلب شخص ما رابطا لتغيير كلمة المرور الخاصة بك.",
"password_reset_email_sent": "يجب أن تتلقى رسالة إلكترونية حول إعادة التعيين على هذا البريد الإلكتروني إذا كان موجوداً في نظامنا.",
"password_reset_instructions": "إذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان ولن يتم تغيير كلمة المرور الخاصة بك.",
"event_awaiting_approval_subject": "في انتظار الموافقة: {{title}} في {{date}}",
"event_still_awaiting_approval": "الحدث لا يزال في انتظار موافقتك",
@@ -217,6 +224,10 @@
"already_have_an_account": "هل لديك حساب بالفعل؟",
"create_account": "إنشاء حساب",
"confirm_password": "تأكيد كلمة المرور",
"confirm_auth_change": "سيتسبب هذا في تغيير طريقة تسجيلك للدخول",
"confirm_auth_email_change": "سيتسبب تغيير عنوان بريدك الإلكتروني في إنهاء طريقة المصادقة الحالية لتسجيل الدخول إلى Cal.com. وسنطلب منك تأكيد عنوان بريدك الإلكتروني الجديد، ثم سيتم تسجيل الخروج وسيتوجب عليك استخدام عنوان البريد الإلكتروني الجديد لتسجيل الدخول بدلاً من طريقة المصادقة الحالية بعد إعادة ضبط كلمة المرور لديك باتباع التعليمات التي ستُرسل إلى بريدك الإلكتروني.",
"reset_your_password": "عيّن كلمة المرور الجديدة متبعاً الإرشادات المرسلة إلى عنوان بريدك الإلكتروني.",
"email_change": "سجّل الدخول ثانيةً باستخدام عنوان بريدك الإلكتروني الجديد وكلمة مرور.",
"create_your_account": "إنشاء حسابك",
"sign_up": "تسجيل الاشتراك",
"youve_been_logged_out": "لقد قمت بتسجيل الخروج",
@@ -243,6 +254,10 @@
"all": "الكل",
"yours": "حسابك",
"available_apps": "التطبيقات المتوفرة",
"available_apps_lower_case": "التطبيقات المتوفرة",
"available_apps_desc": "ليس لديك تطبيقات مثبّتة. تصفّح التطبيقات ذات الشعبية أدناه واستكشف المزيد في <1>متجر التطبيقات</1>",
"fixed_host_helper": "أضف أي شخص بحاجة إلى ارتياد الفعالية. <1>اعرف المزيد</1>",
"round_robin_helper": "سيتناوب الأشخاص ضمن المجموعة على الحضور بحيث سيحضر شخص واحد الفعالية في كل مرة.",
"check_email_reset_password": "تحقق من البريد الإلكتروني. لقد أرسلنا رابطًا لإعادة تعيين كلمة المرور.",
"finish": "إنهاء",
"organization_general_description": "إدارة الإعدادات للغة فريقك ومنطقته الزمنية",
@@ -251,6 +266,7 @@
"nearly_there_instructions": "وأخيرًا، يساعدك الوصف الموجز عنك والصورة في الحصول على الحجوزات والسماح للأشخاص بمعرفة الشخص الذين يحجزون معه.",
"set_availability_instructions": "حدد الفترات الزمنية التي تكون متاحًا فيها بشكل متكرر. يمكنك لاحقًا تحديد المزيد منها وربطها مع تقاويم مختلفة.",
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
"availability_settings": "إعدادات التوافرية",
"continue_without_calendar": "المتابعة من دون تقويم",
"connect_your_calendar": "ربط التقويم لديك",
"connect_your_video_app": "اربط تطبيقات الفيديو لديك",
@@ -392,6 +408,8 @@
"allow_dynamic_booking_tooltip": "روابط الحجز الجماعية التي يمكن إنشاؤها ديناميكيًا عن طريق إضافة أسماء مستخدمين متعددة باستخدام \"+\". مثال: \"{{appName}}/bailey+peer\"",
"allow_dynamic_booking": "السماح للحضور بالحجز لك عن طريق حجز المجموعات الديناميكي",
"dynamic_booking": "روابط المجموعة الديناميكية",
"allow_seo_indexing": "السماح لمحركات البحث بالوصول إلى المحتوى العام لديك",
"seo_indexing": "السماح بالفهرسة وفق SEO",
"email": "البريد الإلكتروني",
"email_placeholder": "name@example.com",
"full_name": "الاسم بالكامل",
@@ -406,6 +424,7 @@
"booking_requested": "تم طلب الحجز",
"meeting_ended": "انتهى الاجتماع",
"form_submitted": "تم إرسال النموذج",
"booking_paid": "تم الدفع لقاء الحجز",
"event_triggers": "مشغلات الحدث",
"subscriber_url": "رابط المشترك",
"create_new_webhook": "إنشاء ويب هوك جديد",
@@ -477,6 +496,7 @@
"max_limit_allowed_hint": "يجب أن يكون عدد الأحرف {{limit}} أو أقل",
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من {{passwordLength}} أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
"incorrect_password": "كلمة المرور غير صحيحة.",
"incorrect_email_password": "اسم المستخدم أو كلمة المرور غير صحيحين.",
"use_setting": "استخدام الإعداد",
"am_pm": "صباحًا/مساءً",
"time_options": "خيارات الوقت",
@@ -505,6 +525,7 @@
"your_name": "اسمك",
"your_full_name": "اسمك الكامل",
"no_name": "لا يوجد اسم",
"enter_number_between_range": "الرجاء إدخال رقم بين 1 و {{maxOccurences}}",
"email_address": "عنوان البريد الإلكتروني",
"enter_valid_email": "يرجى إدخال بريد إلكتروني صالح",
"location": "الموقع",
@@ -542,6 +563,7 @@
"leave": "خروج",
"profile": "الملف الشخصي",
"my_team_url": "عنوان URL الخاص بفريقي",
"my_teams": "فِرَقي",
"team_name": "اسم الفريق",
"your_team_name": "اسم فريقك",
"team_updated_successfully": "تم تحديث الفريق بنجاح",
@@ -569,6 +591,8 @@
"invite_new_member": "دعوة عضو جديد في الفريق",
"invite_new_member_description": "ملاحظة: سيكلّف هذا <1>مقعداً إضافياً (15 دولاراً شهرياً)</1> زيادةً على اشتراكك.",
"invite_new_team_member": "دعوة شخص ما إلى فريقك.",
"upload_csv_file": "تحميل ملف بصيغة .csv",
"invite_via_email": "الدعوة عبر البريد الإلكتروني",
"change_member_role": "تغيير دور العضو في الفريق",
"disable_cal_branding": "تعطيل علامات {{appName}} التجارية",
"disable_cal_branding_description": "إخفاء كل علامات {{appName}} التجارية من الصفحات العامة لديك.",
@@ -802,7 +826,7 @@
"confirm_delete_event_type": "نعم، قم بالحذف",
"delete_account": "حذف الحساب",
"confirm_delete_account": "نعم، احذف الحساب",
"delete_account_confirmation_message": "هل تريد بالتأكيد حذف حسابك على {{appName}}؟ أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
"delete_account_confirmation_message": "أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
"integrations": "التكاملات",
"apps": "التطبيقات",
"apps_description": "هنا يمكنك العثور على قائمة بتطبيقاتك",
@@ -841,6 +865,7 @@
"team_view_user_availability_disabled": "يحتاج المستخدم إلى قبول الدعوة لعرض الأوقات المتاحة",
"set_as_away": "تغيير حالتك إلى \"ليس موجودًا\"",
"set_as_free": "تعطيل حالة \"ليس موجودًا\"",
"toggle_away_error": "حديث خطأ في تحديث الحالة إلى \"بعيد\"",
"user_away": "هذا المستخدم ليس موجودًا حاليًا.",
"user_away_description": "حالة الشخص الذي تحاول حجز خدماته \"ليس موجودًا\"، ولذا فهو لا يقبل عمليات حجز جديدة.",
"meet_people_with_the_same_tokens": "الاجتماع مع أشخاص بنفس ال tokens",
@@ -850,6 +875,7 @@
"account_managed_by_identity_provider_description": "لتغيير بريدك الإلكتروني وكلمة المرور وتمكين المصادقة من عاملين والمزيد، يُرجى زيارة إعدادات حساب {{provider}} لديك.",
"signin_with_google": "تسجيل الدخول عبر Google",
"signin_with_saml": "تسجيل الدخول عبر SAML",
"signin_with_saml_oidc": "تسجيل الدخول عبر SAML/OIDC",
"you_will_need_to_generate": "ستحتاج إلى إنشاء access token من أداة الجدولة القديمة لديك.",
"import": "استيراد",
"import_from": "الاستيراد من",
@@ -949,6 +975,8 @@
"offer_seats_description": "عرض مقاعد للحجز. هذا يعطل تلقائياً حجز الضيوف وحجز الاشتراك.",
"seats_available_one": "المقاعد المتاحة",
"seats_available_other": "المقاعد المتاحة",
"seats_nearly_full": "المقاعد ممتلئة تقريباً",
"seats_half_full": "المقاعد تُشغل بسرعة",
"number_of_seats": "عدد المقاعد لكل حجز",
"enter_number_of_seats": "أدخل عدد المقاعد",
"you_can_manage_your_schedules": "يمكنك إدارة الجداول الخاصة بك في صفحة الأوقات المتاحة.",
@@ -1029,12 +1057,15 @@
"how_you_want_add_cal_site": "كيف تريد إضافة {{appName}} إلى موقعك الإلكتروني؟",
"choose_ways_put_cal_site": "اختر واحدة من الطرق التالية لوضع {{appName}} على موقعك الإلكتروني.",
"setting_up_zapier": "إعداد دمج Zapier الخاص بك",
"setting_up_make": "إعداد دمج Make لديك",
"generate_api_key": "إنشاء مفتاح API",
"generate_api_key_description": "إنشاء مفتاح API لاستخدامه مع {{appName}} على",
"your_unique_api_key": "مفتاح API الفريد الخاص بك",
"copy_safe_api_key": "انسخ مفتاح API هذا واحفظه في مكان آمن. إذا فقدت هذا المفتاح، يتوجب عليك إنشاء مفتاح جديد.",
"zapier_setup_instructions": "<0>سجل الدخول إلى حساب Zapier الخاص بك وأنشئ Zap جديد.</0><1>حدد Cal.com كتطبيق Trigger لديك. اختر أيضًا حدث Trigger.</1><2>اختر حسابك ثم أدخل مفتاح API الفريد الخاص بك.</2><3>اختبر Trigger الخاص بك.</3><4>أنت جاهز!</4>",
"make_setup_instructions": "<0>انتقل إلى <1><0>إنشاء رابط دعوة</0></1> وقم بتثبيت تطبيق Cal.com </0><1> ثم سجّل الدخول إلى حسابك وقم بإنشاء \"سيناريو جديد\".</1><2>اختر Cal.com على أنه أحد تطبيقات \"التشغيل\" لديك. اختر أيضا حدث تشغيل. </2><3>اختر حسابك ثم ادخل مفتاح API الفريد الخاص بك.</3><4>اختبر المشغل لديك.</4><5>لقد أتممت الأمر!</5>",
"install_zapier_app": "يرجى تثبيت تطبيق Zapier من App Store أولًا.",
"install_make_app": "يرجى تثبيت تطبيق Make من متجر التطبيقات أولًا.",
"connect_apple_server": "الاتصال بخادم Apple",
"calendar_url": "رابط التقويم",
"apple_server_generate_password": "أنشئ كلمة مرور خاصة بالتطبيق لاستخدامها مع {{appName}} على",
@@ -1084,6 +1115,9 @@
"email_attendee_action": "إرسال رسالة إلكترونية إلى الحضور",
"sms_attendee_action": "إرسال رسالة نصية قصيرة إلى أحد الحضور",
"sms_number_action": "إرسال رسالة نصية قصيرة إلى رقم محدد",
"send_reminder_sms": "إرسال تذكيرات بالاجتماعات إلى الحاضرين بيُسر عبر الرسائل النصية القصيرة",
"whatsapp_number_action": "إرسال رسالة WhatsApp إلى رقم محدد",
"whatsapp_attendee_action": "إرسال رسالة عبر WhatsApp إلى أحد الحاضرين",
"workflows": "سير العمل",
"new_workflow_btn": "سير عمل جديد",
"add_new_workflow": "إضافة سير عمل جديد",
@@ -1147,6 +1181,8 @@
"choose_template": "اختيار قالب",
"custom": "مخصص",
"reminder": "تذكير",
"rescheduled": "تمت إعادة الجدولة",
"completed": "تمّ",
"reminder_email": "تذكر {{eventType}} مع {{name}} في {{date}}",
"not_triggering_existing_bookings": "لن يتم تشغيل الحجوزات الموجودة مسبقاً حيث سيتم طلب رقم الهاتف عند حجز الحدث.",
"minute_one": "{{count}} دقيقة",
@@ -1239,6 +1275,7 @@
"error_updating_settings": "خطأ في تحديث الإعدادات",
"personal_cal_url": "عنوان {{appName}} URL الخاص بي",
"bio_hint": "اكتب بضع جمل عن نفسك، والتي ستظهر على صفحة عنوان Url الشخصية لديك.",
"user_has_no_bio": "لم يُضف هذا المستخدم لمحة عنه بعد.",
"delete_account_modal_title": "حذف الحساب",
"confirm_delete_account_modal": "هل أنت متأكد من أنك تريد حذف حساب {{appName}} الخاص بك؟",
"delete_my_account": "حذف حسابي",
@@ -1421,6 +1458,7 @@
"add_limit": "إضافة حد",
"team_name_required": "اسم الفريق مطلوب",
"show_attendees": "مشاركة معلومات الحضور مع الضيوف",
"show_available_seats_count": "إظهار عدد المقاعد المتاحة",
"how_booking_questions_as_variables": "كيف يمكن استخدام أسئلة الحجز كمتغيرات؟",
"format": "التنسيق",
"uppercase_for_letters": "استخدم الحروف الكبيرة لجميع الأحرف",
@@ -1489,7 +1527,9 @@
"must_enter_team_name": "يجب إدخال اسم فريق",
"team_url_required": "يجب إدخال عنوان URL للفريق",
"url_taken": "عنوان URL هذا مأخوذ بالفعل",
"problem_registering_domain": "حدثت مشكلة في تسجيل المجال الفرعي، يُرجى المحاولة لاحقاً أو الاتصال بالمشرف",
"team_publish": "نشر فريق",
"number_text_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
"attendee_email_variable": "اسم الحاضر",
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
@@ -1652,8 +1692,11 @@
"delete_sso_configuration_confirmation_description": "هل تريد بالتأكيد حذف تكوين {{connectionType}}؟ لن يتمكن أعضاء فريقك الذين يستخدمون معلومات تسجيل الدخول إلى {{connectionType}} من الوصول إلى Cal.com بعد الآن.",
"organizer_timezone": "منظم المناطق الزمنية",
"email_user_cta": "عرض الدعوة",
"email_no_user_invite_heading_team": "تمت دعوتك للانضمام إلى فريق {{appName}}",
"email_no_user_invite_heading_org": "تمت دعوتك للانضمام إلى منظمة {{appName}}",
"email_no_user_invite_subheading": "دعاك {{invitedBy}} للانضمام إلى فريقه على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
"email_user_invite_subheading_team": "دعاك {{invitedBy}} للانضمام إلى فريقه {{teamName}} على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
"email_user_invite_subheading_org": "لقد دعاك {{invitedBy}} للانضمام إلى فريق {{teamName}} التابع لمنظمتهم على {{appName}}. إن {{appName}} هو برنامج جدولة للفعاليات يمنحك القدرة أنت ومؤسستك على جدولة الاجتماعات من دون الحاجة إلى التراسل الكثيف عبر البريد الإلكتروني.",
"email_no_user_invite_steps_intro": "سنرشدك خلال بضع خطوات قصيرة وستستمتع بجدول زمني خالٍ من التوتر مع {{entity}} في لمح البصر.",
"email_no_user_step_one": "اختر اسم المستخدم الخاص بك",
"email_no_user_step_two": "ربط حساب التقويم الخاص بك",
@@ -1824,6 +1867,8 @@
"insights_no_data_found_for_filter": "لم يتم العثور على بيانات لعامل التصفية المحدد أو التواريخ المحددة.",
"acknowledge_booking_no_show_fee": "أقر بأنه إذا لم أحضر هذا الحدث فسيتم سحب رسوم عدم حضور من بطاقتي تبلغ {{amount, currency}}.",
"card_details": "تفاصيل البطاقة",
"something_went_wrong_on_our_end": "حدث خطأ ما من طرفنا. اتصل بفريق الدعم لدينا، وسنُصلحه لك فوراً.",
"please_provide_following_text_to_suppport": "يرجى تقديم النص التالي عند الاتصال بالدعم لمساعدتك بشكل أفضل",
"seats_and_no_show_fee_error": "لا يمكن حاليًا تمكين المقاعد وفرض رسوم عدم الحضور",
"complete_your_booking": "أكمل الحجز",
"complete_your_booking_subject": "أكمل الحجز: {{title}} في {{date}}",
@@ -1857,6 +1902,9 @@
"open_dialog_with_element_click": "فتح مربع حوار Cal عندما ينقر شخص فوق عنصر ما.",
"need_help_embedding": "هل أنت بحاجة إلى مساعدة؟ راجع أدلتنا لتضمين Cal على Wix، أو Squarespace، أو WordPress، راجع أسئلتنا الشائعة، أو استكشف خيارات التضمين المتقدمة.",
"book_my_cal": "تثبيت الحجز في Cal",
"first_name": "الاسم الأول",
"last_name": "اسم العائلة",
"first_last_name": "الاسم الأول، اسم العائلة",
"invite_as": "دعوة باسم",
"form_updated_successfully": "تم تحديث النموذج بنجاح.",
"disable_attendees_confirmation_emails": "تعطيل رسائل التأكيد الافتراضية للحضور",
@@ -1870,6 +1918,7 @@
"first_event_type_webhook_description": "قم بإنشاء أول شبكة ويب هوك لهذا النوع من الأحداث",
"install_app_on": "تثبيت التطبيق على",
"create_for": "إنشاء من أجل",
"currency": "العملة",
"organization_banner_description": "إنشاء بيئات حيث يمكن لفرقك إنشاء تطبيقات مشتركة ومهام سير العمل وأنواع الأحداث باستخدام الجدولة الدوارة والجماعية.",
"organization_banner_title": "إدارة المنظمات ذات الفرق المتعددة",
"set_up_your_organization": "إعداد منظمتك",
@@ -1930,5 +1979,74 @@
"insights_team_filter": "الفريق: {{teamName}}",
"insights_user_filter": "المستخدم: {{userName}}",
"insights_subtitle": "عرض Insights الحجز عبر أحداثك",
"location_options": "خيارات الموقع {{locationCount}}",
"custom_plan": "الخطة المخصصة",
"email_embed": "البريد الإلكتروني المضمن",
"add_times_to_your_email": "حدّد بعض الفترات المتاحة وأدرجها في رسالتك الإلكترونية",
"select_time": "اختيار الوقت",
"select_date": "اختيار التاريخ",
"see_all_available_times": "رؤية كل الأوقات المتاحة",
"org_team_names_example": "مثال، فريق التسويق",
"org_team_names_example_1": "مثال، فريق التسويق",
"org_team_names_example_2": "مثال، فريق المبيعات",
"org_team_names_example_3": "مثال، فريق التصميم",
"org_team_names_example_4": "مثال، الفريق الهندسي",
"org_team_names_example_5": "مثال، فريق تحليل البيانات",
"org_max_team_warnings": "ستكون قادراً على إضافة فرق إضافية في وقت لاحق.",
"what_is_this_meeting_about": "عمَّ تدور هذه الجلسة؟",
"add_to_team": "إضافة إلى الفريق",
"remove_users_from_org": "إزالة مستخدمين من مؤسسة",
"remove_users_from_org_confirm": "هل أنت متأكد من أنك تريد إزالة هذا العدد من المستخدمين {{userCount}} من هذه المؤسسة؟",
"user_has_no_schedules": "لم يقم هذا المستخدم بإعداد بأي جدولة حتى الآن",
"user_isnt_in_any_teams": "هذا المستخدم ليس في أي فريق",
"requires_booker_email_verification": "يتطلب ذلك تأكيد البريد الإلكتروني لمن يقوم بالحجز",
"description_requires_booker_email_verification": "لضمان تأكيد البريد الإلكتروني لمن يقوم بالحجز قبل جدولة الفعاليات",
"requires_confirmation_mandatory": "يمكن إرسال رسائل نصية فقط للحاضرين عندما يتطلب نوع الفعالية تأكيداً.",
"organizations": "المؤسسات",
"org_admin_other_teams": "فرق أخرى",
"org_admin_other_teams_description": "يمكنك هنا رؤية الفرق ضمن مؤسستك والتي أنت لست جزءاً منها؛ ويمكنك إضافة نفسك إليها عند الحاجة.",
"no_other_teams_found": "لم يتم العثور على فرق أخرى",
"no_other_teams_found_description": "لا توجد فرق أخرى في هذه المؤسسة.",
"attendee_first_name_variable": "الاسم الأول للحاضر",
"attendee_last_name_variable": "اسم العائلة للحاضر",
"attendee_first_name_info": "الاسم الأول للشخص صاحب الحجز",
"attendee_last_name_info": "اسم العائلة للشخص صاحب الحجز",
"me": "أنا",
"verify_team_tooltip": "تأكيد فريقك لتمكين إرسال الرسائل إلى الحاضرين",
"member_removed": "تمت إزالة العضو",
"my_availability": "أوقاتي المتاحة",
"team_availability": "أوقات الفريق المتاحة",
"backup_code": "الرمز الاحتياطي",
"backup_codes": "الرموز الاحتياطية",
"backup_code_instructions": "يمكن استخدام كل رمز احتياطي لمرة واحدة فقط لمنح حق الوصول بدون مصادقة منك.",
"backup_codes_copied": "تم نسخ الرموز الاحتياطية!",
"incorrect_backup_code": "الرمز الاحتياطي غير صحيح.",
"lost_access": "فُقد الوصول",
"missing_backup_codes": "لم يُعثر على رموز احتياطية. الرجاء إنشاؤها في الإعدادات لديك.",
"admin_org_notification_email_subject": "تم إنشاء منظمة جديدة: في انتظار العمل",
"hi_admin": "مرحباً حضرة المشرف",
"admin_org_notification_email_title": "تحتاج منظمة إلى إعداد DNS",
"admin_org_notification_email_body_part1": "تم إنشاء منظمة بعيار \"{{orgSlug}}\".<br /><br />يرجى التأكد من تكوين سجل DNS الخاص بك لتحديد النطاق الفرعي المقابل للمنظمة الجديدة إلى حيث يعمل التطبيق الرئيسي. وإلا فلن تعمل المنظمة.<br /><br />هنا لديك فقط الخيارات الأساسية لتكوين نطاق فرعي للإشارة إلى تطبيقهم حتى يُحمّل صفحة الملف الشخصي للمنظمة.<br /><br />يمكنك القيام بذلك إما بواسطة السجل A:",
"admin_org_notification_email_body_part2": "أو سجل CNAME:",
"admin_org_notification_email_body_part3": "بمجرد تكوين النطاق الفرعي، يرجى وضع علامة على تكوين DNS كما تم في إعدادات مشرف المنظمات.",
"admin_org_notification_email_cta": "الذهاب إلى إعدادات مشرف المؤسسات",
"org_has_been_processed": "تم تجهيز المؤسسة",
"org_error_processing": "حدث خطأ أثناء تجهيز هذه المؤسسة",
"orgs_page_description": "قائمة بجميع المؤسسات. سيسمح قبول مؤسسة ما لجميع المستخدمين الذين لديهم عناوين بريد إلكتروني تنتهي بنطاق المؤسسة بالتسجيل من دون الحاجة إلى تأكيد بريدهم الإلكتروني.",
"unverified": "لم تتم عملية التأكيد",
"dns_missing": "DNS مفقود",
"mark_dns_configured": "وضع إشارة تكوين على DNS",
"value": "القيمة",
"your_organization_updated_sucessfully": "تم تحديث منظمتك بنجاح",
"team_no_event_types": "ليس لدى هذا الفريق أنواع من الفعاليات",
"seat_options_doesnt_multiple_durations": "لا يدعم خيار المقعد فترات متعددة",
"include_calendar_event": "إدراج فعاليات في التقويم",
"recently_added": "تمت الإضافة مؤخراً",
"no_members_found": "لم يُعثر على أعضاء",
"event_setup_length_error": "إعداد الفعالية: يجب أن تكون المدة لدقيقة على الأقل.",
"availability_schedules": "جدولة التوافر",
"view_only_edit_availability_not_onboarded": "لم يكمل هذا المستخدم عملية الانضمام. لن تتمكن من تعيين توافره حتى يكتمل انضمامه.",
"view_only_edit_availability": "أنت تشاهد توافر هذا المستخدم. يمكنك تعديل توافرك فقط.",
"edit_users_availability": "تعديل توافر المستخدم: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+111 -1
View File
@@ -17,9 +17,15 @@
"verify_email_banner_body": "Ověřte svoji e-mailovou adresu, abyste zaručili co nejlepší doručitelnost e-mailů a kalendáře",
"verify_email_email_header": "Ověřte svoji e-mailovou adresu",
"verify_email_email_button": "Ověřit e-mail",
"copy_somewhere_safe": "Uložte si tento klíč API na bezpečné místo. Možnost dalšího zobrazení mít nebudete.",
"verify_email_email_body": "Ověřte svoji e-mailovou adresu kliknutím na tlačítko níže.",
"verify_email_by_code_email_body": "Ověřte prosím svoji e-mailovou adresu pomocí níže uvedeného kódu.",
"verify_email_email_link_text": "Zde je odkaz pro případ, že nemáte rádi klikání na tlačítka:",
"email_verification_code": "Zadejte ověřovací kód",
"email_verification_code_placeholder": "Zadejte ověřovací kód odeslaný na váš e-mail",
"incorrect_email_verification_code": "Ověřovací kód je nesprávný.",
"email_sent": "E-mail byl odeslán",
"email_not_sent": "Při odesílání e-mailu došlo k chybě",
"event_declined_subject": "Odmítnuto: {{title}} v {{date}}",
"event_cancelled_subject": "Zrušeno: {{title}} dne {{date}}",
"event_request_declined": "Žádost o událost byla zamítnuta",
@@ -78,6 +84,7 @@
"event_awaiting_approval_recurring": "Opakovaná událost čeká na schválení",
"someone_requested_an_event": "Někdo požádal o naplánování události ve vašem kalendáři.",
"someone_requested_password_reset": "Někdo požádal o odkaz pro změnu vašeho hesla.",
"password_reset_email_sent": "Pokud tento e-mail v našem systému existuje, měli byste obdržet obnovovací e-mail.",
"password_reset_instructions": "Pokud jste o obnovení hesla nepožádali, můžete tento e-mail ignorovat. Vaše heslo se nezmění.",
"event_awaiting_approval_subject": "Čeká se na schválení: {{title}} dne {{date}}",
"event_still_awaiting_approval": "Událost stále čeká na schválení",
@@ -217,6 +224,10 @@
"already_have_an_account": "Už máte účet?",
"create_account": "Vytvořit účet",
"confirm_password": "Potvrdit heslo",
"confirm_auth_change": "Tím se změní způsob přihlašování",
"confirm_auth_email_change": "Změnou e-mailové adresy dojde v souvislosti s přihlášením do Cal.com k odpojení vašeho současného způsobu ověřování. Budeme po vás chtít ověření nové e-mailové adresy. Následně budete odhlášeni a po nastavení hesla podle pokynů, které vám přijdou do pošty, se k přihlašování bude namísto stávajícího způsobu ověřování používat nová e-mailová adresa.",
"reset_your_password": "Nastavte si nové heslo podle pokynů odeslaných na vaši e-mailovou adresu.",
"email_change": "Přihlaste se znovu pomocí nové e-mailové adresy a hesla.",
"create_your_account": "Vytvořit účet",
"sign_up": "Přihlásit se",
"youve_been_logged_out": "Byli jste odhlášeni",
@@ -243,6 +254,10 @@
"all": "Všechny aplikace",
"yours": "Váš účet",
"available_apps": "Dostupné aplikace",
"available_apps_lower_case": "Dostupné aplikace",
"available_apps_desc": "Nemáte nainstalované žádné aplikace. Níže si můžete prohlédnout oblíbené aplikace a další objevit v našem <1>obchodu s aplikacemi</1>",
"fixed_host_helper": "Přidejte každého, kdo se má zúčastnit události. <1>Další informace</1>",
"round_robin_helper": "Lidé ve skupině se střídají a na událost se dostaví pouze jedna osoba.",
"check_email_reset_password": "Podívejte se do e-mailu. Poslali jsme vám odkaz pro obnovení hesla.",
"finish": "Dokončit",
"organization_general_description": "Správa nastavení jazyka a časového pásma vašeho týmu",
@@ -251,6 +266,7 @@
"nearly_there_instructions": "Poslední věc... pár slov o vás a fotografie vám pomůžou získat rezervace a lidi aspoň budou vědět, koho si rezervují.",
"set_availability_instructions": "Určete si časové intervaly, kdy jste pravidelně dostupní. Později můžete vytvořit další a přiřadit je různým kalendářům.",
"set_availability": "Nastavte, jak jste dostupní",
"availability_settings": "Nastavení dostupnosti",
"continue_without_calendar": "Pokračovat bez kalendáře",
"connect_your_calendar": "Připojit svůj kalendář",
"connect_your_video_app": "Propojte své video aplikace",
@@ -392,6 +408,8 @@
"allow_dynamic_booking_tooltip": "Odkazy na skupinové rezervace, které lze vytvořit dynamicky přidáním více uživatelských jmen s „+“. příklad: „{{appName}}/bailey+peer“",
"allow_dynamic_booking": "Povolit účastníkům rezervovat vás prostřednictvím dynamických skupinových rezervací",
"dynamic_booking": "Dynamické odkazy na skupiny",
"allow_seo_indexing": "Povolte vyhledávačům přístup k vašemu veřejnému obsahu",
"seo_indexing": "Zapnout indexování SEO",
"email": "E-mailová adresa",
"email_placeholder": "vas@email.cz",
"full_name": "Jméno a příjmení",
@@ -406,6 +424,7 @@
"booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán",
"meeting_ended": "Schůzka skončila",
"form_submitted": "Formulář byl odeslán",
"booking_paid": "Rezervace uhrazena",
"event_triggers": "Eventy na základě akce",
"subscriber_url": "URL pro odběry",
"create_new_webhook": "Vytvořit nový webhook",
@@ -506,6 +525,7 @@
"your_name": "Vaše jméno",
"your_full_name": "Vaše celé jméno",
"no_name": "Žádné jméno",
"enter_number_between_range": "Zadejte prosím číslo v rozpětí 1 a {{maxOccurences}}",
"email_address": "E-mailová adresa",
"enter_valid_email": "Zadejte platný e-mail",
"location": "Místo",
@@ -543,6 +563,7 @@
"leave": "Opustit",
"profile": "Profil",
"my_team_url": "URL adresa mého týmu",
"my_teams": "Moje týmy",
"team_name": "Název týmu",
"your_team_name": "Název vašeho týmu",
"team_updated_successfully": "Tým byl úspěšně aktualizován",
@@ -570,6 +591,8 @@
"invite_new_member": "Pozvat nového člena",
"invite_new_member_description": "Poznámka: Ve vašem předplatném to bude <1>stát místo navíc (15 USD/měs.)</1> .",
"invite_new_team_member": "Pozvat někoho do týmu.",
"upload_csv_file": "Nahrajte soubor .csv",
"invite_via_email": "Pozvat e-mailem",
"change_member_role": "Změnit roli člena týmu",
"disable_cal_branding": "Vypnout branding {{appName}}",
"disable_cal_branding_description": "Skrýt branding {{appName}} ze všech veřejných stránek.",
@@ -803,7 +826,7 @@
"confirm_delete_event_type": "Ano, odstranit",
"delete_account": "Smazat účet",
"confirm_delete_account": "Ano, smazat účet",
"delete_account_confirmation_message": "Opravdu chcete odstranit svůj účet na {{appName}}? Všichni, se kterými jste sdíleli odkaz na účet, přijdou o možnost vytvářet rezervace a vy přijdete o uložené předvolby.",
"delete_account_confirmation_message": "Všichni, se kterými jste sdíleli odkaz na účet, přijdou o možnost vytvářet rezervace a vy přijdete o uložené předvolby.",
"integrations": "Integrace",
"apps": "Aplikace",
"apps_description": "Tady najdete seznam svých aplikací",
@@ -842,6 +865,7 @@
"team_view_user_availability_disabled": "Uživatel musí přijmout pozvánku, aby mohl zobrazit dostupnost",
"set_as_away": "Nastavit status \"pryč\"",
"set_as_free": "Vypnout status \"pryč\"",
"toggle_away_error": "Chyba při aktualizaci statusu „pryč“",
"user_away": "Tento uživatel je právě \"pryč\".",
"user_away_description": "Osoba, kterou se snažíte rezervovat, má nastavený status \"pryč\" a nepřijímá tak nové rezervace.",
"meet_people_with_the_same_tokens": "Seznamte se s lidmi se stejnými tokeny",
@@ -851,6 +875,7 @@
"account_managed_by_identity_provider_description": "Pro změnu e-mailu, hesla, dvoufázového ověřování apod. prosím přejděte do nastavení účtu {{provider}}.",
"signin_with_google": "Přihlásit se pomocí Googlu",
"signin_with_saml": "Přihlásit se pomocí SAML",
"signin_with_saml_oidc": "Přihlášení pomocí SAML/OIDC",
"you_will_need_to_generate": "Budete muset vygenerovat přístupový token ve vašem dřívějším plánovacím nástroji.",
"import": "Importovat",
"import_from": "Importovat z",
@@ -950,6 +975,8 @@
"offer_seats_description": "Nabídnout místa k rezervaci. Tím se vypnou rezervace hostů a volitelné rezervace.",
"seats_available_one": "Dostupné místo",
"seats_available_other": "Dostupná místa",
"seats_nearly_full": "Místa jsou téměř obsazena",
"seats_half_full": "Místa se rychle zaplňují",
"number_of_seats": "Počet míst na rezervaci",
"enter_number_of_seats": "Zadejte počet míst",
"you_can_manage_your_schedules": "Své plány můžete spravovat na stránce Dostupnost.",
@@ -1030,12 +1057,15 @@
"how_you_want_add_cal_site": "Jak chcete přidat {{appName}} na vaše stránky?",
"choose_ways_put_cal_site": "Vyberte si některý z následujících způsobů, jak umístit {{appName}} na své stránky.",
"setting_up_zapier": "Nastavení integrace Zapier",
"setting_up_make": "Nastavení integrace s Make",
"generate_api_key": "Vygenerovat klíč API",
"generate_api_key_description": "Vygenerujte klíč API pro použití s {{appName}} na adrese",
"your_unique_api_key": "Váš unikátní klíč API",
"copy_safe_api_key": "Zkopírujte tento klíč API a uschovejte si ho na bezpečné místo. Pokud tento klíč ztratíte, musíte vygenerovat nový.",
"zapier_setup_instructions": "<0>Přihlaste se ke svému účtu Zapier a vytvořte nový Zap.</0><1>Vyberte Cal.com jako svoji aplikaci Trigger. Také vyberte událost Trigger.</1><2>Vyberte svůj účet a pak zadejte svůj unikátní klíč API.</2><3>Otestujte svůj Trigger.</3><4>Všechno máte nastaveno!</4>",
"make_setup_instructions": "<0>Přejděte na <1><0>odkaz s pozvánkou pro Make</0></1> a nainstalujte aplikaci Cal.com.</0><1>Přihlaste se ke svému účtu Make a vytvořte nový scénář.</1><2>Jako spouštěcí aplikaci vyberte Cal.com. Dále vyberte událost spouštěče.</2><3>Vyberte svůj účet a následně zadejte svůj unikátní klíč API.</3><4>Otestujte svůj spouštěč.</4><5>Všechno je připraveno!</5>",
"install_zapier_app": "Nejdřív si z App Store nainstalujte aplikaci Zapier.",
"install_make_app": "Nejprve si nainstalujte aplikaci Make z obchodu s aplikacemi.",
"connect_apple_server": "Připojit se k serveru Apple",
"calendar_url": "URL kalendáře",
"apple_server_generate_password": "Vygenerujte si specifické heslo pro aplikaci {{appName}} na",
@@ -1085,6 +1115,7 @@
"email_attendee_action": "odeslat e-mail účastníkům",
"sms_attendee_action": "Odeslat SMS účastníkovi",
"sms_number_action": "odeslání SMS na konkrétní číslo",
"send_reminder_sms": "Snadné zasílání připomenutí schůzek účastníkům přes SMS",
"whatsapp_number_action": "odeslat Whatsapp na konkrétní číslo",
"whatsapp_attendee_action": "odeslat Whatsapp účastníkovi",
"workflows": "Pracovní postupy",
@@ -1244,6 +1275,7 @@
"error_updating_settings": "Chyba při aktualizaci nastavení",
"personal_cal_url": "Moje osobní adresa URL {{appName}}",
"bio_hint": "Několik vět o vás. Obsah se zobrazí se na vaší osobní stránce URL.",
"user_has_no_bio": "Tento uživatel zatím nepřidal životopis.",
"delete_account_modal_title": "Odstranit účet",
"confirm_delete_account_modal": "Opravdu chcete odstranit svůj účet {{appName}}?",
"delete_my_account": "Odstranit můj účet",
@@ -1426,6 +1458,7 @@
"add_limit": "Přidat limit",
"team_name_required": "Je vyžadován název týmu",
"show_attendees": "Sdílet informace o účastnících",
"show_available_seats_count": "Zobrazení počtu volných míst",
"how_booking_questions_as_variables": "Jak používat rezervační otázky jako proměnné?",
"format": "Formát",
"uppercase_for_letters": "Použít velká písmena pro všechna písmena",
@@ -1659,8 +1692,11 @@
"delete_sso_configuration_confirmation_description": "Opravdu chcete odstranit konfiguraci {{connectionType}}? Členové vašeho týmu, kteří používají přihlášení {{connectionType}}, ztratí přístup k webu Cal.com.",
"organizer_timezone": "Časové pásmo organizátora",
"email_user_cta": "Zobrazit pozvánku",
"email_no_user_invite_heading_team": "Byli jste pozváni do týmu {{appName}}",
"email_no_user_invite_heading_org": "Byli jste pozváni do organizace {{appName}}",
"email_no_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového ping-pongu.",
"email_user_invite_subheading_team": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu „{{teamName}}“ v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového pingpongu.",
"email_user_invite_subheading_org": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k týmu {{teamName}} jeho organizace v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vaší organizaci umožňuje plánovat schůzky bez e-mailového pingpongu.",
"email_no_user_invite_steps_intro": "Provedeme vás několika krátkými kroky a za malou chvíli si budete se svým subjektem {{entity}} užívat plánování bez stresu.",
"email_no_user_step_one": "Vyberte si uživatelské jméno",
"email_no_user_step_two": "Připojte svůj účet kalendáře",
@@ -1831,6 +1867,8 @@
"insights_no_data_found_for_filter": "Pro vybraný filtr nebo vybraná data nebyla nalezena žádná data.",
"acknowledge_booking_no_show_fee": "Beru na vědomí, že pokud se této události nezúčastním, bude z mé karty stržen poplatek za nedostavení se ve výši {{amount, currency}}.",
"card_details": "Údaje o kartě",
"something_went_wrong_on_our_end": "Na naší straně se něco pokazilo. Spojte se s naší podporou a hned to napravíme.",
"please_provide_following_text_to_suppport": "Při kontaktování podpory uveďte v zájmu lepší efektivity následující",
"seats_and_no_show_fee_error": "V současné době nelze povolit místa a strhnout poplatek za nedostavení se",
"complete_your_booking": "Dokončete svou rezervaci",
"complete_your_booking_subject": "Dokončete svou rezervaci: {{title}} dne {{date}}",
@@ -1864,6 +1902,9 @@
"open_dialog_with_element_click": "Otevře dialogové okno Cal, pokud někdo klikne na prvek.",
"need_help_embedding": "Potřebujete pomoc? Prohlédněte si naše průvodce pro vložení Cal do Wixu, Squarespace nebo WordPressu, podívejte se na naše nejčastější dotazy nebo prozkoumejte pokročilé možnosti vložení.",
"book_my_cal": "Zarezervovat na Cal",
"first_name": "Jméno",
"last_name": "Příjmení",
"first_last_name": "Jméno, příjmení",
"invite_as": "Pozvat jako",
"form_updated_successfully": "Formulář byl aktualizován.",
"disable_attendees_confirmation_emails": "Vypnutí výchozích potvrzovacích e-mailů pro účastníky",
@@ -1877,6 +1918,7 @@
"first_event_type_webhook_description": "Vytvořte svůj první webhook pro tento typ události",
"install_app_on": "Nainstalovat aplikaci v",
"create_for": "Vytvořit pro",
"currency": "Měna",
"organization_banner_description": "Vytvořte prostředí, ve kterém budou moct vaše týmy vytvářet sdílené aplikace, pracovní postupy a typy událostí s kolektivním plánováním nebo plánováním typu round-robin.",
"organization_banner_title": "Správa organizace s více týmy",
"set_up_your_organization": "Nastavení organizace",
@@ -1937,6 +1979,74 @@
"insights_team_filter": "Tým: {{teamName}}",
"insights_user_filter": "Uživatel: {{userName}}",
"insights_subtitle": "Zobrazte si Insight rezervací napříč vašimi událostmi",
"location_options": "Možná místa konání: {{locationCount}}",
"custom_plan": "Vlastní plán",
"email_embed": "Vložení do e-mailu",
"add_times_to_your_email": "Vyberte několik dostupných časů a vložte je do e-mailu",
"select_time": "Vyberte čas",
"select_date": "Vyberte datum",
"see_all_available_times": "Zobrazit všechny dostupné časy",
"org_team_names_example": "Např. marketingový tým",
"org_team_names_example_1": "Např. marketingový tým",
"org_team_names_example_2": "Např. obchodní tým",
"org_team_names_example_3": "Např. designérský tým",
"org_team_names_example_4": "Např. inženýrský tým",
"org_team_names_example_5": "Např. tým datové analytiky",
"org_max_team_warnings": "Další týmy budete moct přidat později.",
"what_is_this_meeting_about": "Čeho se tato schůzka týká?",
"add_to_team": "Přidat do týmu",
"remove_users_from_org": "Odstranění uživatelů z organizace",
"remove_users_from_org_confirm": "Opravdu chcete odebrat uživatele ({{userCount}}) z této organizace?",
"user_has_no_schedules": "Tento uživatel zatím nenastavil žádné plány",
"user_isnt_in_any_teams": "Tento uživatel není v žádném týmu",
"requires_booker_email_verification": "Povinné ověření e-mailu rezervujícího",
"description_requires_booker_email_verification": "Zajištění ověření e-mailu rezervujícího před plánováním událostí",
"requires_confirmation_mandatory": "SMS zprávy lze účastníkům zasílat pouze v případě, že typ události vyžaduje potvrzení.",
"organizations": "Organizace",
"org_admin_other_teams": "Ostatní týmy",
"org_admin_other_teams_description": "Zde můžete vidět týmy uvnitř vaší organizace, kterých nejste součástí, a případně se do nich můžete přidat.",
"no_other_teams_found": "Nenalezeny žádné další týmy",
"no_other_teams_found_description": "V této organizaci nejsou žádné další týmy.",
"attendee_first_name_variable": "Jméno účastníka",
"attendee_last_name_variable": "Příjmení účastníka",
"attendee_first_name_info": "Jméno rezervující osoby",
"attendee_last_name_info": "Příjmení rezervující osoby",
"me": "Já",
"verify_team_tooltip": "Proveďte ověření svého týmu a zapněte odesílání zpráv účastníkům",
"member_removed": "Člen odstraněn",
"my_availability": "Moje dostupnost",
"team_availability": "Dostupnost týmu",
"backup_code": "Záložní kód",
"backup_codes": "Záložní kódy",
"backup_code_instructions": "Každý záložní kód lze použít právě jednou k udělení přístupu bez vašeho způsobu ověřování.",
"backup_codes_copied": "Záložní kódy zkopírovány!",
"incorrect_backup_code": "Záložní kód není správný.",
"lost_access": "Ztracený přístup",
"missing_backup_codes": "Nenalezeny žádné záložní kódy. Vygenerujte je v nastavení.",
"admin_org_notification_email_subject": "Vytvořena nová organizace: čekající akce",
"hi_admin": "Dobrý den, administrátore",
"admin_org_notification_email_title": "Organizace vyžaduje nastavení DNS",
"admin_org_notification_email_body_part1": "Byla vytvořena organizace se slugem „{{orgSlug}}“.<br /><br />Zkontrolujte prosím, zda máte nakonfigurovaný registr DNS tak, aby subdoména odpovídající nové organizaci směřovala na místo, kde je spuštěna hlavní aplikace. Jinak organizace nebude fungovat.<br /><br />Tady jsou jen zcela základní možnosti, jak nakonfigurovat subdoménu tak, aby směřovala na příslušnou aplikaci a načítala profilovou stránku organizace.<br /><br />Můžete to udělat buď pomocí záznamu typu A:",
"admin_org_notification_email_body_part2": "nebo záznamu typu CNAME:",
"admin_org_notification_email_body_part3": "Až subdoménu nakonfigurujete, označte konfiguraci DNS jako provedenou v nastavení správce organizací.",
"admin_org_notification_email_cta": "Přejděte do nastavení správce organizací",
"org_has_been_processed": "Organizace byla zpracována",
"org_error_processing": "Při zpracování této organizace došlo k chybě",
"orgs_page_description": "Seznam všech organizací. Přijetí organizace umožní všem uživatelům s touto e-mailovou doménou registraci BEZ ověření e-mailu.",
"unverified": "Neověřeno",
"dns_missing": "Chybí DNS",
"mark_dns_configured": "Označit jako s nakonfigurovaným DNS",
"value": "Hodnota",
"your_organization_updated_sucessfully": "Vaše organizace byla aktualizována",
"team_no_event_types": "Tento tým nemá žádné typy událostí",
"seat_options_doesnt_multiple_durations": "Volba místa nepodporuje více dob trvání",
"include_calendar_event": "Zahrnout událost kalendáře",
"recently_added": "Nedávno přidáno",
"no_members_found": "Nenalezeni žádní členové",
"event_setup_length_error": "Nastavení události: Doba trvání musí být alespoň 1 minuta.",
"availability_schedules": "Plány dostupnosti",
"view_only_edit_availability_not_onboarded": "Tento uživatel ještě nedokončil onboarding. Dokud nedokončí onboarding, dostupnost nebude možné nastavit.",
"view_only_edit_availability": "Právě máte zobrazenou dostupnost tohoto uživatele. Upravovat lze pouze vlastní dostupnost.",
"edit_users_availability": "Upravte dostupnost uživatele: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -703,7 +703,7 @@
"slot_interval_default": "Brug begivenhedslængde (standard)",
"delete_account": "Slet konto",
"confirm_delete_account": "Ja, slet konto",
"delete_account_confirmation_message": "Er du sikker på, at du vil slette din {{appName}} konto? Alle, som du har delt dit konto-link med, vil ikke længere kunne booke ved hjælp af det, og eventuelle præferencer du har gemt, vil gå tabt.",
"delete_account_confirmation_message": "Alle, som du har delt dit konto-link med, vil ikke længere kunne booke ved hjælp af det, og eventuelle præferencer du har gemt, vil gå tabt.",
"integrations": "Integrationer",
"apps": "Apps",
"apps_description": "Her kan du finde en liste over dine apps",
+69 -6
View File
@@ -257,6 +257,7 @@
"available_apps_lower_case": "Verfügbare Apps",
"available_apps_desc": "Sie haben keine Apps installiert. Sehen Sie sich beliebte Apps unten an und entdecken Sie noch mehr in unserem <1>App Store</1>",
"fixed_host_helper": "Füge jeden hinzu, der an der Veranstaltung teilnehmen muss. <1>Mehr erfahren</1>",
"round_robin_helper": "Die Leute in der Gruppe wechseln sich ab und nur eine Person wird für die Veranstaltung erscheinen.",
"check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.",
"finish": "Fertig",
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
@@ -265,6 +266,7 @@
"nearly_there_instructions": "Zu guter Letzt: Ein Foto und eine Kurzbeschreibung zu Ihnen helfen, öfter gebucht zu werden, da Kunden dann genauer wissen, bei wem sie einen Termin buchen.",
"set_availability_instructions": "Definieren Sie Zeiträume, wenn Sie wiederkehrend verfügbar sind. Sie können später weitere Zeiträume erstellen und verschiedenen Kalendern zuordnen.",
"set_availability": "Verfügbarkeit festlegen",
"availability_settings": "Verfügbarkeitseinstellungen",
"continue_without_calendar": "Ohne Kalender fortfahren",
"connect_your_calendar": "Kalender verbinden",
"connect_your_video_app": "Verbinden Sie Ihre Video-Apps",
@@ -289,9 +291,9 @@
"add_another_calendar": "Einen weiteren Kalender hinzufügen",
"other": "Sonstige",
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
"emailed_you_and_attendees": "Wir haben Ihnen und den anderen Teilnehmern eine Einladung zum Kalender mit allen Details zugeschickt.",
"emailed_you_and_attendees_recurring": "Wir haben Ihnen und den anderen Teilnemern eine Kalendereinladung für den ersten Termin dieser wiederkehrenden Termins gesendet.",
"emailed_you_and_any_other_attendees": "Wir haben Ihnen und allen anderen Teilnehmern diese Informationen per E-Mail zugeschickt.",
"emailed_you_and_attendees": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details an alle gesendet.",
"emailed_you_and_attendees_recurring": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Ereignisse an alle gesendet.",
"emailed_you_and_any_other_attendees": "Wir haben eine E-Mail mit diesen Informationen an alle gesendet.",
"needs_to_be_confirmed_or_rejected": "Ihr Termin muss noch bestätigt oder abgelehnt werden.",
"needs_to_be_confirmed_or_rejected_recurring": "Ihr wiederkehrender Termin muss noch bestätigt oder abgelehnt werden.",
"user_needs_to_confirm_or_reject_booking": "{{user}} muss den Termin noch bestätigen oder ablehnen.",
@@ -589,6 +591,8 @@
"invite_new_member": "Ein neues Mitglied einladen",
"invite_new_member_description": "Hinweis: Dies wird <1>einen zusätzlichen Platz(15 $/m)</1> in Ihrem Abonnement kosten.",
"invite_new_team_member": "Laden Sie jemanden in Ihr Team ein.",
"upload_csv_file": "Eine .csv-Datei hochladen",
"invite_via_email": "Per E-Mail einladen",
"change_member_role": "Rolle des Teammitglieds ändern",
"disable_cal_branding": "{{appName}}-Branding deaktivieren",
"disable_cal_branding_description": "Blendet alle {{appName}}-Werbung auf Ihren öffentlichen Seiten aus.",
@@ -822,7 +826,7 @@
"confirm_delete_event_type": "Ja, löschen",
"delete_account": "Account löschen",
"confirm_delete_account": "Ja, Account löschen",
"delete_account_confirmation_message": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten? Sie können dann nicht mehr über Ihre geteilten Links gebucht werden und alle Einstellungen werden gelöscht.",
"delete_account_confirmation_message": "Sie können dann nicht mehr über Ihre geteilten Links gebucht werden und alle Einstellungen werden gelöscht.",
"integrations": "Integrationen",
"apps": "Apps",
"apps_description": "Hier finden Sie eine Liste Ihrer Apps",
@@ -871,6 +875,7 @@
"account_managed_by_identity_provider_description": "Um Ihre E-Mail-Adresse, Ihr Passwort, die Zwei-Faktor-Authentifizierung und sonstiges zu ändern, besuchen Sie bitte Ihre {{provider}} -Kontoeinstellungen.",
"signin_with_google": "Mit Google anmelden",
"signin_with_saml": "Anmelden mit SAML",
"signin_with_saml_oidc": "Anmelden mit SAML/OIDC",
"you_will_need_to_generate": "Sie müssen ein Zugriffstoken in ihrem alten Kalender-Tool erzeugen.",
"import": "Importieren",
"import_from": "Importieren von",
@@ -970,6 +975,8 @@
"offer_seats_description": "Bieten Sie Plätze für Buchungen an (dies deaktiviert Gäste & Opt-in-Buchungen)",
"seats_available_one": "Verfügbarer Platz",
"seats_available_other": "Verfügbare Plätze",
"seats_nearly_full": "Plätze fast voll",
"seats_half_full": "Plätze werden schnell belegt",
"number_of_seats": "Anzahl der Plätze pro Termin",
"enter_number_of_seats": "Anzahl der Plätze eingeben",
"you_can_manage_your_schedules": "Sie können Ihren Verfügbarkeitsplan bei Ihren Verfügbarkeiten ändern.",
@@ -1050,12 +1057,15 @@
"how_you_want_add_cal_site": "Wie möchten Sie {{appName}} zu Ihrer Website hinzufügen?",
"choose_ways_put_cal_site": "Wählen Sie eine der folgenden Möglichkeiten, um {{appName}} in Ihre Website zu integrieren.",
"setting_up_zapier": "Einrichtung Ihrer Zapier-Integration",
"setting_up_make": "Einrichtung Ihrer Make-Integration",
"generate_api_key": "Api Key generieren",
"generate_api_key_description": "Generieren Sie einen API-Schlüssel für {{appName}} unter",
"your_unique_api_key": "Ihr eindeutiger API-Key",
"copy_safe_api_key": "Kopieren Sie diesen API-Schlüssel und speichern Sie ihn an einem sicheren Ort. Wenn Sie diesen Schlüssel verlieren, müssen Sie einen neuen generieren.",
"zapier_setup_instructions": "<0>Loggen Sie sich in Ihr Zapier-Konto ein und erstelle einen neuen Zap.</0><1>Wählen Sie Cal.com als ihre Trigger-App aus. Wählen Sie auch ein Auslöserereignis.</0><1>Wählen Sie ihr Konto aus und geben dann ihren eindeutigen API-Key ein.</2><3>Testen Sie den Trigger.</3><4>Alles fertig!</4>",
"make_setup_instructions": "<0>Gehen Sie zu: <1><0>Make-Einladungs-Link</0></1>und installieren Sie die Cal.com-App.</0><1> Loggen Sie sich in Ihr Make-Konto ein und erstellen Sie ein neues Szenario.</1><2>Wählen Sie Cal.com als ihre Auslöser-App aus. Wählen Sie auch ein Auslöserereignis aus.</2><3>Wählen Sie ihr Konto aus und geben Sie dann Ihren eindeutigen API-Key ein.</3><4>Probierne Sie den Auslöser aus.</4><5>Und fertig!</5>",
"install_zapier_app": "Bitte installieren Sie zuerst die Zapier-App im App Store.",
"install_make_app": "Bitte installieren Sie zuerst die Make-App im App Store.",
"connect_apple_server": "Mit Apple-Server verbinden",
"calendar_url": "Kalender-URL",
"apple_server_generate_password": "Generieren Sie ein App-spezifisches Passwort für {{appName}} unter",
@@ -1093,7 +1103,7 @@
"reschedule_optional": "Grund für die Verschiebung (optional)",
"reschedule_placeholder": "Lassen Sie andere wissen, warum Sie den Termin verschieben müssen",
"event_cancelled": "Dieser Termin ist abgesagt",
"emailed_information_about_cancelled_event": "Wir haben Ihnen und den anderen Teilnehmern eine E-Mail gesendet, damit alle Bescheid wissen.",
"emailed_information_about_cancelled_event": "Wir haben eine E-Mail an alle gesendet, um sie darüber zu informieren.",
"this_input_will_shown_booking_this_event": "Diese Eingabe wird bei der Buchung dieses Termins angezeigt",
"meeting_url_in_confirmation_email": "Termin-URL ist in der Bestätigungsmail",
"url_start_with_https": "URL muss mit http:// oder https:// beginnen",
@@ -1105,6 +1115,7 @@
"email_attendee_action": "E-Mail an Teilnehmende senden",
"sms_attendee_action": "SMS an Teilnehmende senden",
"sms_number_action": "SMS an eine bestimmte Nummer senden",
"send_reminder_sms": "Senden Sie Meeting-Erinnerungen einfach per SMS an Ihre Teilnehmer",
"whatsapp_number_action": "WhatsApp-Nachricht an eine bestimmte Nummer senden",
"whatsapp_attendee_action": "WhatsApp-Nachricht an Teilnehmer senden",
"workflows": "Workflows",
@@ -1264,6 +1275,7 @@
"error_updating_settings": "Fehler beim Aktualisieren der Einstellungen",
"personal_cal_url": "Meine persönliche {{appName}}-URL",
"bio_hint": "Schreiben Sie eine kurze Beschreibung, welche auf Ihrer persönlichen Profil-Seite erscheinen wird.",
"user_has_no_bio": "Dieser Benutzer hat noch keine Bio hinzugefügt.",
"delete_account_modal_title": "Account löschen",
"confirm_delete_account_modal": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten?",
"delete_my_account": "Meinen Account löschen",
@@ -1446,6 +1458,7 @@
"add_limit": "Begrenzung hinzufügen",
"team_name_required": "Teamname erforderlich",
"show_attendees": "Teilnehmerinformationen zwischen Gästen teilen",
"show_available_seats_count": "Anzahl der verfügbaren Plätze anzeigen",
"how_booking_questions_as_variables": "Wie verwendet man Buchungsfragen als Variablen?",
"format": "Format",
"uppercase_for_letters": "Großbuchstaben für alle Buchstaben verwenden",
@@ -1679,8 +1692,11 @@
"delete_sso_configuration_confirmation_description": "Sind Sie sicher, dass Sie die {{connectionType}}-Konfiguration löschen möchten? Ihre Teammitglieder, die sich über {{connectionType}} anmelden, werden keinen Zugriff mehr auf Cal.com haben.",
"organizer_timezone": "Zeitzone des Veranstalters",
"email_user_cta": "Einladung anzeigen",
"email_no_user_invite_heading_team": "Sie wurden eingeladen, einem {{appName}}-Team beizutreten",
"email_no_user_invite_heading_org": "Sie wurden eingeladen, einer {{appName}}-Organisation beizutreten",
"email_no_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem Team in {{appName}} beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne ständiges hin und her zu planen.",
"email_user_invite_subheading_team": "{{invitedBy}} hat Sie eingeladen, dem Team „{{teamName}}“ beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne Hin und Her zu planen.",
"email_user_invite_subheading_org": "{{invitedBy}} hat Sie eingeladen, ihrer Organisation „{{teamName}}“ in {{appName}} beizutreten. {{appName}} ist der Terminplaner, der es Ihnen und Ihrer Organisation ermöglicht, Meetings ohne ständiges hin und her zu planen.",
"email_no_user_invite_steps_intro": "Wir begleiten Sie durch ein paar Schritte, danach können Sie in kürzester Zeit stressfreie Planung mit Ihrer {{entity}} genießen.",
"email_no_user_step_one": "Wählen Sie Ihren Benutzernamen",
"email_no_user_step_two": "Verbinden Sie Ihr Kalenderkonto",
@@ -1851,6 +1867,8 @@
"insights_no_data_found_for_filter": "Keine Daten für den ausgewählten Filter oder das ausgewählte Datum gefunden.",
"acknowledge_booking_no_show_fee": "Ich bestätige, dass meiner Karte eine Gebühr von {{amount, currency}} berechnet wird, sofern ich nicht an diesem Ereignis teilnehme.",
"card_details": "Karteninformationen",
"something_went_wrong_on_our_end": "Etwas ist auf unserer Seite schief gelaufen. Kontaktieren Sie unser Support-Team, wir werden es sofort für Sie reparieren.",
"please_provide_following_text_to_suppport": "Bitte geben Sie den folgenden Text an, wenn Sie den Support kontaktieren, damit wir Ihnen besser helfen können",
"seats_and_no_show_fee_error": "Aktuell können keine Plätze aktiviert oder eine Gebühr für das Nichterscheinen berechnet werden",
"complete_your_booking": "Schließen Sie Ihre Buchung ab",
"complete_your_booking_subject": "Schließen Sie Ihre Buchung ab: {{title}} am {{date}}",
@@ -1961,6 +1979,7 @@
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "Benutzer: {{userName}}",
"insights_subtitle": "Sehen Sie sich Buchungs-Insights zu Ihren Termine an",
"location_options": "{{locationCount}} Veranstaltungsort-Optionen",
"custom_plan": "Maßgeschneiderter Tarif",
"email_embed": "E-Mail Einbettung",
"add_times_to_your_email": "Zeiten zur E-Mail hinzufügen",
@@ -1976,14 +1995,58 @@
"org_max_team_warnings": "Weitere Teams können Sie zu einem späteren Zeitpunkt hinzufügen.",
"what_is_this_meeting_about": "Worum geht es in diesem Termin?",
"add_to_team": "Zum Team hinzufügen",
"remove_users_from_org": "Benutzer aus der Organisation entfernen",
"remove_users_from_org_confirm": "Sind Sie sicher, dass Sie {{userCount}} Benutzer aus dieser Organisation entfernen möchten?",
"user_has_no_schedules": "Dieser Benutzer hat noch keine Verfügbarkeitspläne eingerichtet",
"user_isnt_in_any_teams": "Dieser Benutzer ist in keinem Team",
"kyc_verification_information": "Aus Sicherheitsgründen müssen Sie Ihren {{teamOrAccount}} verifizieren, bevor Sie Textnachrichten an die Teilnehmer senden können. Bitte kontaktieren Sie uns unter <a>{{supportEmail}}</a> und geben Sie folgende Informationen an:",
"requires_booker_email_verification": "Erfordert E-Mail-Verifizierung des Buchenden",
"description_requires_booker_email_verification": "Um sicherzustellen, dass die E-Mail des Buchenden vor der Terminplanung verifiziert ist",
"requires_confirmation_mandatory": "Textnachrichten können nur an Teilnehmer gesendet werden, wenn der Ereignistyp bestätigt werden muss.",
"organizations": "Organisationen",
"org_admin_other_teams": "Weitere Teams",
"org_admin_other_teams_description": "Hier können Sie Teams innerhalb Ihrer Organisation sehen, zu denen Sie nicht gehören. Sie können sich bei Bedarf selbst hinzufügen.",
"no_other_teams_found": "Keine weiteren Teams gefunden",
"no_other_teams_found_description": "Es gibt keine weiteren Teams in dieser Organisation.",
"attendee_first_name_variable": "Vorname des Teilnehmers",
"attendee_last_name_variable": "Nachname des Teilnehmers",
"attendee_first_name_info": "Vorname der buchenden Person",
"attendee_last_name_info": "Nachname der buchenden Person",
"me": "Ich",
"verify_team_tooltip": "Verifizieren Sie Ihr Team, um das Senden von Nachrichten an Teilnehmer zu aktivieren",
"member_removed": "Mitglied entfernt",
"my_availability": "Meine Verfügbarkeit",
"team_availability": "Teamverfügbarkeit",
"backup_code": "Backup-Code",
"backup_codes": "Backup-Codes",
"backup_code_instructions": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugriff ohne Ihren Authentifikator zu gewähren.",
"backup_codes_copied": "Backup-Codes kopiert!",
"incorrect_backup_code": "Backup-Code ist falsch.",
"lost_access": "Zugriff verloren",
"missing_backup_codes": "Keine Backup-Codes gefunden. Bitte generieren Sie diese in Ihren Einstellungen.",
"admin_org_notification_email_subject": "Neue Organisation erstellt: Ausstehende Aktion",
"hi_admin": "Hallo, Administrator",
"admin_org_notification_email_title": "Eine Organisation benötigt DNS-Konfiguration",
"admin_org_notification_email_body_part1": "Eine Organisation mit slug \"{{orgSlug}}\" wurde erstellt.<br /><br />Bitte konfigurieren Sie Ihre DNS-Registry so, dass die Subdomain der neuen Organisation auf die Hauptanwendung verweist. Andernfalls wird die Organisation nicht funktionieren.<br /><br />Hier sind nur die grundlegenden Optionen, um eine Subdomain so zu konfigurieren, dass sie auf ihre App verweist, so dass sie die Organisationsprofilseite lädt.<br /><br />SIe können es entweder mit dem A Record machen:",
"admin_org_notification_email_body_part2": "Oder der CNAME-Eintrag:",
"admin_org_notification_email_body_part3": "Sobald Sie die Subdomain konfiguriert haben, markieren Sie bitte die DNS-Konfiguration wie in den Organisationen Admin-Einstellungen.",
"admin_org_notification_email_cta": "Zu den Organisationen Admin-Einstellungen wechseln",
"org_has_been_processed": "Org wurde verarbeitet",
"org_error_processing": "Beim Verarbeiten dieser Organisation ist ein Fehler aufgetreten",
"orgs_page_description": "Eine Liste aller Organisationen. Wenn Sie eine Organisation akzeptieren, können sich alle Benutzer mit dieser E-Mail-Domain OHNE E-Mail-Überprüfung registrieren.",
"unverified": "Nicht verifiziert",
"dns_missing": "DNS fehlt",
"mark_dns_configured": "Als DNS konfiguriert kennzeichnen",
"value": "Wert",
"your_organization_updated_sucessfully": "Ihre Organisation wurde erfolgreich aktualisiert",
"team_no_event_types": "Dieses Team hat keine Ereignistypen",
"seat_options_doesnt_multiple_durations": "Platzoption unterstützt mehrere Dauern nicht",
"include_calendar_event": "Kalenderereignis hinzufügen",
"recently_added": "Kürzlich hinzugefügt",
"no_members_found": "Keine Mitglieder gefunden",
"event_setup_length_error": "Ereignis-Einrichtung: Die Dauer muss mindestens 1 Minute betragen.",
"availability_schedules": "Verfügbarkeitspläne",
"view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.",
"view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.",
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+20 -9
View File
@@ -255,7 +255,7 @@
"yours": "Your account",
"available_apps": "Available Apps",
"available_apps_lower_case": "Available apps",
"available_apps_desc": "You have no apps installed. View popular apps below and explore more in our <1>App Store</1>",
"available_apps_desc": "View popular apps below and explore more in our <1>App Store</1>",
"fixed_host_helper": "Add anyone who needs to attend the event. <1>Learn more</1>",
"round_robin_helper":"People in the group take turns and only one person will show up for the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
@@ -288,6 +288,7 @@
"when": "When",
"where": "Where",
"add_to_calendar": "Add to calendar",
"add_to_calendar_description":"Select where to add events when you’re booked.",
"add_another_calendar": "Add another calendar",
"other": "Other",
"email_sign_in_subject": "Your sign-in link for {{appName}}",
@@ -599,6 +600,7 @@
"hide_book_a_team_member": "Hide Book a Team Member Button",
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
"danger_zone": "Danger zone",
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
@@ -688,6 +690,7 @@
"people": "People",
"your_email": "Your Email",
"change_avatar": "Change Avatar",
"upload_avatar": "Upload Avatar",
"language": "Language",
"timezone": "Timezone",
"first_day_of_week": "First Day of Week",
@@ -826,7 +829,7 @@
"confirm_delete_event_type": "Yes, delete",
"delete_account": "Delete account",
"confirm_delete_account": "Yes, delete account",
"delete_account_confirmation_message": "Are you sure you want to delete your {{appName}} account? Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
"delete_account_confirmation_message": "Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
"integrations": "Integrations",
"apps": "Apps",
"apps_description": "Here you can find a list of your apps",
@@ -1276,6 +1279,7 @@
"personal_cal_url": "My personal {{appName}} URL",
"bio_hint": "A few sentences about yourself. this will appear on your personal url page.",
"user_has_no_bio": "This user has not added a bio yet.",
"bio":"Bio",
"delete_account_modal_title": "Delete Account",
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
"delete_my_account": "Delete my account",
@@ -1292,7 +1296,7 @@
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
"pro": "Pro",
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
"profile_picture": "Profile picture",
"profile_picture": "Profile Picture",
"upload": "Upload",
"add_profile_photo": "Add profile photo",
"web3": "Web3",
@@ -1639,6 +1643,7 @@
"minimum_round_robin_hosts_count": "Number of hosts required to attend",
"hosts": "Hosts",
"upgrade_to_enable_feature": "You need to create a team to enable this feature. Click to create a team.",
"orgs_upgrade_to_enable_feature" : "You need to upgrade to our enterprise plan to enable this feature.",
"new_attendee": "New Attendee",
"awaiting_approval": "Awaiting Approval",
"requires_google_calendar": "This app requires a Google Calendar connection",
@@ -1743,7 +1748,8 @@
"show_on_booking_page": "Show on booking page",
"get_started_zapier_templates": "Get started with Zapier templates",
"team_is_unpublished": "{{team}} is unpublished",
"team_is_unpublished_description": "This {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them to publish it.",
"org_is_unpublished_description": "This organization link is currently not available. Please contact the organization owner or ask them to publish it.",
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them to publish it.",
"team_member": "Team member",
"a_routing_form": "A Routing Form",
"form_description_placeholder": "Form Description",
@@ -1877,6 +1883,7 @@
"edit_invite_link": "Edit link settings",
"invite_link_copied": "Invite link copied",
"invite_link_deleted": "Invite link deleted",
"api_key_deleted":"API Key deleted",
"invite_link_updated": "Invite link settings saved",
"link_expires_after": "Links set to expire after...",
"one_day": "1 day",
@@ -2002,11 +2009,6 @@
"requires_booker_email_verification": "Requires booker email verification",
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
"kyc_verification_information": "To ensure security, you have to verify your {{teamOrAccount}} before sending text messages to attendees. Please contact us at <a>{{supportEmail}}</a> and provide the following information:",
"kyc_verification_documents": "<ul><li>Your {{teamOrUser}}</li><li>For businesses: Attach your Business Verification Documentation</li><li>For individuals: Attach a government-issued ID</li></ul>",
"verify_team_or_account": "Verify {{teamOrAccount}}",
"verify_account": "Verify Account",
"kyc_verification": "KYC Verification",
"organizations": "Organizations",
"org_admin_other_teams": "Other teams",
"org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.",
@@ -2016,7 +2018,13 @@
"attendee_last_name_variable": "Attendee last name",
"attendee_first_name_info": "The person booking's first name",
"attendee_last_name_info": "The person booking's last name",
"your_monthly_digest": "Your Monthly Digest",
"member_name": "Member Name",
"most_popular_events": "Most Popular Events",
"summary_of_events_for_your_team_for_the_last_30_days": "Here's your summary of popular events for your team {{teamName}} for the last 30 days",
"me": "Me",
"monthly_digest_email":"Monthly Digest Email",
"monthly_digest_email_for_teams": "Monthly digest email for teams",
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
"member_removed": "Member removed",
"my_availability": "My Availability",
@@ -2053,5 +2061,8 @@
"view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
"view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.",
"edit_users_availability":"Edit user's availability: {{username}}",
"resend_invitation": "Resend invitation",
"invitation_resent": "The invitation was resent.",
"this_app_is_not_setup_already": "This app has not been setup yet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+111 -1
View File
@@ -17,9 +17,15 @@
"verify_email_banner_body": "Verifique su dirección de correo electrónico para garantizar la mejor entrega de correo electrónico y calendario",
"verify_email_email_header": "Verifique su dirección de correo electrónico",
"verify_email_email_button": "Verificar correo electrónico",
"copy_somewhere_safe": "Guarde esta clave API en algún lugar seguro. No podrá volver a verla.",
"verify_email_email_body": "Verifique su dirección de correo electrónico al hacer clic en el botón a continuación.",
"verify_email_by_code_email_body": "Verifique su dirección de correo electrónico al hacer clic en el botón a continuación.",
"verify_email_email_link_text": "Este es el enlace en caso de que no prefiera hacer clic en los botones:",
"email_verification_code": "Ingrese el código de verificación",
"email_verification_code_placeholder": "Ingrese el código de verificación enviado a su correo",
"incorrect_email_verification_code": "El código de verificación es incorrecto.",
"email_sent": "Correo electrónico enviado correctamente",
"email_not_sent": "Se produjo un error al enviar el correo electrónico",
"event_declined_subject": "Rechazado: {{title}} en {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} el {{date}}",
"event_request_declined": "Su solicitud de reunión ha sido rechazada",
@@ -78,6 +84,7 @@
"event_awaiting_approval_recurring": "Un nuevo evento está esperando su aprobación",
"someone_requested_an_event": "Alguien ha solicitado programar un evento en su calendario.",
"someone_requested_password_reset": "Alguien ha solicitado un enlace para cambiar su contraseña.",
"password_reset_email_sent": "Si este correo electrónico existe en nuestro sistema, debería recibir un correo electrónico de restablecimiento.",
"password_reset_instructions": "Si usted no solicitó esto, puede ignorar este correo electrónico de forma segura y su contraseña no se cambiará.",
"event_awaiting_approval_subject": "Esperando aprobación: {{title}} en {{date}}",
"event_still_awaiting_approval": "Un evento aún está esperando su aprobación",
@@ -217,6 +224,10 @@
"already_have_an_account": "¿Ya tienes una cuenta?",
"create_account": "Crear Cuenta",
"confirm_password": "Confirmar Contraseña",
"confirm_auth_change": "Esto cambiará la forma de iniciar sesión",
"confirm_auth_email_change": "Cambiar la dirección de correo electrónico desconectará su método de autenticación actual para iniciar sesión en Cal.com. Le pediremos que verifique su nueva dirección de correo electrónico. En el futuro, se cerrará su sesión y utilizará su nueva dirección de correo electrónico para iniciar sesión en lugar de su método de autenticación actual después de configurar su contraseña siguiendo las instrucciones que se enviarán a su correo.",
"reset_your_password": "Establezca su nueva contraseña con las instrucciones enviadas a su dirección de correo electrónico.",
"email_change": "Vuelva a iniciar sesión con su nueva dirección de correo electrónico y contraseña.",
"create_your_account": "Crea tu Cuenta",
"sign_up": "Regístrate",
"youve_been_logged_out": "Has cerrado la sesión",
@@ -243,6 +254,10 @@
"all": "Todas las aplicaciones",
"yours": "Su cuenta",
"available_apps": "Aplicaciones disponibles",
"available_apps_lower_case": "Aplicaciones disponibles",
"available_apps_desc": "No tiene aplicaciones instaladas. Vea las aplicaciones populares a continuación y explore más en nuestra <1>App Store</1>",
"fixed_host_helper": "Agregue a cualquier persona que necesite asistir al evento. <1>Más información</1>",
"round_robin_helper": "Las personas del grupo se turnan y solo una persona se presentará al evento.",
"check_email_reset_password": "Revisa tu email. Te hemos enviado un enlace para restablecer tu contraseña.",
"finish": "Terminar",
"organization_general_description": "Administre los ajustes para el idioma y la zona horaria de su equipo",
@@ -251,6 +266,7 @@
"nearly_there_instructions": "Lo último, una breve descripción sobre usted y una foto lo ayudan a obtener reservas y a que las personas sepan con quién están reservando.",
"set_availability_instructions": "Defina los intervalos de tiempo cuando esté disponible de forma recurrente. Puede crear más tarde y asignarlos a diferentes calendarios.",
"set_availability": "Establecer Disponibilidad",
"availability_settings": "Configuración de disponibilidad",
"continue_without_calendar": "Continuar sin calendario",
"connect_your_calendar": "Conecta tu calendario",
"connect_your_video_app": "Conecte sus aplicaciones favoritas",
@@ -392,6 +408,8 @@
"allow_dynamic_booking_tooltip": "Los enlaces de reservas grupales se pueden crear dinámicamente añadiendo varios nombres de usuario con un '+'. Ejemplo: '{{appName}}/bailey+peer'",
"allow_dynamic_booking": "Permitir que los asistentes lo reserven a través de reservas dinámicas de grupo",
"dynamic_booking": "Enlaces dinámicos de grupo",
"allow_seo_indexing": "Permita que los motores de búsqueda accedan a su contenido público",
"seo_indexing": "Permitir indexación SEO",
"email": "Correo electrónico",
"email_placeholder": "nombre@ejemplo.com",
"full_name": "Nombre Completo",
@@ -406,6 +424,7 @@
"booking_requested": "Reserva solicitada",
"meeting_ended": "Reunión finalizada",
"form_submitted": "Formulario enviado",
"booking_paid": "Reserva pagada",
"event_triggers": "Activadores de Eventos",
"subscriber_url": "URL del suscriptor",
"create_new_webhook": "Crear un nuevo Webhook",
@@ -506,6 +525,7 @@
"your_name": "Tu Nombre",
"your_full_name": "Su nombre completo",
"no_name": "Sin nombre",
"enter_number_between_range": "Introduzca un número entre 1 y {{maxOccurences}}",
"email_address": "Email",
"enter_valid_email": "Ingresa un correo electrónico válido",
"location": "Ubicación",
@@ -543,6 +563,7 @@
"leave": "Abandonar",
"profile": "Perfil",
"my_team_url": "URL de Mi Equipo",
"my_teams": "Mis equipos",
"team_name": "Nombre del equipo",
"your_team_name": "Nombre de tu Equipo",
"team_updated_successfully": "Equipo Actualizado Correctamente",
@@ -570,6 +591,8 @@
"invite_new_member": "Invitar a un nuevo miembro",
"invite_new_member_description": "Nota: Esto costará <1>una plaza adicional ($15/m)</1> en su suscripción.",
"invite_new_team_member": "Invita a alguien a tu equipo.",
"upload_csv_file": "Cargar un archivo .csv",
"invite_via_email": "Invitar por correo electrónico",
"change_member_role": "Cambiar rol del miembro del equipo",
"disable_cal_branding": "Desactivar la marca de {{appName}}",
"disable_cal_branding_description": "Ocultar todas las marcas de {{appName}} de sus páginas públicas.",
@@ -803,7 +826,7 @@
"confirm_delete_event_type": "Sí, eliminar",
"delete_account": "Eliminar cuenta",
"confirm_delete_account": "Sí, eliminar cuenta",
"delete_account_confirmation_message": "¿Estás seguro de que quieres eliminar tu cuenta de {{appName}}? Cualquier persona con la que hayas compartido el enlace de tu cuenta ya no podrá agendar con ella y cualquier preferencia que hayas guardado se perderá.",
"delete_account_confirmation_message": "Cualquier persona con la que hayas compartido el enlace de tu cuenta ya no podrá agendar con ella y cualquier preferencia que hayas guardado se perderá.",
"integrations": "Integraciones",
"apps": "Aplicaciones",
"apps_description": "Aquí puede encontrar una lista de sus aplicaciones",
@@ -842,6 +865,7 @@
"team_view_user_availability_disabled": "El usuario necesita aceptar la invitación para ver su disponibilidad",
"set_as_away": "Activar estado Ausente",
"set_as_free": "Desactivar estado Ausente",
"toggle_away_error": "Error al actualizar el estado ausente",
"user_away": "Este usuario se encuentra Ausente.",
"user_away_description": "La persona a la que estás intentando reservar se ha marcado a sí misma como Ausente, por lo tanto, no acepta nuevas reservas por ahora.",
"meet_people_with_the_same_tokens": "Conoce personas con los mismos tokens",
@@ -851,6 +875,7 @@
"account_managed_by_identity_provider_description": "Para cambiar tu correo electrónico, contraseña, habilitar la autenticación de doble factor y más, por favor visita la configuración de tu cuenta de {{provider}}.",
"signin_with_google": "Iniciar sesión con Google",
"signin_with_saml": "Iniciar sesión con SAML",
"signin_with_saml_oidc": "Inicie sesión con SAML/OIDC",
"you_will_need_to_generate": "Necesitarás generar un token de acceso desde tu antigua herramienta de programación.",
"import": "Importar",
"import_from": "Importar desde",
@@ -950,6 +975,8 @@
"offer_seats_description": "Ofrecer plazas para las reservas (desactiva las reservas de invitados y las optativas)",
"seats_available_one": "Plazas disponibles",
"seats_available_other": "Plazas disponibles",
"seats_nearly_full": "Cupos casi llenos",
"seats_half_full": "Los cupos se están copando rápido",
"number_of_seats": "Número de plazas por reserva",
"enter_number_of_seats": "Introduzca el número de plazas",
"you_can_manage_your_schedules": "Puede administrar sus horarios en la página Disponibilidad.",
@@ -1030,12 +1057,15 @@
"how_you_want_add_cal_site": "¿Cómo quiere añadir {{appName}} a su sitio?",
"choose_ways_put_cal_site": "Elija una de las siguientes maneras de poner {{appName}} en su sitio.",
"setting_up_zapier": "Configurando su integración con Zapier",
"setting_up_make": "Configurando su integración Make",
"generate_api_key": "Generar clave API",
"generate_api_key_description": "Generar una clave API para usar con {{appName}} en",
"your_unique_api_key": "Su clave API única",
"copy_safe_api_key": "Copie esta clave API y guárdela en algún lugar seguro. Si pierde esta clave tendrá que generar una nueva.",
"zapier_setup_instructions": "<0>Inicie sesión en su cuenta de Zapier y cree un nuevo Zap.</0><1>Seleccione Cal.com cómo su aplicación Trigger. También elija un evento Trigger.</1><2>Seleccione su cuenta e ingrese su Clave API única.</2><3>Pruebe su Trigger.</3><4>¡Listo!</4>",
"make_setup_instructions": "<0>Vaya al <1><0>Enlace de invitación de Make</0></1> e instale la aplicación Cal.com.</0><1>Inicie sesión en su cuenta de Make y cree un nuevo escenario.</1><2>Seleccione Cal.com como su aplicación de activación. Elija también un evento de activación.</2><3>Elija su cuenta y luego ingrese su clave API única.</3><4>Pruebe su activador.</4><5>¡Ya está!</5 >",
"install_zapier_app": "Instale primero la aplicación Zapier desde la App Store.",
"install_make_app": "Instale primero la aplicación Make desde la App Store.",
"connect_apple_server": "Conectar con Apple Server",
"calendar_url": "URL del calendario",
"apple_server_generate_password": "Genere una contraseña específica de la aplicación para utilizarla con {{appName}} en",
@@ -1085,6 +1115,7 @@
"email_attendee_action": "enviar correo electrónico a los asistentes",
"sms_attendee_action": "Enviar un SMS al asistente",
"sms_number_action": "enviar SMS a un número específico",
"send_reminder_sms": "Envíe fácilmente recordatorios de reuniones por SMS a sus asistentes",
"whatsapp_number_action": "enviar un mensaje de Whatsapp a un número específico",
"whatsapp_attendee_action": "enviar un mensae de Whatsapp al asistente",
"workflows": "Flujos de trabajo",
@@ -1244,6 +1275,7 @@
"error_updating_settings": "Error al actualizar la configuración",
"personal_cal_url": "Mi URL personal de {{appName}}",
"bio_hint": "Algunas frases sobre usted, esta información aparecerá en la página de su URL personal.",
"user_has_no_bio": "Este usuario no ha añadido una biografía todavía.",
"delete_account_modal_title": "Eliminar cuenta",
"confirm_delete_account_modal": "¿Está seguro que desea eliminar su cuenta de {{appName}}?",
"delete_my_account": "Eliminar mi cuenta",
@@ -1426,6 +1458,7 @@
"add_limit": "Agregue un límite",
"team_name_required": "Nombre de equipo requerido",
"show_attendees": "Comparta información de los asistentes entre los invitados",
"show_available_seats_count": "Mostrar el número de cupos disponibles",
"how_booking_questions_as_variables": "¿Cómo utilizar las preguntas de reserva como variables?",
"format": "Formato",
"uppercase_for_letters": "Use mayúsculas para todas las letras",
@@ -1659,8 +1692,11 @@
"delete_sso_configuration_confirmation_description": "¿Está seguro de que desea eliminar la configuración de {{connectionType}}? Los miembros de su equipo que utilicen el inicio de sesión {{connectionType}} ya no podrán acceder a Cal.com.",
"organizer_timezone": "Zona horaria del organizador",
"email_user_cta": "Ver la invitación",
"email_no_user_invite_heading_team": "Lo han invitado a unirse a un equipo en {{appName}}",
"email_no_user_invite_heading_org": "Lo han invitado a unirse a una organización en {{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} lo invitó a unirse a su equipo en {{appName}}. {{appName}} es el programador de eventos que les permite a usted y a su equipo programar reuniones sin necesidad de enviar correos electrónicos.",
"email_user_invite_subheading_team": "{{invitedBy}} lo ha invitado a unirse a su equipo \"{{teamName}}\" en {{appName}}. {{appName}} es el planificador de eventos que le permite a usted y a su equipo programar reuniones sin correos electrónicos de ida y vuelta.",
"email_user_invite_subheading_org": "{{invitedBy}} lo ha invitado a unirse a su organización \"{{teamName}}\" en {{appName}}. {{appName}} es el programador de eventos que le permite a usted y a su organización programar reuniones sin el tenis del correo electrónico.",
"email_no_user_invite_steps_intro": "Lo guiaremos a través de unos pocos pasos y disfrutará de una programación sin estrés con su {{entity}} en muy poco tiempo.",
"email_no_user_step_one": "Elija su nombre de usuario",
"email_no_user_step_two": "Conecte su cuenta de calendario",
@@ -1831,6 +1867,8 @@
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro seleccionado o las fechas seleccionadas.",
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará a mi tarjeta una tarifa de {{amount, currency}} por no presentarme.",
"card_details": "Detalles de la tarjeta",
"something_went_wrong_on_our_end": "Algo salió mal por nuestra parte. Póngase en contacto con nuestro equipo de soporte y lo solucionaremos de inmediato.",
"please_provide_following_text_to_suppport": "Proporcione el siguiente texto cuando se comunique con el soporte para ayudarlo mejor",
"seats_and_no_show_fee_error": "Actualmente, no se pueden habilitar cupos ni cobrar una tarifa por no presentarse",
"complete_your_booking": "Complete su reserva",
"complete_your_booking_subject": "Complete su reserva: {{title}} el {{date}}",
@@ -1864,6 +1902,9 @@
"open_dialog_with_element_click": "Abre su cuadro de diálogo Cal cuando alguien hace clic en un elemento.",
"need_help_embedding": "¿Necesita ayuda? Consulte nuestras guías para incrustar Cal en Wix, Squarespace o WordPress, consulte nuestras preguntas comunes o explore las opciones avanzadas de incrustación.",
"book_my_cal": "Reservar mi Cal",
"first_name": "Nombre",
"last_name": "Nombre",
"first_last_name": "Nombre, Apellido",
"invite_as": "Invitar como",
"form_updated_successfully": "Formulario actualizado con éxito.",
"disable_attendees_confirmation_emails": "Desactivar los correos electrónicos de confirmación predeterminados para los asistentes",
@@ -1877,6 +1918,7 @@
"first_event_type_webhook_description": "Cree su primer webhook para este tipo de evento",
"install_app_on": "Instalar aplicación en",
"create_for": "Crear para",
"currency": "Moneda",
"organization_banner_description": "Cree entornos en los que sus equipos puedan crear aplicaciones, flujos de trabajo y tipos de eventos compartidos con programación por turnos y colectiva.",
"organization_banner_title": "Gestione organizaciones con varios equipos",
"set_up_your_organization": "Configure su organización",
@@ -1937,6 +1979,74 @@
"insights_team_filter": "Equipo: {{teamName}}",
"insights_user_filter": "Usuario: {{userName}}",
"insights_subtitle": "Mire información de insights en todos sus eventos",
"location_options": "{{locationCount}} opciones de ubicación",
"custom_plan": "Plan personalizado",
"email_embed": "Incrustado en el correo electrónico",
"add_times_to_your_email": "Seleccione algunos horarios disponibles e incrústelos en su correo electrónico",
"select_time": "Seleccione la hora",
"select_date": "Seleccione la fecha",
"see_all_available_times": "Ver todas las horas disponibles",
"org_team_names_example": "ej. Equipo de marketing",
"org_team_names_example_1": "ej. Equipo de marketing",
"org_team_names_example_2": "ej. Equipo de ventas",
"org_team_names_example_3": "ej. Equipo de diseño",
"org_team_names_example_4": "ej. Equipo de ingeniería",
"org_team_names_example_5": "ej. Equipo de análisis de datos",
"org_max_team_warnings": "Más adelante podrá añadir más equipos.",
"what_is_this_meeting_about": "¿De qué trata esta reunión?",
"add_to_team": "Agregar al equipo",
"remove_users_from_org": "Eliminar usuarios de la organización",
"remove_users_from_org_confirm": "¿Está seguro de que desea eliminar {{userCount}} usuarios de esta organización?",
"user_has_no_schedules": "Este usuario no ha configurado ningún horario todavía",
"user_isnt_in_any_teams": "Este usuario no está en ningún equipo",
"requires_booker_email_verification": "Requiere verificación por correo electrónico de quien reserva",
"description_requires_booker_email_verification": "Para asegurar la verificación del correo electrónico de quien reserva antes de programar eventos",
"requires_confirmation_mandatory": "Los mensajes de texto solo se pueden enviar a los asistentes cuando el tipo de evento requiere confirmación.",
"organizations": "Organizaciones",
"org_admin_other_teams": "Otros equipos",
"org_admin_other_teams_description": "Aquí puede ver los equipos dentro de su organización de los que no forma parte. Puede agregarse a ellos si es necesario.",
"no_other_teams_found": "No se encontraron otros equipos",
"no_other_teams_found_description": "No hay otros equipos en esta organización.",
"attendee_first_name_variable": "Nombre del asistente",
"attendee_last_name_variable": "Apellido del asistente",
"attendee_first_name_info": "Nombre de la persona que reserva",
"attendee_last_name_info": "Apellido de la persona que reserva",
"me": "Yo",
"verify_team_tooltip": "Verifique su equipo para activar el envío de mensajes a los asistentes",
"member_removed": "Miembro eliminado",
"my_availability": "Mi disponibilidad",
"team_availability": "Disponibilidad del equipo",
"backup_code": "Código de respaldo",
"backup_codes": "Códigos de respaldo",
"backup_code_instructions": "Cada código de respaldo se puede utilizar exactamente una vez para conceder acceso sin su autenticador.",
"backup_codes_copied": "¡Códigos de respaldo copiados!",
"incorrect_backup_code": "El código de respaldo no es correcto.",
"lost_access": "Acceso perdido",
"missing_backup_codes": "No se encontraron códigos de respaldo. Genere los códigos en su configuración.",
"admin_org_notification_email_subject": "Nueva organización creada: acción pendiente",
"hi_admin": "Hola Administrador",
"admin_org_notification_email_title": "Una organización requiere la configuración DNS",
"admin_org_notification_email_body_part1": "Se creó una organización con slug \"{{orgSlug}}\".<br /><br />Asegúrese de configurar su registro DNS para que apunte el subdominio correspondiente a la nueva organización hacia donde se ejecuta la aplicación principal. De lo contrario, la organización no funcionará.<br /><br />Estas son solo las opciones muy básicas para configurar un subdominio para que apunte a su aplicación para que cargue la página de perfil de la organización.<br /><br />Puede hacerlo ya sea con el Registro A:",
"admin_org_notification_email_body_part2": "O el registro CNAME:",
"admin_org_notification_email_body_part3": "Una vez que configure el subdominio, marque la configuración DNS como se hace en la Configuración de administrador.",
"admin_org_notification_email_cta": "Ir a la Configuración del administrador de organizaciones",
"org_has_been_processed": "Se procesó la organización",
"org_error_processing": "Ocurrió un error al procesar esta organización",
"orgs_page_description": "Una lista de todas las organizaciones. Aceptar una organización permitirá que todos los usuarios con ese dominio de correo electrónico se registren SIN verificación de correo electrónico.",
"unverified": "Sin verificar",
"dns_missing": "Falta el DNS",
"mark_dns_configured": "Marcar como DNS configurado",
"value": "Valor",
"your_organization_updated_sucessfully": "Su organization se actualizó con éxito",
"team_no_event_types": "Este equipo no tiene tipos de eventos",
"seat_options_doesnt_multiple_durations": "La opción Cupo no soporta múltiples duraciones",
"include_calendar_event": "Incluir evento del calendario",
"recently_added": "Añadido recientemente",
"no_members_found": "No se encontraron miembros",
"event_setup_length_error": "Configuración del evento: la duración debe ser de al menos 1 minuto.",
"availability_schedules": "Horarios de disponibilidad",
"view_only_edit_availability_not_onboarded": "Este usuario no ha completado la incorporación. No podrá establecer su disponibilidad hasta que haya completado la incorporación.",
"view_only_edit_availability": "Está viendo la disponibilidad de este usuario. Sólo puede editar su propia disponibilidad.",
"edit_users_availability": "Editar disponibilidad del usuario: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+10 -8
View File
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Oui, supprimer",
"delete_account": "Supprimer le compte",
"confirm_delete_account": "Oui, supprimer le compte",
"delete_account_confirmation_message": "Voulez-vous vraiment supprimer votre compte {{appName}} ? Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez sauvegardées seront perdues.",
"delete_account_confirmation_message": "Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez sauvegardées seront perdues.",
"integrations": "Intégrations",
"apps": "Applications",
"apps_description": "Vous trouverez ici une liste de vos applications.",
@@ -1743,7 +1743,8 @@
"show_on_booking_page": "Afficher sur la page de réservation",
"get_started_zapier_templates": "Démarrer avec les modèles Zapier",
"team_is_unpublished": "{{team}} n'est pas publiée",
"team_is_unpublished_description": "Ce lien d'{{entity}} n'est actuellement pas disponible. Veuillez contacter le propriétaire de l'{{entity}} ou lui demander de le publier.",
"org_is_unpublished_description": "Ce lien d'organisation n'est actuellement pas disponible. Veuillez contacter le propriétaire de l'organisation ou lui demander de le publier.",
"team_is_unpublished_description": "Ce lien d'équipe n'est actuellement pas disponible. Veuillez contacter le propriétaire de l'équipe ou lui demander de le publier.",
"team_member": "Membre d'équipe",
"a_routing_form": "Un formulaire de routage",
"form_description_placeholder": "Description du formulaire",
@@ -2002,11 +2003,6 @@
"requires_booker_email_verification": "Nécessite la vérification par e-mail du participant",
"description_requires_booker_email_verification": "Pour assurer la vérification par e-mail du participant avant la planification des événements.",
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
"verify_team_or_account": "Vérifier {{teamOrAccount}}",
"verify_account": "Vérifier le compte",
"kyc_verification": "Vérification KYC",
"organizations": "Organisations",
"org_admin_other_teams": "Autres équipes",
"org_admin_other_teams_description": "Ici, vous pouvez voir les équipes au sein de votre organisation dont vous ne faites pas partie. Vous pouvez vous y ajouter si nécessaire.",
@@ -2016,7 +2012,13 @@
"attendee_last_name_variable": "Nom du participant",
"attendee_first_name_info": "Le prénom du participant",
"attendee_last_name_info": "Le nom de famille du participant",
"your_monthly_digest": "Votre résumé mensuel",
"member_name": "Nom du membre",
"most_popular_events": "Événements les plus populaires",
"summary_of_events_for_your_team_for_the_last_30_days": "Voici votre résumé des événements populaires pour votre équipe {{teamName}} au cours des 30 derniers jours",
"me": "Moi",
"monthly_digest_email": "E-mail de résumé mensuel",
"monthly_digest_email_for_teams": "E-mail de résumé mensuel pour les équipes",
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
"member_removed": "Membre supprimé",
"my_availability": "Mes disponibilités",
@@ -2046,7 +2048,7 @@
"team_no_event_types": "Cette équipe n'a aucun type d'événement",
"seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples",
"include_calendar_event": "Inclure l'événement du calendrier",
"recently_added": "Ajouté récemment",
"recently_added": "Ajoutées récemment",
"no_members_found": "Aucun membre trouvé",
"event_setup_length_error": "Configuration de l'événement : la durée doit être d'au moins 1 minute.",
"availability_schedules": "Horaires de disponibilité",

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