Compare commits

...
134 Commits
Author SHA1 Message Date
Alex van Andel 0910f65b87 v3.5.2
release.yml / release (push) Canceled after 0s
2023-11-23 16:56:52 +00:00
Alex van Andelandsean-brydon 343f8ee303 Avatar write and unset, ensure no bad behaviour (#12504)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-11-23 15:39:50 +00:00
Hariom Balhara de479bb2da fix: getting-started crash and build failure (#12506) 2023-11-22 22:41:20 -07:00
Omar López 5dc3065a47 v3.5.1
release.yml / release (push) Canceled after 0s
2023-11-22 15:42:24 -07:00
Manpreet Singh 36d315343c fix: adds teamId to team events payload (#12417) 2023-11-22 14:54:18 -05:00
zomars 2171a320f5 fix: Locks Stripe version 2023-11-22 12:19:30 -07:00
JA 7ee035c3a3 feat(webhooks): pass webhook secret to testTrigger (#12187) 2023-11-22 14:17:50 -05:00
Greg PabianandOmar López 1de05ebf3d chore: migrate the event-types page to the app directory (#12390)
Co-authored-by: Omar López <zomars@me.com>
2023-11-22 19:17:33 +00:00
zomars 53d7e2ec3f syncpack fix-mismatches 2023-11-22 12:08:30 -07:00
Udit Takkar f65c7e413f fix: default organizer bug in managed event type (#11921) 2023-11-22 12:43:25 -05:00
Joe Au-YeungandAlex van Andel b762f60214 test: Integration Test GCal Primary Calendar (#12011)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-11-22 11:15:47 -03:00
Joe Au-Yeung 2498785c49 chore: Clean Up Delete Credential Selected Calendar Error Message (#12353) 2023-11-22 10:22:03 -03:00
Matt Nicolls 9a6683e01d fix: include eventTypeId in BOOKING_CANCELLED event (#12445) 2023-11-22 12:04:51 +00:00
sebzzandUdit Takkar 2853288f49 docs: add google credentials in example env (#11695)
* docs:add google credentials in example env

* docs: add a space after #

* chore: update .env.example

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2023-11-22 11:49:27 +00:00
Morgan d04226ab9a fix: alby payment isPaid always false on create (#12463) 2023-11-22 11:39:00 +00:00
cb7ddc455a chore: Add team invite tests (#12425)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-22 11:26:43 +00:00
Adugna TadesseandJoe Au-Yeung 73aa1e8a22 outlook second account fix (#12013)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-11-22 11:01:29 +00:00
Udit Takkar a3b5263b76 chore: reset form on submission (#12465) 2023-11-22 10:42:19 +00:00
Peer Richelsen af2c6c0844 chore: ignore "platform" in pr-assign-team workflow (#12487) 2023-11-22 10:25:56 +00:00
Peer Richelsen 113195224a chore: fixed cal.ai thumbnail (#12486) 2023-11-22 10:23:54 +00:00
Peer Richelsen 828092c1d0 chore: fix cal.ai price (#12485) 2023-11-22 10:18:09 +00:00
Somay ChauhanandKeith Williams 4b060fc2cd fix: opening team invite link in email throws error on signup page (#12397)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-11-22 06:49:37 -03:00
Syed Ali ShahbazandUdit Takkar 16c5b070b6 fix: Admin Logic for event-type API endpoint (#12482)
* Fix Admin logic

* chore: fix prettier

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2023-11-22 06:46:03 -03:00
Hariom Balhara 48dde246e9 test: Add more orgs tests (#12241) 2023-11-21 14:03:01 -03:00
Morgan 556b382f75 fix: better errors for googlecalendar integration (#12403) 2023-11-21 15:14:25 +00:00
85237c4985 fix: Date overrides UI bug depending on screen size (#12423)
* Update DateOverrideInputDialog.tsx

fix: Date overrides UI bug depending on screen size (calcom#12406)

* chore: remove comment

---------

Co-authored-by: madhurgoyal19 <35370133+madhurgoyal19@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2023-11-21 14:56:59 +00:00
Crowdin Bot 404bc0e4d6 New Crowdin translations by Github Action 2023-11-21 12:13:50 +00:00
Morgan 00553e897b fix: alby payment could not be created (#12460)
* fix: alby payment could not be created

* fixup! fix: alby payment could not be created

* fixup! fixup! fix: alby payment could not be created
2023-11-21 12:09:50 +00:00
Crowdin Bot ed2ce005c9 New Crowdin translations by Github Action 2023-11-20 16:42:20 +00:00
Peer Richelsen 058844b6b8 chore: paid support wip (#12419)
* paid support wip

* nit

* added upgrade box to intercom

* nit
2023-11-20 22:08:39 +05:30
77fa3702db fix: slug value in webhook payload (#12290)
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-11-20 15:39:21 +00:00
9eb2ae470c fix: [embed-react] types fix for compatible with TypeScript's "moduleResolution": "bundler" (#12327)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-20 13:33:27 +00:00
Somay Chauhan 3de7b73f5f fix: don't send notes to booker if "Hide notes in calendar" is on (#12333) 2023-11-20 13:28:40 +00:00
Alex van Andel 8e7785ca64 fix: Better error reporting & some fixes to deleteUser API (#12439) 2023-11-20 13:28:25 +00:00
sean-brydonandUdit Takkar 608b891536 fix: identifer (#12426)
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2023-11-20 13:28:10 +00:00
Alex van AndelandPeer Richelsen 4f26ca1a7b feat: Base implementation of v2 of avatars (#12369)
* feat: Base implementation of v2 of avatars

* Make avatarUrl and logoUrl entirely optional

* Made necessary backwards compat changes

* fix: type errors

* Fix: OG image

* fix types

* Consistency with other behaviour, ux tweak

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-20 12:49:38 +00:00
Hariom Balhara c55b36f235 fix: Members count when team slug is same as org slug (#12124) 2023-11-20 18:01:50 +05:30
sean-brydon bdd3b132d4 feat: troubleshooter with weekly view (V2) (#12280)
* Inital UI + layout setup

* use booker approach of grid

* event-select - sidebar + store work

* adds get schedule by event-type-slug

* Calendar toggle

* Load schedule from event slug

* Add busy events to calendar

* useschedule

* Store more event info than just slug

* Add date override to calendar

* Changes sizes on smaller screens

* add event title as a tooltip

* Ensure header navigation works

* Stop navigator throwing errors on inital render

* Correct br

* Event duration fixes

* Add getMoreInfo if user is authed with current request.username

* Add calendar color map wip

* Add WIP comments for coloured outlines

* Revert more info changes

* Calculate date override correctly

* Add description option

* Fix inital schedule data not being populated

* Nudge overlap over to make it clearer

* Fix disabled state

* WIP on math logic

* Event list overlapping events logic

* NIT about width

* i18n + manage calendars link

* Delete old troubleshooter

* Update packages/features/calendars/weeklyview/components/event/EventList.tsx

* Remove t-slots

* Fix i18n & install calendar action

* sm:imrovments

* NITS

* Fix types

* fix: back button

* Month prop null as we control from query param

* Add head SEO

* Fix headseo import

* Fix date override tests
2023-11-20 17:49:33 +05:30
Crowdin Bot 9a4c20cca4 New Crowdin translations by Github Action 2023-11-20 11:08:01 +00:00
Hariom Balhara 2144fcb23e fix: add node-mocks-http to web (#12435) 2023-11-20 16:34:29 +05:30
Crowdin Bot f2c39fc786 New Crowdin translations by Github Action 2023-11-17 16:12:37 +00:00
Crowdin Bot e83e0a770c New Crowdin translations by Github Action 2023-11-17 16:09:31 +00:00
Omar Lópezandsean-brydon c818ef3188 feat: implements basic user locking for admins (#12393)
* feat: implements basic user locking for admins

* Update sendPasswordReset.handler.ts

* check fixes

* Update packages/features/ee/users/components/UsersTable.tsx

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

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-11-17 16:06:29 +00:00
MehulandPeer Richelsen 176564f4ed fix: logo lack of contrast (#12401)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-17 14:10:58 +00:00
Morgan 27d59b6413 fix: add idx scheduleId on eventType (#12400) 2023-11-17 11:01:13 +00:00
Somay ChauhanandPeer Richelsen 57a65401e7 fix: weird date override behaviour (#12292)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-16 21:09:48 +00:00
DmytroHryshynandzomars 28acbe549a chore: [app dir bootstrapping 9]: replace useSearchParams with useCompatSearchParams hook (#12056)
Co-authored-by: zomars <zomars@me.com>
2023-11-16 13:38:27 -07:00
Keith Williams 3534e3c224 Revert "perf: Increased memory/vCPU for slots perf (#12387)" (#12389)
This reverts commit 0b96ef5476.
2023-11-16 20:40:42 +02:00
Erik e48a6c3dcf chore: Disable i18n cache in development (#12374) 2023-11-16 16:56:24 +00:00
Carina WollendorferandCarinaWolli 7d09ccb0d7 fix: add sendgrid-specific header only for SendGrid smtp server (#11932)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-11-16 13:51:30 -03:00
Morgan 9029c5b4c4 fix: change api root statusCode to 200 (#12362) 2023-11-16 16:50:43 +00:00
a4c1df3658 refactor: team settings redesign (#12230)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-16 16:18:24 +00:00
Keith Williams 0b96ef5476 perf: Increased memory/vCPU for slots perf (#12387) 2023-11-16 11:57:37 -03:00
shaharyarshamshiandPeer Richelsen ca9e0f5b72 refactor: Removed the dirName config for app store cli (#12321)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-16 13:59:01 +00:00
a9418b06d6 fix: Wrong spelling of the word "pode" (fix-wrongStringPT-BR) (#12344)
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-11-16 13:15:38 +00:00
Crowdin Bot 833a326c5b New Crowdin translations by Github Action 2023-11-16 13:09:03 +00:00
sean-brydon b7e2a62e26 chore: Use apiLogin instead of user.login in e2e tests (#12382) 2023-11-16 13:05:39 +00:00
Crowdin Bot f687056d60 New Crowdin translations by Github Action 2023-11-16 08:51:33 +00:00
0a39f53a4b fix(stripePaymentCallback): better errors (#12223)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-11-16 08:48:46 +00:00
Joe Au-Yeung ea0a64624c fix: saving credential id for payment apps (#12251)
## What does this PR do?

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

This PR adds the `credentialId` to payment app data. This fixes a bug where team installed payment apps were not working with team events.

Fixes # (issue)

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Requirement/Documentation

<!-- Please provide all documents that are important to understand the reason of that PR. -->

- If there is a requirement document, please, share it here.
- If there is ab UI/UX design document, please, share it here.

## Type of change

<!-- Please delete bullets that are not relevant. -->

- [x] Bug fix (non-breaking change which fixes an issue)

## How should this be tested?

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

- Install Stripe for the individual user
- Enable it for the individual's event type
- The `credentialId` should be saved to the metadata
- Install Stripe to the user's team
- Enable it in the team's event type
- The `credentialId` should be saved to the metadata

## Mandatory Tasks

- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.

## Checklist

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

- I haven't checked if new and existing unit tests pass locally with my changes
2023-11-15 19:59:43 -07:00
Erik 6a8726f5f8 chore: Insights readonly DB client (#12373) 2023-11-15 14:50:20 -07:00
Omar López eb97e1660b v3.5.0
release.yml / release (push) Canceled after 0s
2023-11-15 14:48:10 -07:00
Udit Takkar 371a0f7245 feat: booking errors logging (#12325)
Fixes: https://github.com/calcom/cal.com/issues/12297
Fixes https://github.com/calcom/cal.com/issues/11234

- Displaying error message and X-Vercel-Id( Unique Request Id ) to user on book event form
- Improve error logging 
- Add Error codes

Few things to discuss

1) How to handle calendar integration failures ?
   Currently if for example google integration is broken and someone is trying to book that person then we log the error but don't inform the user that the google calendar is broken and the meeting goes through.
   
 Should I throw error when integration is broken ?
   
<img width="758" alt="Screenshot 2023-11-12 at 12 52 36 AM" src="https://github.com/calcom/cal.com/assets/53316345/c4d921c4-9c8a-4b9b-82a2-bbe0fdbcb3d4">

   
2)  How to handle conferencing app failures? 
 We just default to Cal Video  as location if we are unable to generated conferencing url and log the error and not inform the user(organizer).
2023-11-15 12:52:19 -07:00
Carina Wollendorfer 270d4f6e82 fix: round-robin booked out of availability (#12376)
## What does this PR do?

Fixes that it can happen that Round Robin host is booked outside of availability. 

I found and fixed the following two scenarios where this can happen: 
- when host has a date override
- when host is available for only a part the event time (for example, booking time 9:00-11:00 and user is only available between 10:00-11:00)

Fixes #10315
Fixes #11690

It also fixes that it can happen that round robin doesn't correctly pick the luck user (least recently booked). This happened when a user was an attendee of a booking before, then we always compared this booking and never the actual last booking of this user. 

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How should this be tested?

#### Booked outside of availability: 
1. 
- Create Round Robin event and assign user1 and user2 as round robin hosts
   - event duration: 120 minutes 
- user 1 availability: 
  - Monday to Friday 9:00-17:00
- user2 availability: 
  - Monday to Friday 10:00-17:00
- Book event at a 9:00 slot -> check if i user1 is booked 
- Book event again at a 9:00 slot -> check if user1 is booked again (user2 is not available at that time)

2.
- Change availability of user2
   - Mark Monday as unavailable 
   -   Add date override on any day this month 
- Book any Monday this month -> see that user 1 is booked 
- Again Book any Monday this month -> see that user 1 is booked again 

#### Wrong lucky user 
- Book event and add user1's email as the attendee email address
- Book several slots where both users should be available, and see that it alternates between user1 and user2 (before it ended up always booking user1)

## Mandatory Tasks

- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
2023-11-15 12:49:03 -07:00
DmytroHryshyn 48e7b616b8 chore: [app dir bootstrapping 10] adds ab tests middleware (#12045)
## What does this PR do?
 - Adds AB tests middleware that redirects users to the app-dir pages with probability that is defined by env var
 
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

Fixes # (issue)

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Requirement/Documentation

<!-- Please provide all documents that are important to understand the reason of that PR. -->

This PR requires new env variables:
`APP_ROUTER_EVENT_TYPES_ENABLED`  - boolean that defines if app dir event-types page redirect is enabled
`AB_TEST_BUCKET_PROBABILITY` - number [0, 100] that defines the percentage of users getting redirected to the app dir pages

## Type of change

<!-- Please delete bullets that are not relevant. -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] Chore (refactoring code, technical debt, workflow improvements)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->
Does not requires testing, implements middleware as a dead code

## Mandatory Tasks

- [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
2023-11-15 11:06:18 -07:00
Crowdin Bot 8b80ea99c2 New Crowdin translations by Github Action 2023-11-15 16:18:21 +00:00
Rajiv Sahal 150c6e0f50 refactor: rename AtomsProvider to CalProvider (#12375)
* rename context provider

* rename atoms provider export
2023-11-15 16:14:37 +00:00
Crowdin Bot 4037e87fbd New Crowdin translations by Github Action 2023-11-15 12:33:14 +00:00
a804a29516 feat: Stripe paid apps flow (#12103)
* chore: Stripe paid apps flow

* chore: Subscription

* chore: Webhooks

* chore: Abstract functions

* chore: Lockfile

* chore: Webhook handler

* chore: Use catch-all

* chore: Webhook changes, etc

* chore: Cleanup

* chore: Use actual price id

* chore: Updates

* chore: Install normally until expiry date

* Disable team install for paid apps and cal.ai\

* Fix the same at another place

* Fix Typescript error

* redactedCause doesnt have message has enumerable prop

* Fix reinstallation of an already installed app

* chore: Remove unused deps

* chore: Ensure index

* chore: Price in usd

* chore: PR suggestion

* Fix missing packages in yarn.lock

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-15 09:29:41 -03:00
Crowdin Bot 87b514b91b New Crowdin translations by Github Action 2023-11-15 10:14:51 +00:00
Crowdin Bot 693ca04626 New Crowdin translations by Github Action 2023-11-15 10:11:45 +00:00
Crowdin Bot 67f83c7e7d New Crowdin translations by Github Action 2023-11-15 10:08:40 +00:00
Crowdin Bot ecfecd832d New Crowdin translations by Github Action 2023-11-15 10:05:17 +00:00
Crowdin Bot e55302cbc5 New Crowdin translations by Github Action 2023-11-15 10:02:20 +00:00
Crowdin Bot 9514a68039 New Crowdin translations by Github Action 2023-11-15 09:59:22 +00:00
Crowdin Bot 4e3d159401 New Crowdin translations by Github Action 2023-11-15 09:56:40 +00:00
Mehul a519941b81 fix avatar remove button (#12249) 2023-11-15 15:23:33 +05:30
Crowdin Bot f62c58532d New Crowdin translations by Github Action 2023-11-15 00:52:06 +00:00
Ash Davis 0981c4cb23 feat: POST/PATCH availabilities API to accept date field (#12238) 2023-11-14 21:49:27 -03:00
9d06f6dd0e fix: Fix 'Book a new time' link in request-reschedule for Team Event (#12261)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-11-14 13:23:44 -07:00
Morgan 00df493623 revert: "fix: create prisma client singleton with immediately invoked function" (#12366) 2023-11-14 20:22:35 +00:00
Crowdin Bot 2cf2b11a7b New Crowdin translations by Github Action 2023-11-14 19:32:20 +00:00
Joe Au-Yeung 5c3fd0993e fix: getting shimmer app keys (#12363)
* Fix getting shimmer app keys

* Read from config
2023-11-15 00:59:32 +05:30
Crowdin Bot 811e655720 New Crowdin translations by Github Action 2023-11-14 15:30:43 +00:00
Crowdin Bot defaafc9be New Crowdin translations by Github Action 2023-11-14 15:27:31 +00:00
Crowdin Bot e0c6feff1f New Crowdin translations by Github Action 2023-11-14 15:23:26 +00:00
Morgan d17dcbd319 v3.4.10
release.yml / release (push) Canceled after 0s
2023-11-14 17:19:22 +02:00
Rajiv Sahal 90d52663ac feat: AtomsProvider component (#12354)
* init: atoms provider component

* chore: add atoms provider to index of atoms package

* cleanup comments
2023-11-14 20:47:48 +05:30
sean-brydon ada74467bc fix: Callback redirect (#12361) 2023-11-14 12:15:50 -03:00
Crowdin Bot 7db758c2d5 New Crowdin translations by Github Action 2023-11-14 14:04:22 +00:00
Amit Sharma 2867d53442 fix resend team member invite (#12360) 2023-11-14 13:59:49 +00:00
Crowdin Bot 31067fcd45 New Crowdin translations by Github Action 2023-11-14 12:59:06 +00:00
Hariom Balhara 8c2ce972cc fix: team booking page having same slug as the org (#12213) 2023-11-14 18:25:46 +05:30
Crowdin Bot d3ab11e38e New Crowdin translations by Github Action 2023-11-14 11:54:31 +00:00
Crowdin Bot 3d596fcb1a New Crowdin translations by Github Action 2023-11-14 11:51:13 +00:00
Crowdin Bot 820b45a912 New Crowdin translations by Github Action 2023-11-14 11:48:30 +00:00
Udit Takkar b945849736 chore: variables names (#12352) 2023-11-14 11:45:09 +00:00
Crowdin Bot 6b2c59b733 New Crowdin translations by Github Action 2023-11-14 10:27:14 +00:00
Crowdin Bot b7f85d785a New Crowdin translations by Github Action 2023-11-14 10:23:40 +00:00
Crowdin Bot 08e4e816c6 New Crowdin translations by Github Action 2023-11-14 10:20:41 +00:00
Crowdin Bot 20430022e1 New Crowdin translations by Github Action 2023-11-14 10:17:32 +00:00
Crowdin Bot e4c71e5d2c New Crowdin translations by Github Action 2023-11-14 10:14:23 +00:00
Crowdin Bot 28eda407be New Crowdin translations by Github Action 2023-11-14 10:11:07 +00:00
Crowdin Bot 9dab171520 New Crowdin translations by Github Action 2023-11-14 10:07:51 +00:00
Crowdin Bot d5f359291d New Crowdin translations by Github Action 2023-11-14 10:03:34 +00:00
Morgan 3c1d0a9bf1 fix: add translations to api dockerfile (#12349) 2023-11-14 09:59:32 +00:00
Crowdin Bot 3057edbd87 New Crowdin translations by Github Action 2023-11-14 04:59:52 +00:00
Carina WollendorferandCarinaWolli a2f859b55a fix: unhandled promise rejection in scheduleWorkflowReminder (#12301)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-11-14 10:27:16 +05:30
Crowdin Bot b2f4eaee63 New Crowdin translations by Github Action 2023-11-13 21:53:32 +00:00
Hariom Balhara b66c3f3452 fix: Org: Any team URL with slug starting with d is 404 (#12302) 2023-11-13 14:50:54 -07:00
Keith Williams 5ab0b85e52 chore: Reduce slots API serverless memory configuration (#12270)
* chore: Reduce API serverless memory configuration

* Only limit to slots for now

* Moved the vercel.json file to root

* More fun with vercel.json files

* Trying new pattern

* Using a pattern that matches the Vercel deployment summary function name

* Trying to set the memory directly in the handler file

* Trying it on all functions

* Changed glob

* Moved vercel.json file to root

* Trying a new glob

* Give me all functions

* Trying just * for all functions

* Trying again...

* Putting the config on the defaultResponder

* Seeing if web app takes setings

* Changed .js to .ts

* Changed root path

* Added back vercel.json now that the path is changed

* Reduced API slots memory to 512

* Removed unneeded code changes'

* Update defaultResponder.ts
2023-11-13 15:37:26 +00:00
Alex van Andel 4135c1038e v3.4.9
release.yml / release (push) Canceled after 0s
2023-11-13 15:13:25 +00:00
Crowdin Bot fc5190a33b New Crowdin translations by Github Action 2023-11-13 14:31:15 +00:00
Carina WollendorferandCarinaWolli f29328e6a2 fix: meeting url variable in workflow notifitcations (#12308)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-11-13 09:28:15 -05:00
Udit TakkarandHariom Balhara 621f7639ff fix: display organizer location after booking (#12088)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-11-13 19:29:50 +05:30
63f2f6edba fix: getTeamOrThrow ran on every request (#12337)
* fix: getTeamOrThrow ran on every request

This creates unnecessary strain on the DB when someone is hitting the endpoint a lot.

* Add rate limit

* Update packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts

---------

Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-11-13 13:45:46 +00:00
Morgan 199f41fb1a fix: add teamId index on VerificationToken (#12339)
* fix: add teamId index on InvitationToken

* fix: add migration
2023-11-13 13:42:27 +00:00
Crowdin Bot 3ea9faa414 New Crowdin translations by Github Action 2023-11-13 11:58:42 +00:00
Peer Richelsen 518ec4f605 feat: added /enterprise upgrade tip (#12207) 2023-11-13 13:55:28 +02:00
Hichem FantarandPeer Richelsen 4db0da5da4 feat: smooth transitions to interactive elements (#11983)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-12 17:45:44 +00:00
Crowdin Bot 115ded8429 New Crowdin translations by Github Action 2023-11-12 17:16:59 +00:00
DmytroHryshyn 71485d1b57 chore: [app dir bootstrapping 7] update middleware (#12044) 2023-11-12 17:14:38 +00:00
Crowdin Bot 87071c4f19 New Crowdin translations by Github Action 2023-11-11 11:19:34 +00:00
Hariom 412f15814e Add removeItem to webstorage 2023-11-11 11:16:35 +00:00
Hariom a57085cf64 fix: Broken embed in incognito of chrome 2023-11-11 11:16:35 +00:00
Crowdin Bot 65d1a34e39 New Crowdin translations by Github Action 2023-11-10 20:09:57 +00:00
Hariom BalharaandKeith Williams e4abc85382 fix: Fix recurring booking failure due to rate limiting (#12322)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-11-10 20:06:52 +00:00
Crowdin Bot eb1eb722f2 New Crowdin translations by Github Action 2023-11-10 18:41:47 +00:00
Amit Sharma 033bc45dd1 appstore-install-loader-fix (#12269) 2023-11-10 18:38:45 +00:00
affe85d02c fix: link attendee to meeting in ZohoCRM app (#12200)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-11-10 13:50:30 +00:00
Morgan d6f9a833bd fix: add missing config files to api dockerfile (#12318) 2023-11-10 12:50:49 +00:00
Keith Williams e4281a852a feat: Add Sentry to edge (#12304) 2023-11-10 08:47:55 -03:00
e4b2464548 fix: Fix "could not book the meeting" issue (fix-confirmBookingIssue) (#12305)
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
2023-11-09 18:43:48 -03:00
8c99a2a7af fix: fix bookings today (#12060)
* fix : fixed today's bookings in bookings upcoming section

* chore: improvements

* chore: fix type error

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-11-10 02:20:51 +05:30
363 changed files with 8786 additions and 2575 deletions
+26
View File
@@ -25,6 +25,7 @@ CALCOM_LICENSE_KEY=
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
INSIGHTS_DATABASE_URL=
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
@@ -106,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
NEXT_PUBLIC_FRESHCHAT_TOKEN=
NEXT_PUBLIC_FRESHCHAT_HOST=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` above
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
GOOGLE_LOGIN_ENABLED=false
# - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
@@ -236,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL=
E2E_TEST_APPLE_CALENDAR_EMAIL=""
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
# - CALCOM QA ACCOUNT
# Used for E2E tests on Cal.com that require 3rd party integrations
E2E_TEST_CALCOM_QA_EMAIL="qa@example.com"
# Replace with your own password
E2E_TEST_CALCOM_QA_PASSWORD="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
@@ -263,3 +283,9 @@ E2E_TEST_OIDC_USER_EMAIL=
E2E_TEST_OIDC_USER_PASSWORD=
# ***********************************************************************************************************
# provide a value between 0 and 100 to ensure the percentage of traffic
# redirected from the legacy to the future pages
AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=1
+1 -1
View File
@@ -13,4 +13,4 @@ jobs:
with:
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
organization-name: calcom
ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
+20 -29
View File
@@ -1,7 +1,7 @@
import type { NextMiddleware } from "next-api-middleware";
import { CONSOLE_URL } from "@calcom/lib/constants";
import prisma, { customPrisma } from "@calcom/prisma";
import { customPrisma } from "@calcom/prisma";
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
@@ -12,41 +12,32 @@ 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 = prisma;
req.prisma = customPrisma();
await next();
return;
}
try {
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
.then((res) => res.json())
.then((res) => res.databaseUrl);
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
.then((res) => res.json())
.then((res) => res.databaseUrl);
if (!databaseUrl) {
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
return;
}
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
/* @note:
if (!databaseUrl) {
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
return;
}
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,
we skip verifyApiKey logic and pass onto next middleware instead.
*/
req.isAdmin = true;
req.isCustomPrisma = true;
// We don't need the key from here and on. Prevents unrecognized key errors.
delete req.query.key;
await next();
await req.prisma.$disconnect();
// @ts-expect-error testing
delete req.prisma;
} catch (err) {
if (req.prisma) {
await req.prisma.$disconnect();
// @ts-expect-error testing
delete req.prisma;
}
throw err;
}
req.isAdmin = true;
req.isCustomPrisma = true;
// We don't need the key from here and on. Prevents unrecognized key errors.
delete req.query.key;
await next();
await req.prisma.$disconnect();
// @ts-expect-error testing
delete req.prisma;
};
+2
View File
@@ -26,6 +26,7 @@ const schemaAvailabilityCreateParams = z
startTime: z.date().or(z.string()),
endTime: z.date().or(z.string()),
days: z.array(z.number()).optional(),
date: z.date().or(z.string()).optional(),
})
.strict();
@@ -34,6 +35,7 @@ const schemaAvailabilityEditParams = z
startTime: z.date().or(z.string()).optional(),
endTime: z.date().or(z.string()).optional(),
days: z.array(z.number()).optional(),
date: z.date().or(z.string()).optional(),
})
.strict();
+10 -1
View File
@@ -21,7 +21,16 @@ export const schemaSchedulePublic = z
.merge(
z.object({
availability: z
.array(Availability.pick({ id: true, eventTypeId: true, days: true, startTime: true, endTime: true }))
.array(
Availability.pick({
id: true,
eventTypeId: true,
date: true,
days: true,
startTime: true,
endTime: true,
})
)
.transform((v) =>
v.map((item) => ({
...item,
+2 -2
View File
@@ -205,8 +205,8 @@ import { defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest) {
const { userId, isAdmin } = req;
if (isAdmin) req.userId = req.body.userId || userId;
const booking = await handleNewBooking(req);
return booking;
return await handleNewBooking(req);
}
export default defaultResponder(handler);
+6 -1
View File
@@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) {
statusCode: 401,
message: "ADMIN required for `userId`",
});
if (!isAdmin && body.teamId)
throw new HttpError({
statusCode: 401,
message: "ADMIN required for `teamId`",
});
/* Admin users are required to pass in a userId or teamId */
if (isAdmin && (!body.userId || !body.teamId))
if (isAdmin && !body.userId && !body.teamId)
throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) {
res.status(201).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
res.status(200).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
}
+11 -2
View File
@@ -1,5 +1,6 @@
import type { NextApiRequest } from "next";
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@@ -41,10 +42,18 @@ export async function deleteHandler(req: NextApiRequest) {
// Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
const user = await prisma.user.findUnique({ where: { id: query.userId } });
const user = await prisma.user.findUnique({
where: { id: query.userId },
select: {
id: true,
email: true,
metadata: true,
},
});
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
await prisma.user.delete({ where: { id: user.id } });
await deleteUser(user);
return { message: `User with id: ${user.id} deleted successfully` };
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { describe, expect, test, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
@@ -148,7 +149,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
expect(res._getStatusCode()).toBe(500);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message: "No available users found.",
message: ErrorCode.NoAvailableUsersFound,
})
);
});
+7
View File
@@ -0,0 +1,7 @@
{
"functions": {
"pages/api/slots/*.ts": {
"memory": 512
}
}
}
+2 -2
View File
@@ -10,7 +10,7 @@
"@calcom/config": "*",
"@calcom/dayjs": "*",
"@calcom/ui": "*",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
@@ -49,7 +49,7 @@
"storybook-addon-designs": "^6.3.1",
"storybook-addon-next": "^1.6.9",
"storybook-react-i18next": "^1.1.2",
"tailwindcss": "^3.3.1",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.4",
"vite": "^4.1.2"
}
+68
View File
@@ -0,0 +1,68 @@
import { getBucket } from "abTest/utils";
import type { NextMiddleware, NextRequest } from "next/server";
import { NextResponse, URLPattern } from "next/server";
import z from "zod";
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,
}),
enabled,
]);
const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
export const abTestMiddlewareFactory =
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
async (req: NextRequest) => {
const response = await next(req);
const { pathname } = req.nextUrl;
const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);
const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null;
const enabled = route !== null ? route[1] || override : false;
if (pathname.includes("future") || !enabled) {
return response;
}
const safeParsedBucket = override
? { success: true as const, data: "future" as const }
: bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value);
if (!safeParsedBucket.success) {
// cookie does not exist or it has incorrect value
const bucket = getBucket();
response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, {
expires: Date.now() + 1000 * 60 * 30,
httpOnly: true,
}); // 30 min in ms
if (bucket === "legacy") {
return response;
}
const url = req.nextUrl.clone();
url.pathname = `future${pathname}/`;
return NextResponse.rewrite(url, response);
}
if (safeParsedBucket.data === "legacy") {
return response;
}
const url = req.nextUrl.clone();
url.pathname = `future${pathname}/`;
return NextResponse.rewrite(url, response);
};
+9
View File
@@ -0,0 +1,9 @@
import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants";
const cryptoRandom = () => {
return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff;
};
export const getBucket = () => {
return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy";
};
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { createHydrateClient } from "app/_trpc/createHydrateClient";
import superjson from "superjson";
export const HydrateClient = createHydrateClient({
transformer: superjson,
});
+5
View File
@@ -0,0 +1,5 @@
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { createTRPCReact } from "@trpc/react-query";
export const trpc = createTRPCReact<AppRouter>({});
@@ -0,0 +1,21 @@
"use client";
import { type DehydratedState, Hydrate } from "@tanstack/react-query";
import { useMemo } from "react";
import type { DataTransformer } from "@trpc/server";
export function createHydrateClient(opts: { transformer?: DataTransformer }) {
return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) {
const { state, children } = props;
const transformedState: DehydratedState = useMemo(() => {
if (opts.transformer) {
return opts.transformer.deserialize(state);
}
return state;
}, [state]);
return <Hydrate state={transformedState}>{children}</Hydrate>;
};
}
+128
View File
@@ -0,0 +1,128 @@
import { type DehydratedState, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HydrateClient } from "app/_trpc/HydrateClient";
import { trpc } from "app/_trpc/client";
import { useState } from "react";
import superjson from "superjson";
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
import { httpLink } from "@calcom/trpc/client/links/httpLink";
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
import { splitLink } from "@calcom/trpc/client/links/splitLink";
const ENDPOINTS = [
"admin",
"apiKeys",
"appRoutingForms",
"apps",
"auth",
"availability",
"appBasecamp3",
"bookings",
"deploymentSetup",
"eventTypes",
"features",
"insights",
"payments",
"public",
"saml",
"slots",
"teams",
"organizations",
"users",
"viewer",
"webhook",
"workflows",
"appsRouter",
"googleWorkspace",
] as const;
export type Endpoint = (typeof ENDPOINTS)[number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveEndpoint = (links: any) => {
// TODO: Update our trpc routes so they are more clear.
// This function parses paths like the following and maps them
// to the correct API endpoints.
// - viewer.me - 2 segment paths like this are for logged in requests
// - viewer.public.i18n - 3 segments paths can be public or authed
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (ctx: any) => {
const parts = ctx.op.path.split(".");
let endpoint;
let path = "";
if (parts.length == 2) {
endpoint = parts[0] as keyof typeof links;
path = parts[1];
} else {
endpoint = parts[1] as keyof typeof links;
path = parts.splice(2, parts.length - 2).join(".");
}
return links[endpoint]({ ...ctx, op: { ...ctx.op, path } });
};
};
export const TrpcProvider: React.FC<{ children: React.ReactNode; dehydratedState?: DehydratedState }> = ({
children,
dehydratedState,
}) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: { queries: { staleTime: 5000 } },
})
);
const url =
typeof window !== "undefined"
? "/api/trpc"
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
// adds pretty logs to your console in development and logs errors in production
loggerLink({
enabled: (opts) =>
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
}),
splitLink({
// check for context property `skipBatch`
condition: (op) => !!op.context.skipBatch,
// when condition is true, use normal request
true: (runtime) => {
const links = Object.fromEntries(
ENDPOINTS.map((endpoint) => [
endpoint,
httpLink({
url: `${url}/${endpoint}`,
})(runtime),
])
);
return resolveEndpoint(links);
},
// when condition is false, use batch request
false: (runtime) => {
const links = Object.fromEntries(
ENDPOINTS.map((endpoint) => [
endpoint,
httpBatchLink({
url: `${url}/${endpoint}`,
})(runtime),
])
);
return resolveEndpoint(links);
},
}),
],
transformer: superjson,
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{dehydratedState ? <HydrateClient state={dehydratedState}>{children}</HydrateClient> : children}
</QueryClientProvider>
</trpc.Provider>
);
};
+40
View File
@@ -0,0 +1,40 @@
import { type TFunction } from "i18next";
import { headers } from "next/headers";
import { constructGenericImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
import { getFixedT } from "@calcom/lib/server/getFixedT";
import { preparePageMetadata } from "@lib/metadata";
export const _generateMetadata = async (
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string
) => {
const h = headers();
const canonical = h.get("x-pathname") ?? "";
const locale = h.get("x-locale") ?? "en";
const t = await getFixedT(locale, "common");
const title = getTitle(t);
const description = getDescription(t);
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const image =
SEO_IMG_OGIMG +
constructGenericImage({
title,
description,
});
return preparePageMetadata({
title,
canonical,
image,
description,
siteName: APP_NAME,
metadataBase,
});
};
+64
View File
@@ -0,0 +1,64 @@
"use client";
/**
* Typescript class based component for custom-error
* @link https://nextjs.org/docs/advanced-features/custom-error-page
*/
import type { NextPage } from "next";
import type { ErrorProps } from "next/error";
import React from "react";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { redactError } from "@calcom/lib/redactError";
import { ErrorPage } from "@components/error/error-page";
type NextError = Error & { digest?: string };
// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props
export type DefaultErrorProps = {
error: NextError;
reset: () => void; // A function to reset the error boundary
};
type AugmentedError = NextError | HttpError | null;
type CustomErrorProps = {
err?: AugmentedError;
statusCode?: number;
message?: string;
} & Omit<ErrorProps, "err" | "statusCode">;
const log = logger.getSubLogger({ prefix: ["[error]"] });
const CustomError: NextPage<DefaultErrorProps> = (props) => {
const { error } = props;
let errorObject: CustomErrorProps = {
message: error.message,
err: error,
};
if (error instanceof HttpError) {
const redactedError = redactError(error);
errorObject = {
statusCode: error.statusCode,
title: redactedError.name,
message: redactedError.message,
err: {
...redactedError,
...error,
},
};
}
// `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs
log.debug(`${error?.toString() ?? JSON.stringify(error)}`);
log.info("errorObject: ", errorObject);
return (
<ErrorPage statusCode={errorObject.statusCode} error={errorObject.err} message={errorObject.message} />
);
};
export default CustomError;
@@ -0,0 +1,10 @@
import EventTypes from "@pages/event-types";
import { _generateMetadata } from "app/_utils";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("event_types_page_title"),
(t) => t("event_types_page_subtitle")
);
export default EventTypes;
@@ -0,0 +1,22 @@
// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder
import { headers } from "next/headers";
import { type ReactElement } from "react";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import PageWrapper from "@components/PageWrapperAppDir";
type WrapperWithLayoutProps = {
children: ReactElement;
};
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
return (
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
{children}
</PageWrapper>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { type NextPage } from "next";
import CustomError, { type DefaultErrorProps } from "./error";
export const GlobalError: NextPage<DefaultErrorProps> = (props) => {
return (
<html>
<body>
<CustomError {...props} />
</body>
</html>
);
};
export default GlobalError;
+34 -55
View File
@@ -1,84 +1,63 @@
import type { Metadata } from "next";
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
import { dir } from "i18next";
import { headers, cookies } from "next/headers";
import Script from "next/script";
import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { prepareRootMetadata } from "@lib/metadata";
import "../styles/globals.css";
export const metadata: Metadata = {
icons: {
icon: [
{
sizes: "32x32",
url: "/api/logo?type=favicon-32",
},
{
sizes: "16x16",
url: "/api/logo?type=favicon-16",
},
],
apple: {
sizes: "180x180",
url: "/api/logo?type=apple-touch-icon",
export const generateMetadata = () =>
prepareRootMetadata({
twitterCreator: "@calcom",
twitterSite: "@calcom",
robots: {
index: false,
follow: false,
},
other: [
{
url: "/safari-pinned-tab.svg",
rel: "mask-icon",
},
],
},
manifest: "/site.webmanifest",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
],
other: {
"msapplication-TileColor": "#000000",
},
};
});
const getInitialProps = async (
url: string,
headers: ReturnType<typeof nextHeaders>,
cookies: ReturnType<typeof nextCookies>
) => {
const getInitialProps = async (url: string) => {
const { pathname, searchParams } = new URL(url);
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
const embedColorScheme = searchParams?.get("ui.color-scheme");
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
const newLocale = await getLocale({ headers, cookies });
let direction = "ltr";
try {
const intlLocale = new Intl.Locale(newLocale);
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
direction = intlLocale.textInfo?.direction;
} catch (e) {
console.error(e);
}
const req = { headers: headers(), cookies: cookies() };
const newLocale = await getLocale(req);
const direction = dir(newLocale);
return { isEmbed, embedColorScheme, locale: newLocale, direction };
};
const getFallbackProps = () => ({
locale: "en",
direction: "ltr",
isEmbed: false,
embedColorScheme: false,
});
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headers = nextHeaders();
const cookies = nextCookies();
const h = headers();
const fullUrl = headers.get("x-url") ?? "";
const nonce = headers.get("x-csp") ?? "";
const fullUrl = h.get("x-url") ?? "";
const nonce = h.get("x-csp") ?? "";
const isSSG = !fullUrl;
const { locale, direction, isEmbed, embedColorScheme } = isSSG
? getFallbackProps()
: await getInitialProps(fullUrl);
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
return (
<html
lang={locale}
dir={direction}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
data-nextjs-router="app">
<head nonce={nonce}>
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts
+3 -2
View File
@@ -1,4 +1,4 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
@@ -6,6 +6,7 @@ import { z } from "zod";
import type { CredentialOwner } from "@calcom/app-store/types";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { Badge, ListItemText, Avatar } from "@calcom/ui";
@@ -56,7 +57,7 @@ export default function AppListCard(props: AppListCardProps) {
const router = useRouter();
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
useEffect(() => {
+6 -5
View File
@@ -1,5 +1,6 @@
"use client";
import { type DehydratedState } from "@tanstack/react-query";
import type { SSRConfig } from "next-i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
@@ -10,7 +11,6 @@ import type { ReactNode } from "react";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { trpc } from "@calcom/trpc/react";
import type { AppProps } from "@lib/app-providers-app-dir";
import AppProviders from "@lib/app-providers-app-dir";
@@ -29,13 +29,14 @@ const calFont = localFont({
});
export type PageWrapperProps = Readonly<{
getLayout: (page: React.ReactElement) => ReactNode;
getLayout: ((page: React.ReactElement) => ReactNode) | null;
children: React.ReactElement;
requiresLicense: boolean;
isThemeSupported: boolean;
isBookingPage: boolean;
nonce: string | undefined;
themeBasis: string | null;
dehydratedState?: DehydratedState;
isThemeSupported?: boolean;
isBookingPage?: boolean;
i18n?: SSRConfig;
}>;
@@ -85,4 +86,4 @@ function PageWrapper(props: PageWrapperProps) {
);
}
export default trpc.withTRPC(PageWrapper);
export default PageWrapper;
+39 -18
View File
@@ -42,6 +42,7 @@ export type AppPageProps = {
disableInstall?: boolean;
dependencies?: string[];
concurrentMeetings: AppType["concurrentMeetings"];
paid?: AppType["paid"];
};
export const AppPage = ({
@@ -67,6 +68,7 @@ export const AppPage = ({
isTemplate,
dependencies,
concurrentMeetings,
paid,
}: AppPageProps) => {
const { t, i18n } = useLocale();
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
@@ -163,6 +165,19 @@ export const AppPage = ({
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
{categories[0]}
</Link>{" "}
{paid && (
<>
<Badge className="mr-1">
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
maximumFractionDigits: 0,
}).format(paid.priceInUsd)}
/{t("month")}
</Badge>
</>
)}
•{" "}
<a target="_blank" rel="noreferrer" href={website}>
{t("published_by", { author })}
@@ -206,6 +221,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }}
multiInstall
concurrentMeetings={concurrentMeetings}
paid={paid}
{...props}
/>
);
@@ -244,6 +260,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }}
credentials={appDbQuery.data?.credentials}
concurrentMeetings={concurrentMeetings}
paid={paid}
{...props}
/>
);
@@ -263,7 +280,7 @@ export const AppPage = ({
<SkeletonButton className="mt-6 h-20 grow" />
))}
{price !== 0 && (
{price !== 0 && !paid && (
<span className="block text-right">
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
{feeType === "monthly" && `/${t("month")}`}
@@ -273,23 +290,27 @@ export const AppPage = ({
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
{body}
</div>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
<span className="text-default">
{teamsPlanRequired ? (
t("teams_plan_required")
) : price === 0 ? (
t("free_to_use_apps")
) : (
<>
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && `/${t("month")}`}
</>
)}
</span>
{!paid && (
<>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
<span className="text-default">
{teamsPlanRequired ? (
t("teams_plan_required")
) : price === 0 ? (
t("free_to_use_apps")
) : (
<>
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && `/${t("month")}`}
</>
)}
</span>
</>
)}
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
<ul className="prose-sm -ml-1 -mr-1 leading-5">
@@ -26,6 +26,7 @@ export const InstallAppButtonChild = ({
multiInstall,
credentials,
concurrentMeetings,
paid,
...props
}: {
userAdminTeams?: UserAdminTeams;
@@ -34,6 +35,7 @@ export const InstallAppButtonChild = ({
multiInstall?: boolean;
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
concurrentMeetings?: boolean;
paid?: AppFrontendPayload["paid"];
} & ButtonProps) => {
const { t } = useLocale();
@@ -46,8 +48,27 @@ export const InstallAppButtonChild = ({
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
if (paid) {
return (
<Button
data-testid="install-app-button"
{...props}
disabled={shouldDisableInstallation}
color="primary"
size="base">
{paid.trial ? t("start_paid_trial") : t("install_paid_app")}
</Button>
);
}
if (
!userAdminTeams?.length ||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
) {
return (
<Button
data-testid="install-app-button"
@@ -55,6 +76,7 @@ export const InstallAppButtonChild = ({
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
disabled={shouldDisableInstallation}
size="base">
{multiInstall ? t("install_another") : t("install_app")}
</Button>
+41 -25
View File
@@ -32,8 +32,6 @@ import {
} from "@calcom/ui";
import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } from "@calcom/ui/components/icon";
import useMeQuery from "@lib/hooks/useMeQuery";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
@@ -45,14 +43,18 @@ type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number
type BookingItemProps = BookingItem & {
listingStatus: BookingListingStatus;
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
loggedInUser: {
userId: number | undefined;
userTimeZone: string | undefined;
userTimeFormat: number | null | undefined;
userEmail: string | undefined;
};
};
function BookingListItem(booking: BookingItemProps) {
// Get user so we can determine 12/24 hour format preferences
const query = useMeQuery();
const bookerUrl = useBookerUrl();
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const user = query.data;
const {
t,
i18n: { language },
@@ -215,7 +217,7 @@ function BookingListItem(booking: BookingItemProps) {
};
const startTime = dayjs(booking.startTime)
.tz(user?.timeZone)
.tz(userTimeZone)
.locale(language)
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
@@ -301,7 +303,7 @@ function BookingListItem(booking: BookingItemProps) {
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
setIsOpenDialog={setViewRecordingsDialogIsOpen}
timeFormat={user?.timeFormat ?? null}
timeFormat={userTimeFormat ?? null}
/>
)}
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
@@ -341,11 +343,11 @@ function BookingListItem(booking: BookingItemProps) {
<div className="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={user?.timeFormat}
userTimezone={user?.timeZone}
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
@@ -372,7 +374,12 @@ function BookingListItem(booking: BookingItemProps) {
) : null}
{recurringDates !== undefined && (
<div className="text-muted mt-2 text-sm">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
<RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div>
)}
</div>
@@ -385,11 +392,11 @@ function BookingListItem(booking: BookingItemProps) {
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={user?.timeFormat}
userTimezone={user?.timeZone}
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
@@ -414,7 +421,12 @@ function BookingListItem(booking: BookingItemProps) {
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
<RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div>
)}
</div>
@@ -446,7 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
<DisplayAttendees
attendees={booking.attendees}
user={booking.user}
currentEmail={user?.email}
currentEmail={userEmail}
/>
)}
{isCancelled && booking.rescheduled && (
@@ -460,7 +472,7 @@ function BookingListItem(booking: BookingItemProps) {
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
{isUpcoming && !isCancelled ? (
<>
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
{isPending && userId === booking.user?.id && <TableActions actions={pendingActions} />}
{isConfirmed && <TableActions actions={bookedActions} />}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
</>
@@ -486,12 +498,16 @@ function BookingListItem(booking: BookingItemProps) {
interface RecurringBookingsTooltipProps {
booking: BookingItemProps;
recurringDates: Date[];
userTimeZone: string | undefined;
userTimeFormat: number | null | undefined;
}
const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookingsTooltipProps) => {
// Get user so we can determine 12/24 hour format preferences
const query = useMeQuery();
const user = query.data;
const RecurringBookingsTooltip = ({
booking,
recurringDates,
userTimeZone,
userTimeFormat,
}: RecurringBookingsTooltipProps) => {
const {
t,
i18n: { language },
@@ -523,7 +539,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
.includes(aDate.toDateString());
return (
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
{formatTime(aDate, user?.timeFormat, user?.timeZone)}
{formatTime(aDate, userTimeFormat, userTimeZone)}
{" - "}
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
</p>
@@ -536,7 +552,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
/>
<p className="mt-1 pl-5 text-xs">
{booking.status === BookingStatus.ACCEPTED
? `${t("event_remaining", {
? `${t("event_remaining_other", {
count: recurringCount,
})}`
: getEveryFreqFor({
@@ -47,7 +47,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
};
};
const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => {
const getAppDataSetter = (appId: EventTypeAppsList, credentialId?: number): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
@@ -57,6 +57,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
[appId]: {
...appData,
[key]: value,
credentialId,
},
});
};
@@ -76,7 +77,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
appCards.push(
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
key={app.slug}
app={app}
eventType={eventType}
@@ -90,7 +91,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
appCards.push(
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, team.credentialId)}
key={app.slug + team?.credentialId}
app={{
...app,
@@ -147,7 +148,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
return (
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
key={app.slug}
app={app}
eventType={eventType}
@@ -8,7 +8,7 @@ import { Controller, useFormContext, useFieldArray } from "react-hook-form";
import type { MultiValue } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CAL_URL } from "@calcom/lib/constants";
@@ -247,15 +247,7 @@ export const EventSetupTab = (
<ul ref={animationRef} className="space-y-2">
{locationFields.map((field, index) => {
const eventLocationType = getEventLocationType(field.type);
const defaultLocation = formMethods
.getValues("locations")
?.find((location: { type: EventLocationType["type"]; address?: string }) => {
if (location.type === LocationType.InPerson) {
return location.type === eventLocationType?.type && location.address === field?.address;
} else {
return location.type === eventLocationType?.type;
}
});
const defaultLocation = field;
const option = getLocationFromType(field.type, locationOptions);
@@ -337,10 +329,12 @@ export const EventSetupTab = (
name={eventLocationType.defaultValueVariable}
className="text-error my-1 ml-6 text-sm"
as="div"
id="location-error"
/>
</div>
<div className="ml-6">
<CheckboxField
name={`locations[${index}].displayLocationPublicly`}
data-testid="display-location"
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
@@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
<div>
<Label>{t("assign_to")}</Label>
<ChildrenEventTypeSelect
aria-label="assignment-dropdown"
data-testid="assignment-dropdown"
onChange={(options) => {
onChange &&
onChange(
+2 -2
View File
@@ -14,6 +14,6 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageS
* It is aware of the user's organization to correctly show the avatar from the correct URL
*/
export function UserAvatar(props: UserAvatarProps) {
const { user, previewSrc, ...rest } = props;
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
}
+23 -20
View File
@@ -1,4 +1,5 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
@@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => {
const isBookingPage = useIsBookingPage();
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider>
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={props.isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
</EventCollectionProvider>
<TrpcProvider dehydratedState={props.dehydratedState}>
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider>
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
</EventCollectionProvider>
</TrpcProvider>
);
if (isBookingPage) {
+25 -12
View File
@@ -1,10 +1,11 @@
import type { IncomingMessage, OutgoingMessage } from "http";
import type { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildNonce } from "@lib/buildNonce";
import { buildNonce } from "./buildNonce";
function getCspPolicy(nonce: string) {
//TODO: Do we need to explicitly define it in turbo.json
@@ -46,11 +47,12 @@ const isPagePathRequest = (url: URL) => {
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMessage | NextResponse | null) {
if (!req) {
return { nonce: undefined };
}
const existingNonce = req.headers["x-nonce"];
const existingNonce = "cache" in req ? req.headers.get("x-nonce") : req.headers["x-nonce"];
if (existingNonce) {
const existingNoneParsed = z.string().safeParse(existingNonce);
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
@@ -71,17 +73,28 @@ export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
}
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
// It is generated for all page requests but only used by pages that need CSP
req.headers["x-nonce"] = nonce;
if ("cache" in req) {
req.headers.set("x-nonce", nonce);
} else {
req.headers["x-nonce"] = nonce;
}
if (res) {
res.setHeader(
req.headers["x-csp-enforce"] === "true"
? "Content-Security-Policy"
: "Content-Security-Policy-Report-Only",
getCspPolicy(nonce)
.replace(/\s{2,}/g, " ")
.trim()
);
const enforced =
"cache" in req ? req.headers.get("x-csp-enforce") === "true" : req.headers["x-csp-enforce"] === "true";
const name = enforced ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only";
const value = getCspPolicy(nonce)
.replace(/\s{2,}/g, " ")
.trim();
if ("body" in res) {
res.headers.set(name, value);
} else {
res.setHeader(name, value);
}
}
return { nonce };
+4 -2
View File
@@ -1,10 +1,12 @@
import { usePathname, useSearchParams } from "next/navigation";
import { usePathname } from "next/navigation";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export default function useIsBookingPage(): boolean {
const pathname = usePathname();
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const userParam = Boolean(searchParams?.get("user"));
const teamParam = Boolean(searchParams?.get("team"));
+4 -2
View File
@@ -1,8 +1,10 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export default function useRouterQuery<T extends string>(name: T) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const router = useRouter();
+2 -2
View File
@@ -1,7 +1,7 @@
import { useSearchParams } from "next/navigation";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export function useToggleQuery(name: string) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
return {
isOn: searchParams?.get(name) === "1",
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import { truncateOnWord } from "@calcom/lib/text";
type RootMetadataRecipe = Readonly<{
twitterCreator: string;
twitterSite: string;
robots: {
index: boolean;
follow: boolean;
};
}>;
export type PageMetadataRecipe = Readonly<{
title: string;
canonical: string;
image: string;
description: string;
siteName: string;
metadataBase: URL;
}>;
export const prepareRootMetadata = (recipe: RootMetadataRecipe): Metadata => ({
icons: {
icon: "/favicon.icon",
apple: "/api/logo?type=apple-touch-icon",
other: [
{
rel: "icon-mask",
url: "/safari-pinned-tab.svg",
color: "#000000",
},
{
url: "/api/logo?type=favicon-16",
sizes: "16x16",
type: "image/png",
},
{
url: "/api/logo?type=favicon-32",
sizes: "32x32",
type: "image/png",
},
],
},
manifest: "/site.webmanifest",
viewport: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0",
robots: recipe.robots,
other: {
"application-TileColor": "#ff0000",
},
themeColor: [
{
media: "(prefers-color-scheme: light)",
color: "#f9fafb",
},
{
media: "(prefers-color-scheme: dark)",
color: "#1C1C1C",
},
],
twitter: {
site: recipe.twitterSite,
creator: recipe.twitterCreator,
card: "summary_large_image",
},
});
export const preparePageMetadata = (recipe: PageMetadataRecipe): Metadata => ({
title: recipe.title,
alternates: {
canonical: recipe.canonical,
},
openGraph: {
description: truncateOnWord(recipe.description, 158),
url: recipe.canonical,
type: "website",
siteName: recipe.siteName,
title: recipe.title,
images: [recipe.image],
},
metadataBase: recipe.metadataBase,
});
+30 -3
View File
@@ -1,14 +1,21 @@
import { get } from "@vercel/edge-config";
import { collectEvents } from "next-collect/server";
import type { NextMiddleware } from "next/server";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
const middleware: NextMiddleware = async (req) => {
import { csp } from "@lib/csp";
import { abTestMiddlewareFactory } from "./abTest/middlewareFactory";
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-url", req.url);
if (!url.pathname.startsWith("/api")) {
//
// NOTE: When tRPC hits an error a 500 is returned, when this is received
@@ -32,6 +39,18 @@ const middleware: NextMiddleware = async (req) => {
}
const res = routingForms.handle(url);
const { nonce } = csp(req, res ?? null);
if (!process.env.CSP_POLICY) {
req.headers.set("x-csp", "not-opted-in");
} else if (!req.headers.get("x-csp")) {
// If x-csp not set by gSSP, then it's initialPropsOnly
req.headers.set("x-csp", "initialPropsOnly");
} else {
req.headers.set("x-csp", nonce ?? "");
}
if (res) {
return res;
}
@@ -45,6 +64,12 @@ const middleware: NextMiddleware = async (req) => {
requestHeaders.set("x-csp-enforce", "true");
}
requestHeaders.set("x-pathname", url.pathname);
const locale = await getLocale(req);
requestHeaders.set("x-locale", locale);
return NextResponse.next({
request: {
headers: requestHeaders,
@@ -74,11 +99,13 @@ export const config = {
* Paths required by routingForms.handle
*/
"/apps/routing_forms/:path*",
"/event-types",
"/future/event-types/",
],
};
export default collectEvents({
middleware,
middleware: abTestMiddlewareFactory(middleware),
...nextCollectBasicSettings,
cookieName: "__clnds",
extend: extendEventData,
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.4.8",
"version": "3.5.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -45,7 +45,7 @@
"@hookform/resolvers": "^2.9.7",
"@next-auth/prisma-adapter": "^1.0.4",
"@next/bundle-analyzer": "^13.1.6",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
@@ -168,8 +168,9 @@
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"node-html-parser": "^6.1.10",
"node-mocks-http": "^1.11.0",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.1",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
+2 -2
View File
@@ -1,7 +1,7 @@
import Head from "next/head";
import { useSearchParams } from "next/navigation";
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, showToast } from "@calcom/ui";
import { Copy } from "@calcom/ui/components/icon";
@@ -9,7 +9,7 @@ import { Copy } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
export default function Error500() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
return (
+5 -2
View File
@@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
description={markdownStrippedBio}
meeting={{
title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: null },
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
users: [{ username: `${user.username}`, name: `${user.name}` }],
}}
nextSeoProps={{
@@ -245,7 +245,7 @@ export type UserPageProps = {
allowSEOIndexing: boolean;
username: string | null;
};
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
themeBasis: string | null;
markdownStrippedBio: string;
safeBio: string;
@@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
metadata: true,
brandColor: true,
darkBrandColor: true,
avatarUrl: true,
organizationId: true,
organization: {
select: {
@@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
@@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
name: user.name,
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
away: user.away,
verified: user.verified,
})),
+61
View File
@@ -0,0 +1,61 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
const querySchema = z.object({
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
});
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
const errors = error.errors.map((err) => ({
path: err.path.join("."),
errorCode: `error.validation.${err.code}`,
}));
res.status(400).json({
message: "VALIDATION_ERROR",
errors,
});
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const result = querySchema.safeParse(req.query);
if (!result.success) {
return handleValidationError(res, result.error);
}
const { uuid: objectKey } = result.data;
let img;
try {
const { data } = await prisma.avatar.findUniqueOrThrow({
where: {
objectKey,
},
select: {
data: true,
},
});
img = data;
} catch (e) {
// If anything goes wrong or avatar is not found, use default avatar
res.writeHead(302, {
Location: AVATAR_FALLBACK,
});
res.end();
return;
}
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
});
res.end(imageResp);
}
+9
View File
@@ -2,9 +2,18 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
});
const session = await getServerSession({ req, res });
/* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1;
@@ -3,12 +3,20 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
import type { AppsStatus } from "@calcom/types/Calendar";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
});
const data: RecurringBookingCreateBody[] = req.body;
const session = await getServerSession({ req, res });
const createdBookings: BookingResponse[] = [];
+2 -1
View File
@@ -73,7 +73,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
}
return res.status(200);
if (!res.writableEnded) return res.status(200);
return res;
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
@@ -0,0 +1,116 @@
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export const config = {
api: {
bodyParser: false,
},
};
// This file is a catch-all for any integration related subscription/paid app.
const handleSubscriptionUpdate = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: subscription.status,
},
});
};
const handleSubscriptionDeleted = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
// should we delete the credential here rather than marking as inactive?
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: "inactive",
billingCycleStart: null,
},
});
};
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"customer.subscription.updated": handleSubscriptionUpdate,
"customer.subscription.deleted": handleSubscriptionDeleted,
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
const handler = webhookHandlers[event.type];
if (handler) {
await handler(event);
} else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}
+1
View File
@@ -79,6 +79,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
isTemplate={data.isTemplate}
dependencies={data.dependencies}
concurrentMeetings={data.concurrentMeetings}
paid={data.paid}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={
+3 -2
View File
@@ -1,15 +1,16 @@
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const router = useRouter();
const slug = searchParams?.get("slug") as string;
const { status } = useSession();
@@ -1,10 +1,10 @@
import { Prisma } from "@prisma/client";
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import Shell from "@calcom/features/shell/Shell";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { AppCategories } from "@calcom/prisma/enums";
@@ -13,7 +13,7 @@ import { AppCard, SkeletonText } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t, isLocaleReady } = useLocale();
const category = searchParams?.get("category");
+2 -2
View File
@@ -1,8 +1,8 @@
import { useSearchParams } from "next/navigation";
import { useReducer } from "react";
import { z } from "zod";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
@@ -122,7 +122,7 @@ type ModalState = {
};
export default function InstalledApps() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const category = searchParams?.get("category") as querySchemaType["category"];
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { GetStaticPropsContext } from "next";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import z from "zod";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import { X } from "@calcom/ui/components/icon";
@@ -18,7 +18,7 @@ const querySchema = z.object({
export default function Error() {
const { t } = useLocale();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { error } = querySchema.parse(searchParams);
const isTokenVerificationError = error?.toLowerCase() === "verification";
const errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
+22 -3
View File
@@ -4,7 +4,7 @@ import { jwtVerify } from "jose";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import type { CSSProperties } from "react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
@@ -17,6 +17,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
@@ -53,7 +54,7 @@ export default function Login({
totpEmail,
}: // eslint-disable-next-line @typescript-eslint/ban-types
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
const formSchema = z
@@ -278,7 +279,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
const { req, res } = context;
const { req, res, query } = context;
const session = await getServerSession({ req, res });
const ssr = await ssrInit(context);
@@ -318,6 +319,24 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
}
if (session) {
const { callbackUrl } = query;
if (callbackUrl) {
try {
const destination = getSafeRedirectUrl(callbackUrl as string);
if (destination) {
return {
redirect: {
destination,
permanent: false,
},
};
}
} catch (e) {
console.warn(e);
}
}
return {
redirect: {
destination: "/",
+2 -2
View File
@@ -1,9 +1,9 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Select } from "@calcom/ui";
@@ -16,7 +16,7 @@ export default function Authorize() {
const { status } = useSession();
const router = useRouter();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const client_id = searchParams?.get("client_id") as string;
const state = searchParams?.get("state") as string;
+3 -2
View File
@@ -1,12 +1,13 @@
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import PageWrapper from "@components/PageWrapper";
// To handle the IdP initiated login flow callback
export default function Page() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
useEffect(() => {
const code = searchParams?.get("code");
+3 -2
View File
@@ -1,11 +1,12 @@
import type { GetServerSidePropsContext } from "next";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
@@ -21,7 +22,7 @@ import { ssrInit } from "@server/lib/ssr";
function useSetStep() {
const router = useRouter();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const setStep = (newStep = 1) => {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
+3 -2
View File
@@ -1,6 +1,6 @@
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
@@ -9,6 +9,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
import stripe from "@calcom/features/ee/payments/server/stripe";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import prisma from "@calcom/prisma";
@@ -22,7 +23,7 @@ import { ssrInit } from "@server/lib/ssr";
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Provider(props: SSOProviderPageProps) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const router = useRouter();
useEffect(() => {
+3 -2
View File
@@ -1,10 +1,11 @@
import { signIn } from "next-auth/react";
import Head from "next/head";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import z from "zod";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast } from "@calcom/ui";
@@ -54,7 +55,7 @@ const querySchema = z.object({
});
export default function Verify() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const router = useRouter();
const routerQuery = useRouterQuery();
+3 -2
View File
@@ -1,4 +1,4 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
@@ -8,6 +8,7 @@ import Schedule from "@calcom/features/schedules/components/Schedule";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { availabilityAsString } from "@calcom/lib/availability";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
@@ -83,7 +84,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
};
export default function Availability() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
+3 -2
View File
@@ -1,11 +1,12 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { useCallback } from "react";
import { getLayout } from "@calcom/features/MainLayout";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { ShellMain } from "@calcom/features/shell/Shell";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -131,7 +132,7 @@ const WithQuery = withQuery(trpc.viewer.availability.list as any);
export default function AvailabilityPage() {
const { t } = useLocale();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const router = useRouter();
const pathname = usePathname();
+11 -130
View File
@@ -1,139 +1,20 @@
import dayjs from "@calcom/dayjs";
import Shell from "@calcom/features/shell/Shell";
import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter";
import { getLayout } from "@calcom/features/troubleshooter/layout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
type User = RouterOutputs["viewer"]["me"];
export interface IBusySlot {
start: string | Date;
end: string | Date;
title?: string;
source?: string | null;
}
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
const selectedDate = dayjs(date);
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
{
username: user.username || "",
dateFrom: selectedDate.startOf("day").utc().format(),
dateTo: selectedDate.endOf("day").utc().format(),
withSource: true,
},
{
enabled: !!user.username,
}
);
const overrides =
data?.dateOverrides.reduce((acc, override) => {
if (
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
)
return acc;
acc.push({ ...override, source: "Date override" });
return acc;
}, [] as IBusySlot[]) || [];
return (
<div className="bg-default max-w-xl overflow-hidden rounded-md shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none bg-inherit p-0"
defaultValue={formattedSelectedDate}
onChange={(e) => {
if (e.target.value) setSelectedDate(e.target.value);
}}
/>
<small className="text-muted block">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{(() => {
if (isLoading)
return (
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
);
if (data && (data.busy.length > 0 || overrides.length > 0))
return [...data.busy, ...overrides]
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={dayjs(slot.start).format("HH:mm")}
className="bg-subtle overflow-hidden rounded-md"
data-testid="troubleshooter-busy-time">
<div className="text-emphasis px-4 py-5 sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="text-default font-medium" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="text-default font-medium" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
</div>
</div>
));
return (
<div className="bg-subtle overflow-hidden rounded-md">
<div className="text-emphasis px-4 py-5 sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
);
})()}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
};
export default function Troubleshoot() {
const { data, isLoading } = trpc.viewer.me.useQuery();
function TroubleshooterPage() {
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}>
{!isLoading && data && <AvailabilityView user={data} />}
</Shell>
</div>
<>
<HeadSeo title={t("troubleshoot")} description={t("troubleshoot_availability")} />
<Troubleshooter month={null} />
</>
);
}
Troubleshoot.PageWrapper = PageWrapper;
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? `0${h}` : h;
const ms = m < 10 ? `0${m}` : m;
return `${hs}:${ms}`;
}
TroubleshooterPage.getLayout = getLayout;
TroubleshooterPage.PageWrapper = PageWrapper;
export default TroubleshooterPage;
+3 -2
View File
@@ -4,7 +4,7 @@ import { createEvent } from "ics";
import type { GetServerSidePropsContext } from "next";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { RRule } from "rrule";
import { z } from "zod";
@@ -36,6 +36,7 @@ import {
} from "@calcom/lib/date-fns";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
@@ -98,7 +99,7 @@ export default function Success(props: SuccessProps) {
const router = useRouter();
const routerQuery = useRouterQuery();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const {
allRemainingBookings,
isSuccessBookingPage,
+24 -2
View File
@@ -5,6 +5,7 @@ import React from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import { getLayout } from "@calcom/features/MainLayout";
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
@@ -20,6 +21,7 @@ import { Alert, Button, EmptyScreen } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import useMeQuery from "@lib/hooks/useMeQuery";
import PageWrapper from "@components/PageWrapper";
import BookingListItem from "@components/booking/BookingListItem";
@@ -78,6 +80,7 @@ export default function Bookings() {
const { data: filterQuery } = useFilterQuery();
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
const { t } = useLocale();
const user = useMeQuery().data;
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
@@ -119,7 +122,10 @@ export default function Bookings() {
}
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
return new Date(booking.startTime).toDateString() !== new Date().toDateString();
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
}
return true;
};
@@ -132,7 +138,11 @@ export default function Bookings() {
recurringInfoToday = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
);
return new Date(booking.startTime).toDateString() === new Date().toDateString();
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
})
)[0] || [];
@@ -166,6 +176,12 @@ export default function Bookings() {
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
@@ -190,6 +206,12 @@ export default function Bookings() {
return (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
+74
View File
@@ -0,0 +1,74 @@
import { getLayout } from "@calcom/features/MainLayout";
import { ShellMain } from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui";
import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
export default function EnterprisePage() {
const { t } = useLocale();
const features = [
{
icon: <Globe className="h-5 w-5 text-red-500" />,
title: t("branded_subdomain"),
description: t("branded_subdomain_description"),
},
{
icon: <BarChart className="h-5 w-5 text-blue-500" />,
title: t("org_insights"),
description: t("org_insights_description"),
},
{
icon: <Paintbrush className="h-5 w-5 text-pink-500" />,
title: t("extensive_whitelabeling"),
description: t("extensive_whitelabeling_description"),
},
{
icon: <Users className="h-5 w-5 text-orange-500" />,
title: t("unlimited_teams"),
description: t("unlimited_teams_description"),
},
{
icon: <CreditCard className="h-5 w-5 text-green-500" />,
title: t("unified_billing"),
description: t("unified_billing_description"),
},
{
icon: <Lock className="h-5 w-5 text-purple-500" />,
title: t("advanced_managed_events"),
description: t("advanced_managed_events_description"),
},
];
return (
<div>
<ShellMain heading="Enterprise" subtitle={t("enterprise_description")}>
<UpgradeTip
plan="enterprise"
title={t("create_your_org")}
description={t("create_your_org_description")}
features={features}
background="/tips/enterprise"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://i.cal.com/sales/enterprise?duration=25" target="_blank">
{t("contact_sales")}
</Button>
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
<>Create Org</>
</UpgradeTip>
</ShellMain>
</div>
);
}
EnterprisePage.PageWrapper = PageWrapper;
EnterprisePage.getLayout = getLayout;
+8 -5
View File
@@ -1,8 +1,10 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { User } from "@prisma/client";
import { Trans } from "next-i18next";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import type { FC } from "react";
import { memo, useEffect, useState } from "react";
import { z } from "zod";
@@ -19,6 +21,7 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF
import { ShellMain } from "@calcom/features/shell/Shell";
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
@@ -208,7 +211,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -386,7 +389,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
return (
<li key={type.id}>
<div className="hover:bg-muted flex w-full items-center justify-between">
<div className="hover:bg-muted flex w-full items-center justify-between transition">
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
{!(firstItem && firstItem.id === type.id) && (
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
@@ -840,7 +843,7 @@ const Main = ({
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
}) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
if (!data || status === "loading") {
@@ -904,7 +907,7 @@ const Main = ({
const EventTypesPage = () => {
const { t } = useLocale();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { open } = useIntercom();
const { data: user } = useMeQuery();
const [showProfileBanner, setShowProfileBanner] = useState(false);
+1
View File
@@ -46,6 +46,7 @@ export default function InsightsPage() {
<div>
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
@@ -250,10 +250,7 @@ const AppearanceView = ({
/>
{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."
/>
<Alert severity="warning" message={t("light_theme_contrast_error")} />
</div>
) : null}
</div>
@@ -282,10 +279,7 @@ const AppearanceView = ({
/>
{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."
/>
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
</div>
) : null}
</div>
+12 -31
View File
@@ -10,7 +10,7 @@ import OrganizationMemberAvatar from "@calcom/features/ee/organizations/componen
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 { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
@@ -72,37 +72,22 @@ interface DeleteAccountValues {
type FormValues = {
username: string;
avatar: string | null;
avatar: string;
name: string;
email: string;
bio: string;
};
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
return !fetchedImgSrc || 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>(undefined);
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
onSuccess: async (userData) => {
try {
if (!userData.organization) {
const res = await fetch(userData.avatar);
if (res.url) setFetchedImgSrc(res.url);
} else {
setFetchedImgSrc("");
}
} catch (err) {
setFetchedImgSrc("");
}
},
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
enabled: !isLoading && !user?.avatarUrl,
});
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
@@ -234,7 +219,7 @@ const ProfileView = () => {
const defaultValues = {
username: user.username || "",
avatar: user.avatar || "",
avatar: getUserAvatarUrl(user),
name: user.name || "",
email: user.email || "",
bio: user.bio || "",
@@ -251,8 +236,7 @@ const ProfileView = () => {
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading}
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
userAvatar={user.avatar}
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
user={user}
userOrganization={user.organization}
onSubmit={(values) => {
@@ -398,7 +382,6 @@ const ProfileForm = ({
extraField,
isLoading = false,
isFallbackImg,
userAvatar,
user,
userOrganization,
}: {
@@ -407,7 +390,6 @@ const ProfileForm = ({
extraField?: React.ReactNode;
isLoading: boolean;
isFallbackImg: boolean;
userAvatar: string;
user: RouterOutputs["viewer"]["me"];
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => {
@@ -416,7 +398,7 @@ const ProfileForm = ({
const profileFormSchema = z.object({
username: z.string(),
avatar: z.string().nullable(),
avatar: z.string(),
name: z
.string()
.trim()
@@ -438,7 +420,6 @@ const ProfileForm = ({
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
return (
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
@@ -447,7 +428,7 @@ const ProfileForm = ({
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
const organization =
userOrganization && userOrganization.id
? {
@@ -474,7 +455,7 @@ const ProfileForm = ({
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
imageSrc={value}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
/>
@@ -482,7 +463,7 @@ const ProfileForm = ({
<Button
color="secondary"
onClick={() => {
formMethods.setValue("avatar", null, { shouldDirty: true });
formMethods.setValue("avatar", "", { shouldDirty: true });
}}>
{t("remove")}
</Button>
+4 -4
View File
@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import type { CSSProperties } from "react";
import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
@@ -13,6 +12,7 @@ import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
@@ -37,7 +37,7 @@ type SignupProps = inferSSRProps<typeof getServerSideProps>;
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain] = email.split("@");
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
@@ -53,7 +53,7 @@ function addOrUpdateQueryParam(url: string, key: string, value: string) {
}
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
const flags = useFlagMap();
@@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
methods.clearErrors("apiError");
}
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
methods.setValue(
"username",
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
+23 -12
View File
@@ -11,10 +11,6 @@ let pages = (exports.pages = glob
)
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")));
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
// It would also not match /free/30min/embed because we are ensuring just two slashes
@@ -27,11 +23,26 @@ let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
));
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
exports.orgUserTypeRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
"/|"
)}|_next/|public/)[^/]+)/:type((?!avatar\.png)[^/]+)`;
exports.orgUserTypeEmbedRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
"/|"
)}|_next/|public/)[^/]+)/:type/embed`;
/**
* Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
*/
function getRegExpMatchingAllReservedRoutes(suffix) {
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
const nextJsSpecialPaths = ["_next", "public"];
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes).concat(nextJsSpecialPaths);
return beforeRewriteExcludePages.join(`${suffix}|`) + suffix;
}
// To handle /something
exports.orgUserRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/?$")})[a-zA-Z0-9\-_]+)`;
// To handle /something/somethingelse
exports.orgUserTypeRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes(
"/"
)})[^/]+)/:type((?!avatar\.png)[^/]+)`;
// To handle /something/somethingelse/embed
exports.orgUserTypeEmbedRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/")})[^/]+)/:type/embed`;
+1
View File
@@ -40,6 +40,7 @@ test.describe("Availablity tests", () => {
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
await page.goto(troubleshooterURL);
await page.waitForLoadState("networkidle");
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
});
});
+91
View File
@@ -1,6 +1,8 @@
import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random";
import { SchedulingType } from "@calcom/prisma/client";
import type { Schedule, TimeRange } from "@calcom/types/schedule";
import { test } from "./lib/fixtures";
import {
@@ -342,3 +344,92 @@ test.describe("Booking on different layouts", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
});
test.describe("Booking round robin event", () => {
test.beforeEach(async ({ page, users }) => {
const teamMatesObj = [{ name: "teammate-1" }];
const dateRanges: TimeRange = {
start: new Date(new Date().setUTCHours(10, 0, 0, 0)), //one hour after default schedule (teammate-1's schedule)
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
};
const schedule: Schedule = [[], [dateRanges], [dateRanges], [dateRanges], [dateRanges], [dateRanges], []];
const testUser = await users.create(
{ username: "test-user", name: "Test User", email: "testuser@example.com", schedule },
{
hasTeam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
teamEventLength: 120,
teammates: teamMatesObj,
}
);
const team = await testUser.getFirstTeam();
await page.goto(`/team/${team.team.slug}`);
});
test("Does not book round robin host outside availability with date override", async ({ page, users }) => {
const [testUser] = users.get();
testUser.apiLogin();
const team = await testUser.getFirstTeam();
// Click first event type (round robin)
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="incrementMonth"]');
// books 9AM slots for 120 minutes (test-user is not available at this time, availability starts at 10)
await page.locator('[data-testid="time"]').nth(0).click();
await page.waitForLoadState("networkidle");
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const host = await page.locator('[data-testid="booking-host-name"]');
const hostName = await host.innerText();
//expect teammate-1 to be booked, test-user is not available at this time
expect(hostName).toBe("teammate-1");
// make another booking to see if also for the second booking teammate-1 is booked
await page.goto(`/team/${team.team.slug}`);
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="incrementMonth"]');
await page.click('[data-testid="incrementMonth"]');
// Again book a 9AM slot for 120 minutes where test-user is not available
await page.locator('[data-testid="time"]').nth(0).click();
await page.waitForLoadState("networkidle");
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const hostSecondBooking = await page.locator('[data-testid="booking-host-name"]');
const hostNameSecondBooking = await hostSecondBooking.innerText();
expect(hostNameSecondBooking).toBe("teammate-1"); // teammate-1 should be booked again
});
});
+127 -3
View File
@@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Event Types A/B tests", () => {
test("should point to the /future/event-types page", async ({ page, users, context }) => {
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
const user = await users.create();
await user.apiLogin();
await page.goto("/event-types");
await page.waitForLoadState();
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByRole("heading", { name: "Event Types" });
await expect(locator).toBeVisible();
});
});
test.describe("Event Types tests", () => {
test.describe("user", () => {
testBothFutureAndLegacyRoutes.describe("user", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
});
@@ -254,6 +285,87 @@ test.describe("Event Types tests", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/);
});
test("can add single organizer address location without display location public option", async ({
page,
}) => {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
await page.waitForURL((url) => {
return !!url.pathname.match(/\/event-types\/.+/);
});
const locationAddress = "New Delhi";
await fillLocation(page, locationAddress, 0, false);
await page.locator("[data-testid=update-eventtype]").click();
await page.goto("/event-types");
const previewLink = await page
.locator("[data-testid=preview-link-button]")
.first()
.getAttribute("href");
await page.goto(previewLink ?? "");
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator(`[data-testid="where"]`)).toHaveText(locationAddress);
});
test("can select 'display on booking page' option when multiple organizer input type are present", async ({
page,
}) => {
await gotoFirstEventType(page);
await page.locator("#location-select").click();
await page.locator(`text="Link meeting"`).click();
const locationInputName = (idx: number) => `locations[${idx}].link`;
const testUrl1 = "https://cal.ai/";
await page.locator(`input[name="${locationInputName(0)}"]`).fill(testUrl1);
await page.locator("[data-testid=display-location]").last().check();
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
await page.locator("[data-testid=add-location]").click();
const testUrl2 = "https://cal.com/ai";
await page.locator(`text="Link meeting"`).last().click();
await page.locator(`input[name="${locationInputName(1)}"]`).waitFor();
await page.locator(`input[name="${locationInputName(1)}"]`).fill(testUrl2);
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
// Remove Both of the locations
const removeButtomId = "delete-locations.0.type";
await page.getByTestId(removeButtomId).click();
await page.getByTestId(removeButtomId).click();
// Add Multiple Organizer Phone Number options
await page.locator("#location-select").click();
await page.locator(`text="Organizer Phone Number"`).click();
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
const testPhoneInputValue1 = "9199999999";
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).waitFor();
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).fill(testPhoneInputValue1);
await page.locator("[data-testid=display-location]").last().check();
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
await page.locator("[data-testid=add-location]").click();
const testPhoneInputValue2 = "9188888888";
await page.locator(`text="Organizer Phone Number"`).last().click();
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).waitFor();
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).fill(testPhoneInputValue2);
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
});
});
});
});
@@ -293,7 +405,7 @@ async function addAnotherLocation(page: Page, locationOptionText: string) {
await page.locator(`text="${locationOptionText}"`).click();
}
const fillLocation = async (page: Page, inputText: string, index: number) => {
const fillLocation = async (page: Page, inputText: string, index: number, selectDisplayLocation = true) => {
// Except the first location, dropdown automatically opens when adding another location
if (index == 0) {
await page.locator("#location-select").last().click();
@@ -303,5 +415,17 @@ const fillLocation = async (page: Page, inputText: string, index: number) => {
const locationInputName = `locations[${index}].address`;
await page.locator(`input[name="${locationInputName}"]`).waitFor();
await page.locator(`input[name="locations[${index}].address"]`).fill(inputText);
await page.locator("[data-testid=display-location]").last().check();
if (selectDisplayLocation) {
await page.locator("[data-testid=display-location]").last().check();
}
};
const checkDisplayLocation = async (page: Page) => {
await page.locator("[data-testid=display-location]").last().check();
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked();
};
const unCheckDisplayLocation = async (page: Page) => {
await page.locator("[data-testid=display-location]").last().uncheck();
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked({ checked: false });
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+34
View File
@@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";
declare global {
interface Window {
E2E_CLIPBOARD_VALUE?: string;
}
}
export type Window = typeof window;
// creates the single server fixture
export const createClipboardFixture = (page: Page) => {
return {
reset: async () => {
await page.evaluate(() => {
delete window.E2E_CLIPBOARD_VALUE;
});
},
get: async () => {
return getClipboardValue({ page });
},
};
};
function getClipboardValue({ page }: { page: Page }) {
return page.evaluate(() => {
return new Promise<string>((resolve, reject) => {
setInterval(() => {
if (!window.E2E_CLIPBOARD_VALUE) return;
resolve(window.E2E_CLIPBOARD_VALUE);
}, 500);
setTimeout(() => reject(new Error("Timeout")), 1000);
});
});
}
@@ -204,7 +204,7 @@ export function createBookingPageFixture(page: Page) {
placeholder?: string
) => {
await page.getByTestId("add-field").click();
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("test-field-type").click();
await page.getByTestId(`select-option-${questionType}`).click();
await page.getByLabel("Identifier").dblclick();
await page.getByLabel("Identifier").fill(identifier);
+41 -7
View File
@@ -10,6 +10,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { Schedule } from "@calcom/types/schedule";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import { TimeZoneEnum } from "./types";
@@ -46,6 +47,7 @@ const createTeamEventType = async (
schedulingType?: SchedulingType;
teamEventTitle?: string;
teamEventSlug?: string;
teamEventLength?: number;
}
) => {
return await prisma.eventType.create({
@@ -65,10 +67,16 @@ const createTeamEventType = async (
id: user.id,
},
},
hosts: {
create: {
userId: user.id,
isFixed: scenario?.schedulingType === SchedulingType.COLLECTIVE ? true : false,
},
},
schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE,
title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`,
slug: scenario?.teamEventSlug ?? `${teamEventSlug}-team-id-${team.id}`,
length: 30,
length: scenario?.teamEventLength ?? 30,
},
});
};
@@ -78,12 +86,14 @@ const createTeamAndAddUser = async (
user,
isUnpublished,
isOrg,
isOrgVerified,
hasSubteam,
organizationId,
}: {
user: { id: number; username: string | null; role?: MembershipRole };
user: { id: number; email: string; username: string | null; role?: MembershipRole };
isUnpublished?: boolean;
isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true;
organizationId?: number | null;
},
@@ -95,7 +105,14 @@ const createTeamAndAddUser = async (
};
data.metadata = {
...(isUnpublished ? { requestedSlug: slug } : {}),
...(isOrg ? { isOrganization: true } : {}),
...(isOrg
? {
isOrganization: true,
isOrganizationVerified: !!isOrgVerified,
orgAutoAcceptEmail: user.email.split("@")[1],
isOrganizationConfigured: false,
}
: {}),
};
data.slug = !isUnpublished ? slug : undefined;
if (isOrg && hasSubteam) {
@@ -135,7 +152,9 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
schedulingType?: SchedulingType;
teamEventTitle?: string;
teamEventSlug?: string;
teamEventLength?: number;
isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true;
isUnpublished?: true;
} = {}
@@ -283,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
if (scenario.hasTeam) {
const team = await createTeamAndAddUser(
{
user: { id: user.id, username: user.username, role: "OWNER" },
user: { id: user.id, email: user.email, username: user.username, role: "OWNER" },
isUnpublished: scenario.isUnpublished,
isOrg: scenario.isOrg,
isOrgVerified: scenario.isOrgVerified,
hasSubteam: scenario.hasSubteam,
organizationId: opts?.organizationId,
},
@@ -376,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
await prisma.user.delete({ where: { id } });
store.users = store.users.filter((b) => b.id !== id);
},
set: async (email: string) => {
const user = await prisma.user.findUniqueOrThrow({
where: { email },
include: userIncludes,
});
const userFixture = createUserFixture(user, store.page);
store.users.push(userFixture);
return userFixture;
},
};
};
@@ -400,7 +429,11 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
eventTypes: user.eventTypes,
routingForms: user.routingForms,
self,
apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page),
apiLogin: async (password?: string) =>
apiLogin({ ...(await self()), password: password || user.username }, store.page),
/**
* @deprecated use apiLogin instead
*/
login: async () => login({ ...(await self()), password: user.username }, store.page),
logout: async () => {
await page.goto("/auth/logout");
@@ -489,6 +522,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
// ignores adding the worker-index after username
useExactUsername?: boolean;
roleInOrganization?: MembershipRole;
schedule?: Schedule;
};
// creates the actual user in the db.
@@ -520,7 +554,7 @@ const createUser = (
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
data: getAvailabilityFromSchedule(opts?.schedule ?? DEFAULT_SCHEDULE),
},
},
},
@@ -641,7 +675,7 @@ 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("[data-testid='app-switch']").first().click();
await page.getByPlaceholder("Price").fill("100");
await page.getByTestId("update-eventtype").click();
}
+94 -1
View File
@@ -1,6 +1,10 @@
import { expect } from "@playwright/test";
import type Prisma from "@prisma/client";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { test } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures";
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
@@ -34,6 +38,95 @@ test.describe("Stripe integration", () => {
});
});
test("when enabling Stripe, credentialId is included", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.setupEventWithPrice(eventType);
// Need to wait for the DB to be updated with the metadata
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
// Check event type metadata to see if credentialId is included
const eventTypeMetadata = await prisma.eventType.findFirst({
where: {
id: eventType.id,
},
select: {
metadata: true,
},
});
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
const stripeAppMetadata = metadata?.apps?.stripe;
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
});
test("when enabling Stripe, team credentialId is included", 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.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamEvent = await owner.getFirstTeamEvent(team.id);
await page.goto("/apps/stripe");
/** We start the Stripe flow */
await Promise.all([
page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"),
page.click('[data-testid="install-app-button"]'),
page.click('[data-testid="anything else"]'),
]);
await Promise.all([
page.waitForURL("/apps/installed/payment?hl=stripe"),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
await owner.setupEventWithPrice(teamEvent);
// Need to wait for the DB to be updated with the metadata
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
// Check event type metadata to see if credentialId is included
const eventTypeMetadata = await prisma.eventType.findFirst({
where: {
id: teamEvent.id,
},
select: {
metadata: true,
},
});
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
const stripeAppMetadata = metadata?.apps?.stripe;
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
});
test("Can book a paid booking", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
@@ -174,7 +267,7 @@ test.describe("Stripe integration", () => {
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.getByTestId("stripe-currency-select").click();
await page.locator("#react-select-2-input").fill("mexi");
await page.locator("#react-select-2-option-81").click();
+9
View File
@@ -4,10 +4,12 @@ import type { API } from "mailhog";
import mailhog from "mailhog";
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createClipboardFixture } from "../fixtures/clipboard";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments";
@@ -28,6 +30,7 @@ export interface Fixtures {
emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
clipboard: ReturnType<typeof createClipboardFixture>;
}
declare global {
@@ -85,6 +88,8 @@ export const test = base.extend<Fixtures>({
const mailhogAPI = mailhog();
await use(mailhogAPI);
} else {
//FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it
logger.warn("Mailhog is not enabled - Skipping Emails verification");
await use(undefined);
}
},
@@ -92,4 +97,8 @@ export const test = base.extend<Fixtures>({
const bookingPage = createBookingPageFixture(page);
await use(bookingPage);
},
clipboard: async ({ page }, use) => {
const clipboard = createClipboardFixture(page);
await use(clipboard);
},
});
@@ -0,0 +1,33 @@
import { test } from "./fixtures";
export type RouteVariant = "future" | "legacy";
const routeVariants = ["future", "legacy"];
/**
* Small wrapper around test.describe().
* When using testbothFutureLegacyRoutes.describe() instead of test.describe(), this will run the specified
* tests twice. One with the pages route, and one with the new app dir "future" route. It will also add the route variant
* name to the test name for easier debugging.
* Finally it also adds a parameter routeVariant to your testBothFutureAndLegacyRoutes.describe() callback, which
* can be used to do any conditional rendering in the test for a specific route variant (should be as little
* as possible).
*
* See apps/web/playwright/event-types.e2e.ts for an example.
*/
export const testBothFutureAndLegacyRoutes = {
describe: (testName: string, testFn: (routeVariant: RouteVariant) => void) => {
routeVariants.forEach((routeVariant) => {
test.describe(`${testName} -- ${routeVariant}`, () => {
if (routeVariant === "future") {
test.beforeEach(({ context }) => {
context.addCookies([
{ name: "x-calcom-future-routes-override", value: "1", url: "http://localhost:3000" },
]);
});
}
testFn(routeVariant as RouteVariant);
});
});
},
};
+11
View File
@@ -1,11 +1,13 @@
import type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { createHash } from "crypto";
import EventEmitter from "events";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import type { API, Messages } from "mailhog";
import { totp } from "otplib";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees(
});
return { user, eventType, booking };
}
export function generateTotpCode(email: string) {
const secret = createHash("md5")
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
totp.options = { step: 90 };
return totp.generate(secret);
}
+2 -2
View File
@@ -24,7 +24,7 @@ test.describe("2FA Tests", async () => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const userPassword = user.username!;
await user.login();
await user.apiLogin();
// expects the home page for an authorized user
await page.goto("/settings/security/two-factor-auth");
@@ -94,7 +94,7 @@ test.describe("2FA Tests", async () => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const userPassword = user.username!;
await user.login();
await user.apiLogin();
// expects the home page for an authorized user
await page.goto("/settings/security/two-factor-auth");
+30 -2
View File
@@ -1,6 +1,9 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
import { localize } from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
@@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => {
await page.goto("/event-types");
await page.getByTestId("event-types").locator('a[title="managed"]').click();
await page.getByTestId("vertical-tab-assignment").click();
await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click();
await page.getByTestId("assignment-dropdown").click();
await page.getByTestId(`select-option-${memberUser.id}`).click();
await page.locator('[type="submit"]').click();
await page.getByTestId("toast-success").waitFor();
});
await adminUser.logout();
await test.step("Managed event type can use Organizer's default app as location", async () => {
await page.getByTestId("vertical-tab-event_setup_tab_title").click();
await page.locator("#location-select").click();
const optionText = (await localize("en"))("organizer_default_conferencing_app");
await page.locator(`text=${optionText}`).click();
await page.locator("[data-testid=update-eventtype]").click();
await page.getByTestId("toast-success").waitFor();
await page.waitForLoadState("networkidle");
await page.getByTestId("vertical-tab-assignment").click();
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
});
await test.step("Managed event type has locked fields for added member", async () => {
await adminUser.logout();
// Coming back as member user to see if there is a managed event present after assignment
await memberUser.apiLogin();
await page.goto("/event-types");
@@ -91,3 +113,9 @@ test.describe("Managed Event Types tests", () => {
});
});
});
async function gotoBookingPage(page: Page) {
const previewLink = await page.getByTestId("preview-button").getAttribute("href");
await page.goto(previewLink ?? "");
}
@@ -0,0 +1,28 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
// eslint-disable-next-line no-restricted-imports
import type { API, Messages } from "mailhog";
import { getEmailsReceivedByUser } from "../lib/testUtils";
export async function expectInvitationEmailToBeReceived(
page: Page,
emails: API | undefined,
userEmail: string,
subject: string,
returnLink?: string
) {
if (!emails) return null;
// We need to wait for the email to go through, otherwise it will fail
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(5000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
expect(receivedEmails?.total).toBe(1);
const [firstReceivedEmail] = (receivedEmails as Messages).items;
expect(firstReceivedEmail.subject).toBe(subject);
if (!returnLink) return;
const dom = new JSDOM(firstReceivedEmail.html);
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
return anchor?.getAttribute("href");
}
@@ -0,0 +1,143 @@
import { expect } from "@playwright/test";
import path from "path";
import { test } from "../lib/fixtures";
import { generateTotpCode } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
test.afterAll(({ users, emails }) => {
users.deleteAll();
emails?.deleteAll();
});
function capitalize(text: string) {
if (!text) {
return text;
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
test.describe("Organization", () => {
test("should be able to create an organization and complete onboarding", async ({
page,
users,
emails,
}) => {
const orgOwner = await users.create();
const orgDomain = `${orgOwner.username}-org`;
const orgName = capitalize(`${orgOwner.username}-org`);
await orgOwner.apiLogin();
await page.goto("/settings/organizations/new");
await page.waitForLoadState("networkidle");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.locator(".text-red-700")).toHaveCount(3);
// Happy path
await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`);
expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName);
expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain);
await page.locator("button[type=submit]").click();
await page.waitForLoadState("networkidle");
// Check admin email about code verification
await expectInvitationEmailToBeReceived(
page,
emails,
`john@${orgOwner.username}-org.com`,
"Verify your email to create an organization"
);
await test.step("Verification", async () => {
// Code verification
await expect(page.locator("#modal-title")).toBeVisible();
await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`));
await page.locator("button:text('Verify')").click();
// Check admin email about DNS pending action
await expectInvitationEmailToBeReceived(
page,
emails,
"admin@example.com",
"New organization created: pending action"
);
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/set-password");
});
});
await test.step("Admin password", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.locator(".text-red-700")).toHaveCount(3); // 3 password hints
// Happy path
await page.locator("input[name='password']").fill("ADMIN_user2023$");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/about");
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.locator('button:text("Upload")').click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.locator('button:text("Save")').click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-admins");
});
await test.step("On-board administrators", async () => {
// Required field
await page.locator("button[type=submit]").click();
// Happy path
await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`);
await page.locator("button[type=submit]").click();
// Check if invited admin received the invitation email
await expectInvitationEmailToBeReceived(
page,
emails,
`rick@${orgDomain}.com`,
`${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
);
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Create teams", async () => {
// Initial state
await expect(page.locator('input[name="teams.0.name"]')).toHaveCount(1);
await expect(page.locator('button:text("Continue")')).toBeDisabled();
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
await expect(page.locator('button:text("Continue")')).toBeEnabled();
// Adding another team
await page.locator('button:text("Add a team")').click();
await expect(page.locator('button:text("Continue")')).toBeDisabled();
await expect(page.locator('input[name="teams.1.name"]')).toHaveCount(1);
await page.locator('input[name="teams.1.name"]').fill("Sales");
await expect(page.locator('button:text("Continue")')).toBeEnabled();
// Finishing the creation wizard
await page.locator('button:text("Continue")').click();
await page.waitForURL("/event-types");
});
});
});
@@ -0,0 +1,119 @@
import { expect } from "@playwright/test";
import { test } from "../lib/fixtures";
import { expectInvitationEmailToBeReceived } from "./expects";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users, emails, clipboard }) => {
clipboard.reset();
await users.deleteAll();
emails?.deleteAll();
});
test.describe("Organization", () => {
test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
const { team: org } = await orgOwner.getOrg();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (external user)", async () => {
const invitedUserEmail = `rick@domain-${Date.now()}.com`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
// Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
// Follow invite link in new window
const context = await browser.newContext();
const newPage = await context.newPage();
newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
// Check required fields
await newPage.locator("button[type=submit]").click();
await expect(newPage.locator(".text-red-700")).toHaveCount(3); // 3 password hints
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await context.close();
await newPage.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
await page.goto("/settings/organizations/members");
page.locator(`[data-testid="login-form"]`);
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
await test.step("To the organization by invite link", async () => {
// Get the invite link
await page.locator('button:text("Add")').click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
const inviteLink = await clipboard.get();
await page.waitForLoadState("networkidle");
// Follow invite link in new window
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("networkidle");
// Check required fields
await inviteLinkPage.locator("button[type=submit]").click();
await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints
// Happy path
await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
});
});
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
const { team: org } = await orgOwner.getOrg();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (internal user)", async () => {
const invitedUserEmail = `rick@example.com`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`
);
// Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
});
});
+3 -3
View File
@@ -77,7 +77,7 @@ test.describe("Payment app", () => {
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("stripe-currency-select").click();
await page.getByTestId("select-option-usd").click();
await page.getByTestId("price-input-stripe").click();
@@ -123,10 +123,10 @@ test.describe("Payment app", () => {
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.getByTestId("paypal-currency-select").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.getByTestId("paypal-payment-option-select").click();
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
await page.getByTestId("update-eventtype").click();
@@ -0,0 +1,56 @@
import { expect } from "@playwright/test";
import path from "path";
import { prisma } from "@calcom/prisma";
import { test } from "../lib/fixtures";
test.describe("UploadAvatar", async () => {
test("can upload an image", async ({ page, users }) => {
const user = await users.create({});
await user.apiLogin();
await test.step("Can upload an initial picture", async () => {
await page.goto("/settings/my-account/profile");
await page.getByTestId("open-upload-avatar-dialog").click();
const [fileChooser] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting.
page.waitForEvent("filechooser"),
// Opens the file chooser.
page.getByTestId("open-upload-image-filechooser").click(),
]);
await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`);
await page.getByTestId("upload-avatar").click();
await page.locator("input[name='name']").fill(user.email);
await page.getByText("Update").click();
await page.waitForSelector("text=Settings updated successfully");
const response = await prisma.avatar.findUniqueOrThrow({
where: {
teamId_userId: {
userId: user.id,
teamId: 0,
},
},
});
// todo: remove this; ideally the organization-avatar is updated the moment
// 'Settings updated succesfully' is saved.
await page.waitForLoadState("networkidle");
await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey);
const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, {
maxRedirects: 0,
});
await expect(urlResponse?.status()).toBe(200);
});
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import type { API, Messages } from "mailhog";
import { getEmailsReceivedByUser } from "../lib/testUtils";
export async function expectInvitationEmailToBeReceived(
page: Page,
emails: API | undefined,
userEmail: string,
subject: string,
returnLink?: string
) {
if (!emails) return null;
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(10000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
expect(receivedEmails?.total).toBe(1);
const [firstReceivedEmail] = (receivedEmails as Messages).items;
expect(firstReceivedEmail.subject).toBe(subject);
if (!returnLink) return;
const dom = new JSDOM(firstReceivedEmail.html);
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
return anchor?.getAttribute("href");
}
@@ -0,0 +1,124 @@
import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { test } from "../lib/fixtures";
import { localize } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users, emails, clipboard }) => {
clipboard.reset();
await users.deleteAll();
emails?.deleteAll();
});
test.describe("Team", () => {
test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => {
const t = await localize("en");
const teamOwner = await users.create(undefined, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
await test.step("To the team by email (external user)", async () => {
const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`;
await page.locator(`button:text("${t("add")}")`).click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${team.name}'s admin invited you to join the team ${team.name} on Cal.com`,
"signup?token"
);
//Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
// Follow invite link to new window
const context = await browser.newContext();
const newPage = await context.newPage();
await newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
// Check required fields
await newPage.locator("button[type=submit]").click();
await expect(newPage.locator('[data-testid="hint-error"]')).toHaveCount(3);
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await newPage.close();
await context.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
await test.step("To the team by invite link", async () => {
const user = await users.create({
email: `user-invite-${Date.now()}@domain.com`,
password: "P4ssw0rd!",
});
await page.locator(`button:text("${t("add")}")`).click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
const inviteLink = await clipboard.get();
await page.waitForLoadState("networkidle");
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("domcontentloaded");
await inviteLinkPage.locator("button[type=submit]").click();
await expect(inviteLinkPage.locator('[data-testid="field-error"]')).toHaveCount(2);
await inviteLinkPage.locator("input[name=email]").fill(user.email);
await inviteLinkPage.locator("input[name=password]").fill(user.username || "P4ssw0rd!");
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL(`${WEBAPP_URL}/teams**`);
});
});
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
const t = await localize("en");
const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (internal user)", async () => {
const invitedUserEmail = `rick@example.com`;
await page.locator(`button:text("${t("add")}")`).click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
await page.waitForLoadState("networkidle");
await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${teamOwner.name} invited you to join the team ${team.name} on Cal.com`
);
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
});
});
});
+4 -4
View File
@@ -134,7 +134,7 @@ test.describe("Teams - NonOrg", () => {
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
@@ -351,7 +351,7 @@ test.describe("Teams - Org", () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
await expect(page.locator("text=This page could not be found")).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,
@@ -370,7 +370,7 @@ test.describe("Teams - Org", () => {
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// All the teammates should be in the booking
for (const teammate of teamMatesObj) {
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
}
@@ -412,7 +412,7 @@ test.describe("Teams - Org", () => {
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
});
+3 -4
View File
@@ -103,12 +103,11 @@ test.describe("BOOKING_CREATED", async () => {
body.payload.videoCallData = dynamic;
body.payload.appsStatus = dynamic;
body.payload.metadata.videoCallUrl = dynamic;
expect(body).toMatchObject({
triggerEvent: "BOOKING_CREATED",
createdAt: "[redacted/dynamic]",
payload: {
type: "30 min",
type: "30-min",
title: "30 min between Nameless and Test Testson",
description: "",
additionalNotes: "",
@@ -236,7 +235,7 @@ test.describe("BOOKING_REJECTED", async () => {
triggerEvent: "BOOKING_REJECTED",
createdAt: "[redacted/dynamic]",
payload: {
type: "Opt in",
type: "opt-in",
title: "Opt in between Nameless and Test Testson",
customInputs: {},
startTime: "[redacted/dynamic]",
@@ -357,7 +356,7 @@ test.describe("BOOKING_REQUESTED", async () => {
triggerEvent: "BOOKING_REQUESTED",
createdAt: "[redacted/dynamic]",
payload: {
type: "Opt in",
type: "opt-in",
title: "Opt in between Nameless and Test Testson",
customInputs: {},
startTime: "[redacted/dynamic]",
+1 -1
View File
@@ -17,7 +17,7 @@ test.describe("Workflow tests", () => {
async ({ page, users }) => {
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await user.apiLogin();
await page.goto(`/workflows`);
await page.click('[data-testid="create-button"]');
@@ -268,6 +268,7 @@
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
"availability_settings": "إعدادات التوافرية",
"continue_without_calendar": "المتابعة من دون تقويم",
"continue_with": "الاستمرار مع {{appName}}",
"connect_your_calendar": "ربط التقويم لديك",
"connect_your_video_app": "اربط تطبيقات الفيديو لديك",
"connect_your_video_app_instructions": "قم بتوصيل تطبيقات الفيديو لديك لاستخدامها في أنواع الأحداث الخاصة بك.",
@@ -288,6 +289,8 @@
"when": "متى",
"where": "أين",
"add_to_calendar": "إضافة إلى التقويم",
"add_to_calendar_description": "اختر أين ستُضاف الأحداث الجديدة عندما يتم الحجز لديك.",
"add_events_to": "إضافة أحداث إلى",
"add_another_calendar": "إضافة تقويم آخر",
"other": "آخر",
"email_sign_in_subject": "رابط تسجيل الدخول بك لـ{{appName}}",
@@ -422,6 +425,7 @@
"booking_created": "تم إنشاء الحجز",
"booking_rejected": "تم رفض الحجز",
"booking_requested": "تم طلب الحجز",
"booking_payment_initiated": "بدء الدفع للحجز",
"meeting_ended": "انتهى الاجتماع",
"form_submitted": "تم إرسال النموذج",
"booking_paid": "تم الدفع لقاء الحجز",
@@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "لم يقم هذا المستخدم بإعداد أي أنواع للحدث حتى الآن.",
"edit_logo": "تعديل الشعار",
"upload_a_logo": "تحميل شعار",
"upload_logo": "تحميل شعار",
"remove_logo": "إزالة الشعار",
"enable": "تمكين",
"code": "الرمز",
@@ -568,6 +573,7 @@
"your_team_name": "اسم فريقك",
"team_updated_successfully": "تم تحديث الفريق بنجاح",
"your_team_updated_successfully": "تم تحديث فريقك بنجاح.",
"your_org_updated_successfully": "تم تحديث منظمتك بنجاح.",
"about": "حول",
"team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.",
"org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.",
@@ -599,6 +605,7 @@
"hide_book_a_team_member": "إخفاء الزر \"حجز عضو فريق\"",
"hide_book_a_team_member_description": "إخفاء الزر \"حجز عضو فريق\" من صفحاتك العامة.",
"danger_zone": "منطقة خطر",
"account_deletion_cannot_be_undone": "احترس. لا يمكن التراجع عن حذف الحساب.",
"back": "عودة",
"cancel": "إلغاء",
"cancel_all_remaining": "إلغاء كل ما تبقى",
@@ -688,6 +695,7 @@
"people": "الأشخاص",
"your_email": "بريدك الإلكتروني",
"change_avatar": "تغيير الصورة الرمزية",
"upload_avatar": "تحميل صورة رمزية",
"language": "اللغة",
"timezone": "المنطقة الزمنية",
"first_day_of_week": "أول يوم في الأسبوع",
@@ -778,6 +786,7 @@
"disable_guests": "تعطيل خاصية الضيوف",
"disable_guests_description": "قم بتعطيل إضافة مزيد من الضيوف أثناء الحجز.",
"private_link": "إنشاء رابط خاص",
"enable_private_url": "تمكين عنوان URL خاص",
"private_link_label": "رابط خاص",
"private_link_hint": "سيتم إعادة توليد الرابط الخاص بك بعد كل استخدام",
"copy_private_link": "نسخ الرابط الخاص",
@@ -840,6 +849,7 @@
"next_step": "تخطي الخطوة",
"prev_step": "الخطوة السابقة",
"install": "تثبيت",
"install_paid_app": "اشتراك",
"installed": "تم التثبيت",
"active_install_one": "{{count}} تثبيت نشط",
"active_install_other": "{{count}} تثبيت نشط",
@@ -1088,6 +1098,7 @@
"developer_documentation": "مستندات المطور",
"get_in_touch": "تواصل معنا",
"contact_support": "الاتصال بالدعم",
"community_support": "الدعم المجتمعي",
"feedback": "الملاحظات",
"submitted_feedback": "نشكرك على ملاحظاتك!",
"feedback_error": "حدث خطأ عند إرسال الملاحظات",
@@ -1213,6 +1224,7 @@
"organizer_name_variable": "اسم المنظم",
"app_upgrade_description": "لاستخدام هذه الميزة، تحتاج إلى الترقية إلى حساب Pro.",
"invalid_number": "رقم الهاتف غير صالح",
"invalid_url_error_message": "عنوان URL غير صالح من أجل {{label}}. عنوان URL للعينة: {{sampleUrl}}",
"navigate": "تنقّل",
"open": "فتح",
"close": "إغلاق",
@@ -1276,6 +1288,7 @@
"personal_cal_url": "عنوان {{appName}} URL الخاص بي",
"bio_hint": "اكتب بضع جمل عن نفسك، والتي ستظهر على صفحة عنوان Url الشخصية لديك.",
"user_has_no_bio": "لم يُضف هذا المستخدم لمحة عنه بعد.",
"bio": "سيرة ذاتية",
"delete_account_modal_title": "حذف الحساب",
"confirm_delete_account_modal": "هل أنت متأكد من أنك تريد حذف حساب {{appName}} الخاص بك؟",
"delete_my_account": "حذف حسابي",
@@ -1286,6 +1299,7 @@
"select_calendars": "حدد الرزنامات التي تريد التحقق منها بحثًا عن تضاربات لمنع الحجوزات المزدوجة.",
"check_for_conflicts": "التحقق من وجود تضاربات",
"view_recordings": "عرض التسجيلات",
"check_for_recordings": "تحقق من وجود تسجيلات",
"adding_events_to": "إضافة أحداث إلى",
"follow_system_preferences": "اتبع تفضيلات النظام",
"custom_brand_colors": "ألوان العلامة التجارية المخصصة",
@@ -1530,6 +1544,7 @@
"problem_registering_domain": "حدثت مشكلة في تسجيل المجال الفرعي، يُرجى المحاولة لاحقاً أو الاتصال بالمشرف",
"team_publish": "نشر فريق",
"number_text_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
"number_sms_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
"attendee_email_variable": "اسم الحاضر",
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
@@ -1594,6 +1609,7 @@
"options": "خيارات",
"enter_option": "أدخل خيار {{index}}",
"add_an_option": "إضافة خيار",
"location_already_exists": "هذا الموقع موجود بالفعل. الرجاء اختيار موقع جديد",
"radio": "الراديو",
"google_meet_warning": "من أجل استخدام Google Meet، يجب عليك تعيين تقويم وجهتك على تقويم Google",
"individual": "فرد",
@@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "وضع علامة غير متاح على التواريخ المحددة",
"date_overrides_add_btn": "إضافة تجاوز",
"date_overrides_update_btn": "تحديث التجاوز",
"date_successfully_added": "تم إضافة تخطّي التاريخ بنجاح",
"event_type_duplicate_copy_text": "{{slug}}-نسخ",
"set_as_default": "تعيين كافتراضي",
"hide_eventtype_details": "إخفاء تفاصيل نوع الحدث",
@@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "عدد المضيفين المطلوب حضورهم",
"hosts": "المضيفون",
"upgrade_to_enable_feature": "تحتاج إلى إنشاء فريق لتمكين هذه الميزة. انقر لإنشاء فريق.",
"orgs_upgrade_to_enable_feature": "تحتاج إلى الترقية إلى خطة enterprise لتفعيل هذه الميزة.",
"new_attendee": "حضور جديد",
"awaiting_approval": "في انتظار الموافقة",
"requires_google_calendar": "يتطلب هذا التطبيق اتصال تقويم Google",
@@ -1743,6 +1761,7 @@
"show_on_booking_page": "إظهار في صفحة الحجز",
"get_started_zapier_templates": "البدء في استخدام قوالب Zapier",
"team_is_unpublished": "لم يُنشر {{team}}",
"org_is_unpublished_description": "رابط هذه المنظمة غير متاح حاليًا. يرجى الاتصال بمالك المنظمة أو طلب النشر منه.",
"team_is_unpublished_description": "رابط هذا {{entity}} غير متاح حاليًا. يرجى الاتصال بمالك {{entity}} أو طلب نشره منه.",
"team_member": "عضو الفريق",
"a_routing_form": "نموذج توجيه",
@@ -1877,6 +1896,7 @@
"edit_invite_link": "تعديل إعدادات الرابط",
"invite_link_copied": "تم نسخ رابط الدعوة",
"invite_link_deleted": "تم حذف رابط الدعوة",
"api_key_deleted": "تم حذف مفتاح API",
"invite_link_updated": "تم حفظ إعدادات رابط الدعوة",
"link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...",
"one_day": "1 يوم",
@@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "اسم العائلة للحاضر",
"attendee_first_name_info": "الاسم الأول للشخص صاحب الحجز",
"attendee_last_name_info": "اسم العائلة للشخص صاحب الحجز",
"your_monthly_digest": "موجزك الشهري",
"member_name": "اسم العضو",
"most_popular_events": "الأحداث الأكثر شعبية",
"summary_of_events_for_your_team_for_the_last_30_days": "إليك ملخصك للأحداث الشعبية لفريقك {{teamName}} خلال آخر 30 يومًا",
"me": "أنا",
"monthly_digest_email": "الرسالة الإلكترونية للموجز الشهري",
"monthly_digest_email_for_teams": "رسالة إلكترونية بالموجز الشهري للفرق",
"verify_team_tooltip": "تأكيد فريقك لتمكين إرسال الرسائل إلى الحاضرين",
"member_removed": "تمت إزالة العضو",
"my_availability": "أوقاتي المتاحة",
@@ -2039,12 +2065,41 @@
"team_no_event_types": "ليس لدى هذا الفريق أنواع من الفعاليات",
"seat_options_doesnt_multiple_durations": "لا يدعم خيار المقعد فترات متعددة",
"include_calendar_event": "إدراج فعاليات في التقويم",
"oAuth": "OAuth",
"recently_added": "تمت الإضافة مؤخراً",
"no_members_found": "لم يُعثر على أعضاء",
"event_setup_length_error": "إعداد الفعالية: يجب أن تكون المدة لدقيقة على الأقل.",
"availability_schedules": "جدولة التوافر",
"unauthorized": "غير مصرح به",
"access_cal_account": "يرغب {{clientName}} في الوصول إلى حسابك على {{appName}}",
"select_account_team": "اختر حسابًا أو فريق",
"allow_client_to": "سيمنح هذا {{clientName}} القدرة على",
"associate_with_cal_account": "رافق نفسك بمعلوماتك الشخصية من {{clientName}}",
"see_personal_info": "شاهد معلوماتك الشخصية، بما في ذلك أي معلومات شخصية جعلتها متاحة للجمهور",
"see_primary_email_address": "طالع عنوان بريدك الإلكتروني الأساسي",
"connect_installed_apps": "قم بالارتباط بتطبيقاتك المثبتة",
"access_event_type": "اقرأ وعدّل واحذف أنواع الأحداث لديك",
"access_availability": "اقرأ وعّدل واحذف توافرك",
"access_bookings": "اقرأ وعدّل واحذف الحجوزات لديك",
"allow_client_to_do": "السماح لـ {{clientName}} بالقيام بهذا؟",
"oauth_access_information": "عبر نقرك على السماح، ستسمح لهذا التطبيق باستخدام معلوماتك وفقاً لشروط الخدمة وسياسة الخصوصية لديه. يمكنك إزالة هذا الوصول من متجر التطبيقات {{appName}}.",
"allow": "السماح",
"view_only_edit_availability_not_onboarded": "لم يكمل هذا المستخدم عملية الانضمام. لن تتمكن من تعيين توافره حتى يكتمل انضمامه.",
"view_only_edit_availability": "أنت تشاهد توافر هذا المستخدم. يمكنك تعديل توافرك فقط.",
"you_can_override_calendar_in_advanced_tab": "يمكنك تجاوز هذا لكل حدث على حدة في الإعدادات المتقدمة في كل نوع من أنواع الأحداث.",
"edit_users_availability": "تعديل توافر المستخدم: {{username}}",
"resend_invitation": "إعادة إرسال الدعوة",
"invitation_resent": "تم إعادة إرسال الدعوة.",
"add_client": "إضافة عميل",
"copy_client_secret_info": "لن تتمكن من مطالعة السر بعد نسخه بعد الآن",
"add_new_client": "إضافة عميل جديد",
"this_app_is_not_setup_already": "لم يتم إعداد هذا التطبيق بعد",
"as_csv": "كـ CSV",
"overlay_my_calendar": "تركيب تقويمي",
"overlay_my_calendar_toc": "من خلال الارتباط بتقويمك، أنت تقبل سياسة الخصوصية وشروط الاستخدام. يمكنك إلغاء الوصول في أي وقت.",
"view_overlay_calendar_events": "طالع أحداث تقويمك لمنع التضارب بين الحجوزات.",
"lock_timezone_toggle_on_booking_page": "قفل المنطقة الزمنية في صفحة الحجز",
"description_lock_timezone_toggle_on_booking_page": "تقفل المنطقة الزمنية على صفحة الحجز، وهذا مفيد للأحداث وجهاً لوجه.",
"extensive_whitelabeling": "عملية انضمام ودعم هندسي مخصصين",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -268,6 +268,7 @@
"set_availability": "Nastavte, jak jste dostupní",
"availability_settings": "Nastavení dostupnosti",
"continue_without_calendar": "Pokračovat bez kalendáře",
"continue_with": "Pokračovat přes {{appName}}",
"connect_your_calendar": "Připojit svůj kalendář",
"connect_your_video_app": "Propojte své video aplikace",
"connect_your_video_app_instructions": "Propojte své video aplikace a používejte je ve svých typech událostí.",
@@ -288,6 +289,8 @@
"when": "Kdy",
"where": "Kde",
"add_to_calendar": "Přidat do kalendáře",
"add_to_calendar_description": "Vyberte, kam se mají přidávat události, pokud jste zarezervováni.",
"add_events_to": "Přidat události do",
"add_another_calendar": "Přidat další kalendář",
"other": "Ostatní",
"email_sign_in_subject": "Váš přihlašovací odkaz pro {{appName}}",
@@ -422,6 +425,7 @@
"booking_created": "Rezervace vytvořena",
"booking_rejected": "Rezervace byla zamítnuta",
"booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán",
"booking_payment_initiated": "Platba za rezervaci zahájena",
"meeting_ended": "Schůzka skončila",
"form_submitted": "Formulář byl odeslán",
"booking_paid": "Rezervace uhrazena",
@@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "Tento uživatel zatím nezaložil žádné typy událostí.",
"edit_logo": "Upravit logo",
"upload_a_logo": "Nahrát logo",
"upload_logo": "Nahrát logo",
"remove_logo": "Odstranit logo",
"enable": "Povolit",
"code": "Kód",
@@ -568,6 +573,7 @@
"your_team_name": "Název vašeho týmu",
"team_updated_successfully": "Tým byl úspěšně aktualizován",
"your_team_updated_successfully": "Váš tým byl úspěšně aktualizován.",
"your_org_updated_successfully": "Vaše organizace byla aktualizována.",
"about": "O aplikaci",
"team_description": "Pár vět o vašem týmu. Objeví se na URL stránce vašeho týmu.",
"org_description": "Několik vět o vaší organizaci. Zobrazí se na adrese URL vaší organizace.",
@@ -599,6 +605,7 @@
"hide_book_a_team_member": "Skrýt tlačítko Rezervovat člena týmu",
"hide_book_a_team_member_description": "Skrýt tlačítko Rezervovat člena týmu na veřejných stránkách.",
"danger_zone": "Nebezpečná zóna",
"account_deletion_cannot_be_undone": "Pozor. Odstranění účtu nelze vrátit.",
"back": "Zpět",
"cancel": "Zrušit",
"cancel_all_remaining": "Zrušit vše zbývající",
@@ -688,6 +695,7 @@
"people": "Lidé",
"your_email": "Váš e-mail",
"change_avatar": "Změnit avatar",
"upload_avatar": "Nahrát avatar",
"language": "Jazyk",
"timezone": "Časová zóna",
"first_day_of_week": "První den v týdnu",
@@ -778,6 +786,7 @@
"disable_guests": "Zakázat hosty",
"disable_guests_description": "Zakázat přidávání dalších hostů během rezervace.",
"private_link": "Vygenerovat soukromou adresu URL",
"enable_private_url": "Zapnout soukromou adresu URL",
"private_link_label": "Soukromý odkaz",
"private_link_hint": "Váš soukromý odkaz se po každém použití obnoví",
"copy_private_link": "Zkopírovat soukromý odkaz",
@@ -840,6 +849,7 @@
"next_step": "Přeskočit krok",
"prev_step": "Předchozí krok",
"install": "Nainstalovat",
"install_paid_app": "Odebírat",
"installed": "Nainstalováno",
"active_install_one": "Aktivní instalace: {{count}}",
"active_install_other": "Aktivní instalace: {{count}}",
@@ -1088,6 +1098,7 @@
"developer_documentation": "Dokumentace vývojáře",
"get_in_touch": "Kontaktujte nás",
"contact_support": "Kontaktujte podporu",
"community_support": "Podpora komunity",
"feedback": "Zpětná vazba",
"submitted_feedback": "Děkujeme za vaši zpětnou vazbu!",
"feedback_error": "Chyba při odesílání zpětné vazby",
@@ -1213,6 +1224,7 @@
"organizer_name_variable": "Jméno organizátora",
"app_upgrade_description": "Pokud chcete použít tuto funkci, musíte provést aktualizaci na účet Pro.",
"invalid_number": "Neplatné telefonní číslo",
"invalid_url_error_message": "Neplatná adresa URL pro {{label}}. Příklad URL: {{sampleUrl}}",
"navigate": "Navigace",
"open": "Otevřít",
"close": "Zavřít",
@@ -1276,6 +1288,7 @@
"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.",
"bio": "Bio",
"delete_account_modal_title": "Odstranit účet",
"confirm_delete_account_modal": "Opravdu chcete odstranit svůj účet {{appName}}?",
"delete_my_account": "Odstranit můj účet",
@@ -1286,6 +1299,7 @@
"select_calendars": "Vyberte kalendáře, u kterých chcete kontrolovat konflikty v zájmu prevence dvojích rezervací.",
"check_for_conflicts": "Zkontrolovat konflikty",
"view_recordings": "Zobrazit nahrávky",
"check_for_recordings": "Zkontrolovat nahrávky",
"adding_events_to": "Přidání událostí do:",
"follow_system_preferences": "Řídit se předvolbami systému",
"custom_brand_colors": "Vlastní barvy značky",
@@ -1530,6 +1544,7 @@
"problem_registering_domain": "Při registraci subdomény došlo k problému, zkuste to prosím znovu nebo kontaktujte správce",
"team_publish": "Zveřejnit tým",
"number_text_notifications": "Telefonní číslo (SMS oznámení)",
"number_sms_notifications": "Telefonní číslo (SMS oznámení)",
"attendee_email_variable": "E-mail účastníka",
"attendee_email_info": "E-mail osoby provádějící rezervaci",
"kbar_search_placeholder": "Zadejte příkaz nebo vyhledejte...",
@@ -1594,6 +1609,7 @@
"options": "Možnosti",
"enter_option": "Zadejte možnost {{index}}",
"add_an_option": "Přidat možnost",
"location_already_exists": "Toto místo již existuje. Vyberte prosím nové místo",
"radio": "Přepínač",
"google_meet_warning": "Abyste mohli používat službu Google Meet, musíte jako cílový kalendář nastavit Kalendář Google",
"individual": "Jedinec",
@@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "Zadat nedostupnost ve vybraných datech",
"date_overrides_add_btn": "Přidat změnu",
"date_overrides_update_btn": "Aktualizovat změnu",
"date_successfully_added": "Přidána změna dnů",
"event_type_duplicate_copy_text": "{{slug}} – kopie",
"set_as_default": "Nastavit jako výchozí",
"hide_eventtype_details": "Skrýt podrobnosti o typu události",
@@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "Počet hostitelů, kteří se musí zúčastnit",
"hosts": "Hostitelé",
"upgrade_to_enable_feature": "K povolení této funkce je třeba vytvořit tým. Tým vytvoříte kliknutím.",
"orgs_upgrade_to_enable_feature": "Pokud chcete zapnout tuto funkci, musíte upgradovat na náš tarif Enterprise.",
"new_attendee": "Nový účastník",
"awaiting_approval": "Čeká na schválení",
"requires_google_calendar": "Tato aplikace vyžaduje připojení ke Kalendáři Google",
@@ -1743,6 +1761,7 @@
"show_on_booking_page": "Zobrazit na stránce rezervace",
"get_started_zapier_templates": "Začněte používat šablony Zapier",
"team_is_unpublished": "Tým {{team}} není zveřejněn",
"org_is_unpublished_description": "Tento odkaz organizace není v současné době k dispozici. Kontaktujte prosím vlastníka organizace nebo ho požádejte o jeho zveřejnění.",
"team_is_unpublished_description": "Tento odkaz subjektu ({{entity}}) není v současné době k dispozici. Kontaktujte prosím vlastníka subjektu ({{entity}}) nebo ho požádejte o jeho zveřejnění.",
"team_member": "Člen týmu",
"a_routing_form": "Směrovací formulář",
@@ -1877,6 +1896,7 @@
"edit_invite_link": "Upravit nastavení odkazu",
"invite_link_copied": "Odkaz pozvánky byl zkopírován",
"invite_link_deleted": "Odkaz pozvánky byl odstraněn",
"api_key_deleted": "Klíč API odstraněn",
"invite_link_updated": "Nastavení odkazu pozvánky bylo uloženo",
"link_expires_after": "Platnost odkazů vyprší za...",
"one_day": "1 den",
@@ -2009,7 +2029,13 @@
"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",
"your_monthly_digest": "Váš měsíční přehled",
"member_name": "Jméno člena",
"most_popular_events": "Nejoblíbenější události",
"summary_of_events_for_your_team_for_the_last_30_days": "Zde je přehled oblíbených událostí vašeho týmu {{teamName}} za posledních 30 dní",
"me": "Já",
"monthly_digest_email": "E-mail s měsíčním přehledem",
"monthly_digest_email_for_teams": "Měsíční e-mail s přehledem pro týmy",
"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",
@@ -2039,12 +2065,41 @@
"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",
"oAuth": "OAuth",
"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",
"unauthorized": "Neautorizováno",
"access_cal_account": "{{clientName}} žádá o přístup k vašemu účtu {{appName}}",
"select_account_team": "Vyberte účet nebo tým",
"allow_client_to": "To umožní klientovi {{clientName}}",
"associate_with_cal_account": "Přiřadit vám vaše osobní údaje z klienta {{clientName}}",
"see_personal_info": "Zobrazit vaše osobní údaje, včetně všech osobních údajů, které jste zveřejnili",
"see_primary_email_address": "Zobrazit vaši primární e-mailovou adresu",
"connect_installed_apps": "Připojit se k vašim nainstalovaným aplikacím",
"access_event_type": "Číst, upravovat a odstraňovat vaše typy událostí",
"access_availability": "Číst, upravovat a odstraňovat vaši dostupnost",
"access_bookings": "Číst, upravovat a odstraňovat rezervace",
"allow_client_to_do": "Chcete povolit klientovi {{clientName}} provádět uvedené akce?",
"oauth_access_information": "Kliknutím na tlačítko Povolit této aplikaci umožníte používat vaše údaje v souladu s podmínkami služby a zásadami ochrany osobních údajů. Přístup k aplikaci {{appName}} můžete odebrat přes App Store.",
"allow": "Povolit",
"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.",
"you_can_override_calendar_in_advanced_tab": "Tuto možnost můžete zrušit pro každou událost zvlášť v pokročilém nastavení jednotlivých typů událostí.",
"edit_users_availability": "Upravte dostupnost uživatele: {{username}}",
"resend_invitation": "Znovu odeslat pozvánku",
"invitation_resent": "Pozvánka byla odeslána znovu.",
"add_client": "Přidat klienta",
"copy_client_secret_info": "Po zkopírování již nebude možné tajný klíč zobrazit",
"add_new_client": "Přidat nového klienta",
"this_app_is_not_setup_already": "Tato aplikace ještě nebyla nastavena",
"as_csv": "jako CSV",
"overlay_my_calendar": "Překryv mého kalendáře",
"overlay_my_calendar_toc": "Připojením ke svému kalendáři přijímáte naše zásady ochrany osobních údajů a podmínky používání. Přístup můžete kdykoli odvolat.",
"view_overlay_calendar_events": "Zobrazení událostí v kalendáři, aby se zabránilo kolidujícím rezervacím.",
"lock_timezone_toggle_on_booking_page": "Uzamčení časového pásma na stránce rezervace",
"description_lock_timezone_toggle_on_booking_page": "Uzamčení časového pásma na stránce rezervace (užitečné pro osobní události).",
"extensive_whitelabeling": "Vyhrazená podpora zaškolovací a inženýrská podpora",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -717,6 +717,7 @@
"next_step": "Spring trin over",
"prev_step": "Forrige trin",
"install": "Installér",
"install_paid_app": "Abonnér",
"installed": "Installeret",
"active_install_one": "{{count}} aktiv installation",
"active_install_other": "{{count}} aktive installationer",
@@ -268,6 +268,7 @@
"set_availability": "Verfügbarkeit festlegen",
"availability_settings": "Verfügbarkeitseinstellungen",
"continue_without_calendar": "Ohne Kalender fortfahren",
"continue_with": "Mit {{appName}} fortfahren",
"connect_your_calendar": "Kalender verbinden",
"connect_your_video_app": "Verbinden Sie Ihre Video-Apps",
"connect_your_video_app_instructions": "Verbinden Sie Ihre Video-Apps, um sie für Ihre Termintypen zu verwenden.",
@@ -289,6 +290,7 @@
"where": "Wo",
"add_to_calendar": "Zum Kalender hinzufügen",
"add_to_calendar_description": "Legen Sie fest, wo neue Termine hinzugefügt werden sollen, wenn Sie gebucht werden.",
"add_events_to": "Termine hinzufügen zu",
"add_another_calendar": "Einen weiteren Kalender hinzufügen",
"other": "Sonstige",
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
@@ -423,6 +425,7 @@
"booking_created": "Termin erstellt",
"booking_rejected": "Termin abgelehnt",
"booking_requested": "Buchung angefragt",
"booking_payment_initiated": "Buchungszahlung eingeleitet",
"meeting_ended": "Meeting beendet",
"form_submitted": "Formular gesendet",
"booking_paid": "Buchung bezahlt",
@@ -457,6 +460,7 @@
"no_event_types_have_been_setup": "Dieser Benutzer hat noch keine Termintypen eingerichtet.",
"edit_logo": "Logo bearbeiten",
"upload_a_logo": "Logo hochladen",
"upload_logo": "Logo hochladen",
"remove_logo": "Logos entfernen",
"enable": "Aktivieren",
"code": "Code",
@@ -569,6 +573,7 @@
"your_team_name": "Ihr Teamname",
"team_updated_successfully": "Team erfolgreich aktualisiert",
"your_team_updated_successfully": "Ihr Team wurde erfolgreich aktualisiert.",
"your_org_updated_successfully": "Ihre Org wurde erfolgreich aktualisiert.",
"about": "Beschreibung",
"team_description": "Ein paar Sätze über Ihr Team auf der öffentlichen Teamseite.",
"org_description": "Ein paar Sätze zu Ihrer Organisation. Dies wird auf der URL-Seite Ihrer Organisation erscheinen.",
@@ -781,6 +786,7 @@
"disable_guests": "Gäste deaktivieren",
"disable_guests_description": "Das Hinzufügen zusätzlicher Gäste deaktivieren.",
"private_link": "Privaten Link generieren",
"enable_private_url": "Private URL aktivieren",
"private_link_label": "Privater Link",
"private_link_hint": "Ihr privater Link wird nach jeder Nutzung neu generiert",
"copy_private_link": "Privaten Link kopieren",
@@ -843,6 +849,7 @@
"next_step": "Schritt überspringen",
"prev_step": "Vorheriger Schritt",
"install": "Installieren",
"install_paid_app": "Abonnieren",
"installed": "Installiert",
"active_install_one": "{{count}} aktive Installation",
"active_install_other": "{{count}} aktive Installationen",
@@ -1091,6 +1098,7 @@
"developer_documentation": "Entwickler-Dokumentation",
"get_in_touch": "Kontakt aufnehmen",
"contact_support": "Support kontaktieren",
"community_support": "Community Support",
"feedback": "Feedback",
"submitted_feedback": "Vielen Dank für Ihr Feedback!",
"feedback_error": "Fehler beim Senden des Feedbacks",
@@ -1216,6 +1224,7 @@
"organizer_name_variable": "Organisator Name",
"app_upgrade_description": "Um diese Funktion nutzen zu können, müssen Sie ein Upgrade auf einen Pro-Account durchführen.",
"invalid_number": "Ungültige Telefonnummer",
"invalid_url_error_message": "Ungültige URL für {{label}}. Beispiel-URL: {{sampleUrl}}",
"navigate": "Navigieren",
"open": "Öffnen",
"close": "Schließen",
@@ -1290,6 +1299,7 @@
"select_calendars": "Wählen Sie aus, in welchen Kalendern Sie nach Konflikten suchen wollen, um Doppelbuchungen zu vermeiden.",
"check_for_conflicts": "Auf Konflikte prüfen",
"view_recordings": "Aufnahmen anzeigen",
"check_for_recordings": "Nach Aufnahmen suchen",
"adding_events_to": "Termine hinzufügen zu",
"follow_system_preferences": "Systemeinstellungen folgen",
"custom_brand_colors": "Eigene Marken-Farben",
@@ -1599,6 +1609,7 @@
"options": "Optionen",
"enter_option": "Option {{index}} eingeben",
"add_an_option": "Option hinzufügen",
"location_already_exists": "Dieser Standort existiert bereits. Bitte wählen Sie einen neuen Standort",
"radio": "Radio",
"google_meet_warning": "Um Google Meet nutzen zu können, müssen Sie Ihren Zielkalender zu einem Google Calendar ändern",
"individual": "Person",
@@ -1618,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "An ausgewählten Daten als „nicht verfügbar“ markieren",
"date_overrides_add_btn": "Überschreibung hinzufügen",
"date_overrides_update_btn": "Überschreiben aktualisieren",
"date_successfully_added": "Datumsüberbrückung erfolgreich hinzugefügt",
"event_type_duplicate_copy_text": "{{slug}}-Kopie",
"set_as_default": "Als Standard festlegen",
"hide_eventtype_details": "Ereignistyp-Einzelheiten ausblenden",
@@ -2062,6 +2074,7 @@
"access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen",
"select_account_team": "Konto oder Team auswählen",
"allow_client_to": "Dies wird {{clientName}} erlauben",
"associate_with_cal_account": "Verknüpfen Sie sich mit Ihren persönlichen Daten von {{clientName}}",
"see_personal_info": "Ihre persönlichen Daten einzusehen, einschließlich persönlicher Informationen, die Sie öffentlich zugänglich gemacht haben",
"see_primary_email_address": "Ihre primäre E-Mail-Adresse einzusehen",
"connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden",
@@ -2069,12 +2082,24 @@
"access_availability": "Lesen, Bearbeiten, Löschen Ihrer Verfügbarkeiten",
"access_bookings": "Lesen, Bearbeiten, Löschen Ihrer Termine",
"allow_client_to_do": "{{clientName}} zulassen, dies zu tun?",
"oauth_access_information": "Indem Sie auf „Erlauben“ klicken, erlauben Sie dieser App, Ihre Informationen gemäß ihrer Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können den Zugriff im {{appName}} App Store aufheben.",
"allow": "Zulassen",
"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.",
"you_can_override_calendar_in_advanced_tab": "Sie können dies in den erweiterten Einstellungen pro Termin für jeden Termintyp überschreiben.",
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
"resend_invitation": "Einladung erneut senden",
"invitation_resent": "Die Einladung wurde erneut gesendet.",
"add_client": "Kunde hinzufügen",
"copy_client_secret_info": "Nach dem Kopieren des Geheimnisses können Sie es nicht mehr ansehen",
"add_new_client": "Neuen Kunden hinzufügen",
"this_app_is_not_setup_already": "Diese App wurde noch nicht eingerichtet",
"as_csv": "als CSV",
"overlay_my_calendar": "Meinen Kalender überlagern",
"overlay_my_calendar_toc": "Durch das Verbinden mit Ihrem Kalender akzeptieren Sie unsere Datenschutzerklärung und Nutzungsbedingungen. Sie können den Zugriff jederzeit widerrufen.",
"view_overlay_calendar_events": "Sehen Sie sich Ihre Kalendertermine an, um Buchungskonflikte zu vermeiden.",
"lock_timezone_toggle_on_booking_page": "Zeitzone auf der Buchungsseite sperren",
"description_lock_timezone_toggle_on_booking_page": "Um die Zeitzone auf der Buchungsseite zu sperren, nützlich für Termine in Person.",
"extensive_whitelabeling": "Dedizierte Onboarding- und Engineeringsupport",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,4 +1,5 @@
{
"identity_provider": "Πάροχος ταυτότητας",
"day_one": "{{count}} ημέρα",
"day_other": "{{count}} ημέρες",
"second_one": "{{count}} δευτερόλεπτο",
@@ -7,8 +8,13 @@
"accept_invitation": "Αποδοχή Πρόσκλησης",
"have_any_questions": "Έχετε ερωτήσεις? Είμαστε εδώ για να βοηθήσουμε.",
"reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης",
"verify_email_email_header": "Επιβεβαιώστε τη διεύθυνση ηλεκτρονικού ταχυδρομείου σας",
"verify_email_email_button": "Επιβεβαίωση ηλεκτρονικού ταχυδρομείου",
"copy_somewhere_safe": "Αποθηκεύστε αυτό το κλειδί API κάπου ασφαλές. Δεν θα μπορείτε να το δείτε ξανά.",
"event_declined_subject": "Απορρίφθηκε: {{title}} στις {{date}}",
"organizer": "Διοργανωτής",
"need_to_reschedule_or_cancel": "Χρειάζεται να επαναπρογραμματίσετε ή να ακυρώσετε;",
"no_options_available": "Καμία διαθέσιμη επιλογή",
"cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)",
"rejection_reason": "Λόγος απόρριψης",
"rejection_reason_title": "Απόρριψη του αιτήματος κράτησης;",
@@ -21,6 +27,8 @@
"meeting_awaiting_payment": "Η πληρωμή της συνάντησής σας εκκρεμεί",
"help": "Βοήθεια",
"price": "Τιμή",
"paid": "Πληρώθηκε",
"refunded": "Πιστώθηκε",
"payment": "Πληρωμή",
"missing_card_fields": "Λείπουν τα πεδία της κάρτας",
"pay_now": "Πληρωμή τώρα",
@@ -56,6 +56,17 @@
"a_refund_failed": "A refund failed",
"awaiting_payment_subject": "Awaiting Payment: {{title}} on {{date}}",
"meeting_awaiting_payment": "Your meeting is awaiting payment",
"dark_theme_contrast_error":"Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
"light_theme_contrast_error":"Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
"payment_not_created_error": "Payment could not be created",
"couldnt_charge_card_error": "Could not charge card for Payment",
"no_available_users_found_error": "No available users found. Could you try another time slot?",
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
"create_calendar_event_error": "Unable to create Calendar event in Organizer's calendar",
"update_calendar_event_error": "Unable to update Calendar event.",
"delete_calendar_event_error": "Unable to delete Calendar event.",
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
"help": "Help",
"price": "Price",
"paid": "Paid",
@@ -606,6 +617,7 @@
"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":"Be Careful. Account deletion cannot be undone.",
"team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
@@ -849,6 +861,8 @@
"next_step": "Skip step",
"prev_step": "Prev step",
"install": "Install",
"install_paid_app": "Subscribe",
"start_paid_trial": "Start free Trial",
"installed": "Installed",
"active_install_one": "{{count}} active install",
"active_install_other": "{{count}} active installs",
@@ -1097,6 +1111,8 @@
"developer_documentation": "Developer Documentation",
"get_in_touch": "Get in touch",
"contact_support": "Contact Support",
"premium_support": "Premium Support",
"community_support": "Community Support",
"feedback": "Feedback",
"submitted_feedback": "Thank you for your feedback!",
"feedback_error": "Error sending feedback",
@@ -1361,6 +1377,7 @@
"event_name_info": "The event type name",
"event_date_info": "The event date",
"event_time_info": "The event start time",
"event_type_not_found": "EventType not Found",
"location_info": "The location of the event",
"additional_notes_info": "The additional notes of booking",
"attendee_name_info": "The person booking's name",
@@ -1401,6 +1418,7 @@
"slot_length": "Slot length",
"booking_appearance": "Booking Appearance",
"appearance_team_description": "Manage settings for your team's booking appearance",
"appearance_org_description": "Manage settings for your organization's booking appearance",
"only_owner_change": "Only the owner of this team can make changes to the team's booking ",
"team_disable_cal_branding_description": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}'",
"invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}",
@@ -2096,7 +2114,28 @@
"overlay_my_calendar":"Overlay my calendar",
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
"troubleshooting":"Troubleshooting",
"calendars_were_checking_for_conflicts":"Calendars we’re checking for conflicts",
"availabilty_schedules":"Availability schedules",
"manage_calendars":"Manage calendars",
"manage_availability_schedules":"Manage availability schedules",
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
"install_calendar":"Install Calendar",
"branded_subdomain": "Branded Subdomain",
"branded_subdomain_description": "Get your own branded subdomain, such as acme.cal.com",
"org_insights": "Organization-wide Insights",
"org_insights_description": "Understand how your entire organization is spending time",
"extensive_whitelabeling": "Extensive Whitelabeling",
"extensive_whitelabeling_description": "Customize your scheduling experience with your own logo, colors, and more",
"unlimited_teams": "Unlimited Teams",
"unlimited_teams_description": "Add as many subteams as you need to your organization",
"unified_billing": "Unified Billing",
"unified_billing_description": "Add a single credit card to pay for all your team's subscriptions",
"advanced_managed_events": "Advanced Managed Event Types",
"advanced_managed_events_description": "Add a single credit card to pay for all your team's subscriptions",
"enterprise_description": "Upgrade to Enterprise to create your Organization",
"create_your_org": "Create your Organization",
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

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