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 8785 additions and 2574 deletions

No files matched your search

+26
View File
@@ -25,6 +25,7 @@ CALCOM_LICENSE_KEY=
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN= UPSTASH_REDIS_REST_TOKEN=
INSIGHTS_DATABASE_URL=
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy # 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. # 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_TOKEN=
NEXT_PUBLIC_FRESHCHAT_HOST= 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 # Inbox to send user feedback
SEND_FEEDBACK_EMAIL= SEND_FEEDBACK_EMAIL=
@@ -236,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL=
E2E_TEST_APPLE_CALENDAR_EMAIL="" E2E_TEST_APPLE_CALENDAR_EMAIL=""
E2E_TEST_APPLE_CALENDAR_PASSWORD="" 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 *********************************************************************************** # - APP CREDENTIAL SYNC ***********************************************************************************
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations # 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 # 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= 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: with:
repo-token: ${{ secrets.GH_ACCESS_TOKEN }} repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
organization-name: calcom 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 type { NextMiddleware } from "next-api-middleware";
import { CONSOLE_URL } from "@calcom/lib/constants"; 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; 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; } = req;
// If no custom api Id is provided, attach to request the regular cal.com prisma client. // If no custom api Id is provided, attach to request the regular cal.com prisma client.
if (!key) { if (!key) {
req.prisma = prisma; req.prisma = customPrisma();
await next(); await next();
return; return;
} }
try { // If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
// 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}`)
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`) .then((res) => res.json())
.then((res) => res.json()) .then((res) => res.databaseUrl);
.then((res) => res.databaseUrl);
if (!databaseUrl) { if (!databaseUrl) {
res.status(400).json({ error: "no databaseUrl set up at your instance yet" }); res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
return; return;
} }
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } }); req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
/* @note: /* @note:
In order to skip verifyApiKey for customPrisma requests, In order to skip verifyApiKey for customPrisma requests,
we pass isAdmin true, and userId 0, if we detect them later, we pass isAdmin true, and userId 0, if we detect them later,
we skip verifyApiKey logic and pass onto next middleware instead. we skip verifyApiKey logic and pass onto next middleware instead.
*/ */
req.isAdmin = true; req.isAdmin = true;
req.isCustomPrisma = true; req.isCustomPrisma = true;
// We don't need the key from here and on. Prevents unrecognized key errors. // We don't need the key from here and on. Prevents unrecognized key errors.
delete req.query.key; delete req.query.key;
await next(); await next();
await req.prisma.$disconnect(); await req.prisma.$disconnect();
// @ts-expect-error testing // @ts-expect-error testing
delete req.prisma; delete req.prisma;
} catch (err) {
if (req.prisma) {
await req.prisma.$disconnect();
// @ts-expect-error testing
delete req.prisma;
}
throw err;
}
}; };
+2
View File
@@ -26,6 +26,7 @@ const schemaAvailabilityCreateParams = z
startTime: z.date().or(z.string()), startTime: z.date().or(z.string()),
endTime: z.date().or(z.string()), endTime: z.date().or(z.string()),
days: z.array(z.number()).optional(), days: z.array(z.number()).optional(),
date: z.date().or(z.string()).optional(),
}) })
.strict(); .strict();
@@ -34,6 +35,7 @@ const schemaAvailabilityEditParams = z
startTime: z.date().or(z.string()).optional(), startTime: z.date().or(z.string()).optional(),
endTime: z.date().or(z.string()).optional(), endTime: z.date().or(z.string()).optional(),
days: z.array(z.number()).optional(), days: z.array(z.number()).optional(),
date: z.date().or(z.string()).optional(),
}) })
.strict(); .strict();
+10 -1
View File
@@ -21,7 +21,16 @@ export const schemaSchedulePublic = z
.merge( .merge(
z.object({ z.object({
availability: z 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) => .transform((v) =>
v.map((item) => ({ v.map((item) => ({
...item, ...item,
+2 -2
View File
@@ -205,8 +205,8 @@ import { defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest) { async function handler(req: NextApiRequest) {
const { userId, isAdmin } = req; const { userId, isAdmin } = req;
if (isAdmin) req.userId = req.body.userId || userId; if (isAdmin) req.userId = req.body.userId || userId;
const booking = await handleNewBooking(req);
return booking; return await handleNewBooking(req);
} }
export default defaultResponder(handler); export default defaultResponder(handler);
+6 -1
View File
@@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) {
statusCode: 401, statusCode: 401,
message: "ADMIN required for `userId`", 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 */ /* 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" }); throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) { 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 type { NextApiRequest } from "next";
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server"; 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 // 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" }); 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" }); 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` }; 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 dayjs from "@calcom/dayjs";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder"; import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -148,7 +149,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
expect(res._getStatusCode()).toBe(500); expect(res._getStatusCode()).toBe(500);
expect(JSON.parse(res._getData())).toEqual( expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({ 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/config": "*",
"@calcom/dayjs": "*", "@calcom/dayjs": "*",
"@calcom/ui": "*", "@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-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-dropdown-menu": "^2.0.5",
@@ -49,7 +49,7 @@
"storybook-addon-designs": "^6.3.1", "storybook-addon-designs": "^6.3.1",
"storybook-addon-next": "^1.6.9", "storybook-addon-next": "^1.6.9",
"storybook-react-i18next": "^1.1.2", "storybook-react-i18next": "^1.1.2",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.3",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"vite": "^4.1.2" "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 { dir } from "i18next";
import { headers as nextHeaders, cookies as nextCookies } from "next/headers"; import { headers, cookies } from "next/headers";
import Script from "next/script"; import Script from "next/script";
import React from "react"; import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { IS_PRODUCTION } from "@calcom/lib/constants"; import { IS_PRODUCTION } from "@calcom/lib/constants";
import { prepareRootMetadata } from "@lib/metadata";
import "../styles/globals.css"; import "../styles/globals.css";
export const metadata: Metadata = { export const generateMetadata = () =>
icons: { prepareRootMetadata({
icon: [ twitterCreator: "@calcom",
{ twitterSite: "@calcom",
sizes: "32x32", robots: {
url: "/api/logo?type=favicon-32", index: false,
}, follow: false,
{
sizes: "16x16",
url: "/api/logo?type=favicon-16",
},
],
apple: {
sizes: "180x180",
url: "/api/logo?type=apple-touch-icon",
}, },
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 ( const getInitialProps = async (url: string) => {
url: string,
headers: ReturnType<typeof nextHeaders>,
cookies: ReturnType<typeof nextCookies>
) => {
const { pathname, searchParams } = new URL(url); const { pathname, searchParams } = new URL(url);
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null; const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
const embedColorScheme = searchParams?.get("ui.color-scheme"); 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 req = { headers: headers(), cookies: cookies() };
const newLocale = await getLocale({ headers, cookies }); const newLocale = await getLocale(req);
let direction = "ltr"; const direction = dir(newLocale);
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);
}
return { isEmbed, embedColorScheme, locale: newLocale, direction }; 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 }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headers = nextHeaders(); const h = headers();
const cookies = nextCookies();
const fullUrl = headers.get("x-url") ?? ""; const fullUrl = h.get("x-url") ?? "";
const nonce = headers.get("x-csp") ?? ""; 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 ( return (
<html <html
lang={locale} lang={locale}
dir={direction} dir={direction}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}> style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
data-nextjs-router="app">
<head nonce={nonce}> <head nonce={nonce}>
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && ( {!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts // 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 type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { z } from "zod"; import { z } from "zod";
@@ -6,6 +6,7 @@ import { z } from "zod";
import type { CredentialOwner } from "@calcom/app-store/types"; import type { CredentialOwner } from "@calcom/app-store/types";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { Badge, ListItemText, Avatar } from "@calcom/ui"; import { Badge, ListItemText, Avatar } from "@calcom/ui";
@@ -56,7 +57,7 @@ export default function AppListCard(props: AppListCardProps) {
const router = useRouter(); const router = useRouter();
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug); const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
+6 -5
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { type DehydratedState } from "@tanstack/react-query";
import type { SSRConfig } from "next-i18next"; import type { SSRConfig } from "next-i18next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import localFont from "next/font/local"; import localFont from "next/font/local";
@@ -10,7 +11,6 @@ import type { ReactNode } from "react";
import "@calcom/embed-core/src/embed-iframe"; import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; 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 type { AppProps } from "@lib/app-providers-app-dir";
import AppProviders 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<{ export type PageWrapperProps = Readonly<{
getLayout: (page: React.ReactElement) => ReactNode; getLayout: ((page: React.ReactElement) => ReactNode) | null;
children: React.ReactElement; children: React.ReactElement;
requiresLicense: boolean; requiresLicense: boolean;
isThemeSupported: boolean;
isBookingPage: boolean;
nonce: string | undefined; nonce: string | undefined;
themeBasis: string | null; themeBasis: string | null;
dehydratedState?: DehydratedState;
isThemeSupported?: boolean;
isBookingPage?: boolean;
i18n?: SSRConfig; 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; disableInstall?: boolean;
dependencies?: string[]; dependencies?: string[];
concurrentMeetings: AppType["concurrentMeetings"]; concurrentMeetings: AppType["concurrentMeetings"];
paid?: AppType["paid"];
}; };
export const AppPage = ({ export const AppPage = ({
@@ -67,6 +68,7 @@ export const AppPage = ({
isTemplate, isTemplate,
dependencies, dependencies,
concurrentMeetings, concurrentMeetings,
paid,
}: AppPageProps) => { }: AppPageProps) => {
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0; 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"> className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
{categories[0]} {categories[0]}
</Link>{" "} </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}> <a target="_blank" rel="noreferrer" href={website}>
{t("published_by", { author })} {t("published_by", { author })}
@@ -206,6 +221,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }} addAppMutationInput={{ type, variant, slug }}
multiInstall multiInstall
concurrentMeetings={concurrentMeetings} concurrentMeetings={concurrentMeetings}
paid={paid}
{...props} {...props}
/> />
); );
@@ -244,6 +260,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }} addAppMutationInput={{ type, variant, slug }}
credentials={appDbQuery.data?.credentials} credentials={appDbQuery.data?.credentials}
concurrentMeetings={concurrentMeetings} concurrentMeetings={concurrentMeetings}
paid={paid}
{...props} {...props}
/> />
); );
@@ -263,7 +280,7 @@ export const AppPage = ({
<SkeletonButton className="mt-6 h-20 grow" /> <SkeletonButton className="mt-6 h-20 grow" />
))} ))}
{price !== 0 && ( {price !== 0 && !paid && (
<span className="block text-right"> <span className="block text-right">
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar} {feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
{feeType === "monthly" && `/${t("month")}`} {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"> <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} {body}
</div> </div>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4> {!paid && (
<span className="text-default"> <>
{teamsPlanRequired ? ( <h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
t("teams_plan_required") <span className="text-default">
) : price === 0 ? ( {teamsPlanRequired ? (
t("free_to_use_apps") t("teams_plan_required")
) : ( ) : price === 0 ? (
<> t("free_to_use_apps")
{Intl.NumberFormat(i18n.language, { ) : (
style: "currency", <>
currency: "USD", {Intl.NumberFormat(i18n.language, {
useGrouping: false, style: "currency",
}).format(price)} currency: "USD",
{feeType === "monthly" && `/${t("month")}`} useGrouping: false,
</> }).format(price)}
)} {feeType === "monthly" && `/${t("month")}`}
</span> </>
)}
</span>
</>
)}
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4> <h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
<ul className="prose-sm -ml-1 -mr-1 leading-5"> <ul className="prose-sm -ml-1 -mr-1 leading-5">
@@ -26,6 +26,7 @@ export const InstallAppButtonChild = ({
multiInstall, multiInstall,
credentials, credentials,
concurrentMeetings, concurrentMeetings,
paid,
...props ...props
}: { }: {
userAdminTeams?: UserAdminTeams; userAdminTeams?: UserAdminTeams;
@@ -34,6 +35,7 @@ export const InstallAppButtonChild = ({
multiInstall?: boolean; multiInstall?: boolean;
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"]; credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
concurrentMeetings?: boolean; concurrentMeetings?: boolean;
paid?: AppFrontendPayload["paid"];
} & ButtonProps) => { } & ButtonProps) => {
const { t } = useLocale(); const { t } = useLocale();
@@ -46,8 +48,27 @@ export const InstallAppButtonChild = ({
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); 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 ( return (
<Button <Button
data-testid="install-app-button" data-testid="install-app-button"
@@ -55,6 +76,7 @@ export const InstallAppButtonChild = ({
// @TODO: Overriding color and size prevent us from // @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now. // having to duplicate InstallAppButton for now.
color="primary" color="primary"
disabled={shouldDisableInstallation}
size="base"> size="base">
{multiInstall ? t("install_another") : t("install_app")} {multiInstall ? t("install_another") : t("install_app")}
</Button> </Button>
+41 -25
View File
@@ -32,8 +32,6 @@ import {
} from "@calcom/ui"; } from "@calcom/ui";
import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } from "@calcom/ui/components/icon"; 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 { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
@@ -45,14 +43,18 @@ type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number
type BookingItemProps = BookingItem & { type BookingItemProps = BookingItem & {
listingStatus: BookingListingStatus; listingStatus: BookingListingStatus;
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined; 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) { function BookingListItem(booking: BookingItemProps) {
// Get user so we can determine 12/24 hour format preferences
const query = useMeQuery();
const bookerUrl = useBookerUrl(); const bookerUrl = useBookerUrl();
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const user = query.data;
const { const {
t, t,
i18n: { language }, i18n: { language },
@@ -215,7 +217,7 @@ function BookingListItem(booking: BookingItemProps) {
}; };
const startTime = dayjs(booking.startTime) const startTime = dayjs(booking.startTime)
.tz(user?.timeZone) .tz(userTimeZone)
.locale(language) .locale(language)
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); .format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
@@ -301,7 +303,7 @@ function BookingListItem(booking: BookingItemProps) {
booking={booking} booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen} isOpenDialog={viewRecordingsDialogIsOpen}
setIsOpenDialog={setViewRecordingsDialogIsOpen} setIsOpenDialog={setViewRecordingsDialogIsOpen}
timeFormat={user?.timeFormat ?? null} timeFormat={userTimeFormat ?? null}
/> />
)} )}
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */} {/* 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="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div> <div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm"> <div className="text-subtle text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "} {formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)} {formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones <MeetingTimeInTimezones
timeFormat={user?.timeFormat} timeFormat={userTimeFormat}
userTimezone={user?.timeZone} userTimezone={userTimeZone}
startTime={booking.startTime} startTime={booking.startTime}
endTime={booking.endTime} endTime={booking.endTime}
attendees={booking.attendees} attendees={booking.attendees}
@@ -372,7 +374,12 @@ function BookingListItem(booking: BookingItemProps) {
) : null} ) : null}
{recurringDates !== undefined && ( {recurringDates !== undefined && (
<div className="text-muted mt-2 text-sm"> <div className="text-muted mt-2 text-sm">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} /> <RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div> </div>
)} )}
</div> </div>
@@ -385,11 +392,11 @@ function BookingListItem(booking: BookingItemProps) {
<div className="flex w-full items-center justify-between sm:hidden"> <div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div> <div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm"> <div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "} {formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)} {formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones <MeetingTimeInTimezones
timeFormat={user?.timeFormat} timeFormat={userTimeFormat}
userTimezone={user?.timeZone} userTimezone={userTimeZone}
startTime={booking.startTime} startTime={booking.startTime}
endTime={booking.endTime} endTime={booking.endTime}
attendees={booking.attendees} attendees={booking.attendees}
@@ -414,7 +421,12 @@ function BookingListItem(booking: BookingItemProps) {
)} )}
{recurringDates !== undefined && ( {recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden"> <div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} /> <RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div> </div>
)} )}
</div> </div>
@@ -446,7 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
<DisplayAttendees <DisplayAttendees
attendees={booking.attendees} attendees={booking.attendees}
user={booking.user} user={booking.user}
currentEmail={user?.email} currentEmail={userEmail}
/> />
)} )}
{isCancelled && booking.rescheduled && ( {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"> <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 ? ( {isUpcoming && !isCancelled ? (
<> <>
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />} {isPending && userId === booking.user?.id && <TableActions actions={pendingActions} />}
{isConfirmed && <TableActions actions={bookedActions} />} {isConfirmed && <TableActions actions={bookedActions} />}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>} {isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
</> </>
@@ -486,12 +498,16 @@ function BookingListItem(booking: BookingItemProps) {
interface RecurringBookingsTooltipProps { interface RecurringBookingsTooltipProps {
booking: BookingItemProps; booking: BookingItemProps;
recurringDates: Date[]; recurringDates: Date[];
userTimeZone: string | undefined;
userTimeFormat: number | null | undefined;
} }
const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookingsTooltipProps) => { const RecurringBookingsTooltip = ({
// Get user so we can determine 12/24 hour format preferences booking,
const query = useMeQuery(); recurringDates,
const user = query.data; userTimeZone,
userTimeFormat,
}: RecurringBookingsTooltipProps) => {
const { const {
t, t,
i18n: { language }, i18n: { language },
@@ -523,7 +539,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
.includes(aDate.toDateString()); .includes(aDate.toDateString());
return ( return (
<p key={key} className={classNames(pastOrCancelled && "line-through")}> <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")} {dayjs(aDate).locale(language).format("D MMMM YYYY")}
</p> </p>
@@ -536,7 +552,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
/> />
<p className="mt-1 pl-5 text-xs"> <p className="mt-1 pl-5 text-xs">
{booking.status === BookingStatus.ACCEPTED {booking.status === BookingStatus.ACCEPTED
? `${t("event_remaining", { ? `${t("event_remaining_other", {
count: recurringCount, count: recurringCount,
})}` })}`
: getEveryFreqFor({ : 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) { 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) // Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {}; const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
@@ -57,6 +57,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
[appId]: { [appId]: {
...appData, ...appData,
[key]: value, [key]: value,
credentialId,
}, },
}); });
}; };
@@ -76,7 +77,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
appCards.push( appCards.push(
<EventTypeAppCard <EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)} getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)} setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
key={app.slug} key={app.slug}
app={app} app={app}
eventType={eventType} eventType={eventType}
@@ -90,7 +91,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
appCards.push( appCards.push(
<EventTypeAppCard <EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)} 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} key={app.slug + team?.credentialId}
app={{ app={{
...app, ...app,
@@ -147,7 +148,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
return ( return (
<EventTypeAppCard <EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)} getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)} setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
key={app.slug} key={app.slug}
app={app} app={app}
eventType={eventType} eventType={eventType}
@@ -8,7 +8,7 @@ import { Controller, useFormContext, useFieldArray } from "react-hook-form";
import type { MultiValue } from "react-select"; import type { MultiValue } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations"; 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 useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CAL_URL } from "@calcom/lib/constants"; import { CAL_URL } from "@calcom/lib/constants";
@@ -247,15 +247,7 @@ export const EventSetupTab = (
<ul ref={animationRef} className="space-y-2"> <ul ref={animationRef} className="space-y-2">
{locationFields.map((field, index) => { {locationFields.map((field, index) => {
const eventLocationType = getEventLocationType(field.type); const eventLocationType = getEventLocationType(field.type);
const defaultLocation = formMethods const defaultLocation = field;
.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 option = getLocationFromType(field.type, locationOptions); const option = getLocationFromType(field.type, locationOptions);
@@ -337,10 +329,12 @@ export const EventSetupTab = (
name={eventLocationType.defaultValueVariable} name={eventLocationType.defaultValueVariable}
className="text-error my-1 ml-6 text-sm" className="text-error my-1 ml-6 text-sm"
as="div" as="div"
id="location-error"
/> />
</div> </div>
<div className="ml-6"> <div className="ml-6">
<CheckboxField <CheckboxField
name={`locations[${index}].displayLocationPublicly`}
data-testid="display-location" data-testid="display-location"
defaultChecked={defaultLocation?.displayLocationPublicly} defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")} description={t("display_location_label")}
@@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
<div> <div>
<Label>{t("assign_to")}</Label> <Label>{t("assign_to")}</Label>
<ChildrenEventTypeSelect <ChildrenEventTypeSelect
aria-label="assignment-dropdown"
data-testid="assignment-dropdown"
onChange={(options) => { onChange={(options) => {
onChange && onChange &&
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 * It is aware of the user's organization to correctly show the avatar from the correct URL
*/ */
export function UserAvatar(props: UserAvatarProps) { export function UserAvatar(props: UserAvatarProps) {
const { user, previewSrc, ...rest } = props; const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />; 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 { TooltipProvider } from "@radix-ui/react-tooltip";
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { dir } from "i18next"; import { dir } from "i18next";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react"; import { SessionProvider, useSession } from "next-auth/react";
@@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => {
const isBookingPage = useIsBookingPage(); const isBookingPage = useIsBookingPage();
const RemainingProviders = ( const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}> <TrpcProvider dehydratedState={props.dehydratedState}>
<SessionProvider> <EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<CustomI18nextProvider i18n={props.i18n}> <SessionProvider>
<TooltipProvider> <CustomI18nextProvider i18n={props.i18n}>
{/* 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 */} <TooltipProvider>
<CalcomThemeProvider {/* 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 */}
themeBasis={props.themeBasis} <CalcomThemeProvider
nonce={props.nonce} themeBasis={props.themeBasis}
isThemeSupported={props.isThemeSupported} nonce={props.nonce}
isBookingPage={props.isBookingPage || isBookingPage}> isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
<FeatureFlagsProvider> isBookingPage={props.isBookingPage || isBookingPage}>
<OrgBrandProvider> <FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider> <OrgBrandProvider>
</OrgBrandProvider> <MetaProvider>{props.children}</MetaProvider>
</FeatureFlagsProvider> </OrgBrandProvider>
</CalcomThemeProvider> </FeatureFlagsProvider>
</TooltipProvider> </CalcomThemeProvider>
</CustomI18nextProvider> </TooltipProvider>
</SessionProvider> </CustomI18nextProvider>
</EventCollectionProvider> </SessionProvider>
</EventCollectionProvider>
</TrpcProvider>
); );
if (isBookingPage) { if (isBookingPage) {
+25 -12
View File
@@ -1,10 +1,11 @@
import type { IncomingMessage, OutgoingMessage } from "http"; import type { IncomingMessage, OutgoingMessage } from "http";
import type { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants"; import { IS_PRODUCTION } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildNonce } from "@lib/buildNonce"; import { buildNonce } from "./buildNonce";
function getCspPolicy(nonce: string) { function getCspPolicy(nonce: string) {
//TODO: Do we need to explicitly define it in turbo.json //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); 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) { if (!req) {
return { nonce: undefined }; 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) { if (existingNonce) {
const existingNoneParsed = z.string().safeParse(existingNonce); const existingNoneParsed = z.string().safeParse(existingNonce);
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" }; 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 // 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 // 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) { if (res) {
res.setHeader( const enforced =
req.headers["x-csp-enforce"] === "true" "cache" in req ? req.headers.get("x-csp-enforce") === "true" : req.headers["x-csp-enforce"] === "true";
? "Content-Security-Policy"
: "Content-Security-Policy-Report-Only", const name = enforced ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only";
getCspPolicy(nonce)
.replace(/\s{2,}/g, " ") const value = getCspPolicy(nonce)
.trim() .replace(/\s{2,}/g, " ")
); .trim();
if ("body" in res) {
res.headers.set(name, value);
} else {
res.setHeader(name, value);
}
} }
return { nonce }; 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 { export default function useIsBookingPage(): boolean {
const pathname = usePathname(); const pathname = usePathname();
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route)); const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const userParam = Boolean(searchParams?.get("user")); const userParam = Boolean(searchParams?.get("user"));
const teamParam = Boolean(searchParams?.get("team")); 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 { useCallback } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export default function useRouterQuery<T extends string>(name: T) { export default function useRouterQuery<T extends string>(name: T) {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); 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) { export function useToggleQuery(name: string) {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
return { return {
isOn: searchParams?.get(name) === "1", 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 { get } from "@vercel/edge-config";
import { collectEvents } from "next-collect/server"; import { collectEvents } from "next-collect/server";
import type { NextMiddleware } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry"; 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 url = req.nextUrl;
const requestHeaders = new Headers(req.headers); const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-url", req.url);
if (!url.pathname.startsWith("/api")) { if (!url.pathname.startsWith("/api")) {
// //
// NOTE: When tRPC hits an error a 500 is returned, when this is received // 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 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) { if (res) {
return res; return res;
} }
@@ -45,6 +64,12 @@ const middleware: NextMiddleware = async (req) => {
requestHeaders.set("x-csp-enforce", "true"); 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({ return NextResponse.next({
request: { request: {
headers: requestHeaders, headers: requestHeaders,
@@ -74,11 +99,13 @@ export const config = {
* Paths required by routingForms.handle * Paths required by routingForms.handle
*/ */
"/apps/routing_forms/:path*", "/apps/routing_forms/:path*",
"/event-types",
"/future/event-types/",
], ],
}; };
export default collectEvents({ export default collectEvents({
middleware, middleware: abTestMiddlewareFactory(middleware),
...nextCollectBasicSettings, ...nextCollectBasicSettings,
cookieName: "__clnds", cookieName: "__clnds",
extend: extendEventData, extend: extendEventData,
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "3.4.8", "version": "3.5.2",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -45,7 +45,7 @@
"@hookform/resolvers": "^2.9.7", "@hookform/resolvers": "^2.9.7",
"@next-auth/prisma-adapter": "^1.0.4", "@next-auth/prisma-adapter": "^1.0.4",
"@next/bundle-analyzer": "^13.1.6", "@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-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-dropdown-menu": "^2.0.5",
@@ -168,8 +168,9 @@
"module-alias": "^2.2.2", "module-alias": "^2.2.2",
"msw": "^0.42.3", "msw": "^0.42.3",
"node-html-parser": "^6.1.10", "node-html-parser": "^6.1.10",
"node-mocks-http": "^1.11.0",
"postcss": "^8.4.18", "postcss": "^8.4.18",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6", "tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.9.4" "typescript": "^4.9.4"
+2 -2
View File
@@ -1,7 +1,7 @@
import Head from "next/head"; import Head from "next/head";
import { useSearchParams } from "next/navigation";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, showToast } from "@calcom/ui"; import { Button, showToast } from "@calcom/ui";
import { Copy } from "@calcom/ui/components/icon"; import { Copy } from "@calcom/ui/components/icon";
@@ -9,7 +9,7 @@ import { Copy } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
export default function Error500() { export default function Error500() {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { t } = useLocale(); const { t } = useLocale();
return ( return (
+5 -2
View File
@@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
description={markdownStrippedBio} description={markdownStrippedBio}
meeting={{ meeting={{
title: markdownStrippedBio, title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: null }, profile: { name: `${profile.name}`, image: user.avatarUrl || null },
users: [{ username: `${user.username}`, name: `${user.name}` }], users: [{ username: `${user.username}`, name: `${user.name}` }],
}} }}
nextSeoProps={{ nextSeoProps={{
@@ -245,7 +245,7 @@ export type UserPageProps = {
allowSEOIndexing: boolean; allowSEOIndexing: boolean;
username: string | null; username: string | null;
}; };
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[]; users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
themeBasis: string | null; themeBasis: string | null;
markdownStrippedBio: string; markdownStrippedBio: string;
safeBio: string; safeBio: string;
@@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
metadata: true, metadata: true,
brandColor: true, brandColor: true,
darkBrandColor: true, darkBrandColor: true,
avatarUrl: true,
organizationId: true, organizationId: true,
organization: { organization: {
select: { select: {
@@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
image: user.avatar, image: user.avatar,
theme: user.theme, theme: user.theme,
brandColor: user.brandColor, brandColor: user.brandColor,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor, darkBrandColor: user.darkBrandColor,
allowSEOIndexing: user.allowSEOIndexing ?? true, allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username, username: user.username,
@@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
name: user.name, name: user.name,
username: user.username, username: user.username,
bio: user.bio, bio: user.bio,
avatarUrl: user.avatarUrl,
away: user.away, away: user.away,
verified: user.verified, 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 { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; 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"; import { defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) { 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 }); const session = await getServerSession({ req, res });
/* To mimic API behavior and comply with types */ /* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1; 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 { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types"; 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 { defaultResponder } from "@calcom/lib/server";
import type { AppsStatus } from "@calcom/types/Calendar"; 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. // @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) { 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 data: RecurringBookingCreateBody[] = req.body;
const session = await getServerSession({ req, res }); const session = await getServerSession({ req, res });
const createdBookings: BookingResponse[] = []; const createdBookings: BookingResponse[] = [];
+2 -1
View File
@@ -73,7 +73,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler); redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab }); res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
} }
return res.status(200); if (!res.writableEnded) return res.status(200);
return res;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (error instanceof HttpError) { 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} isTemplate={data.isTemplate}
dependencies={data.dependencies} dependencies={data.dependencies}
concurrentMeetings={data.concurrentMeetings} concurrentMeetings={data.concurrentMeetings}
paid={data.paid}
// tos="https://zoom.us/terms" // tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy" // privacy="https://zoom.us/privacy"
body={ body={
+3 -2
View File
@@ -1,15 +1,16 @@
import type { InferGetServerSidePropsType } from "next"; import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react"; 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 { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps"; import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { HeadSeo } from "@calcom/ui"; import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) { export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const router = useRouter(); const router = useRouter();
const slug = searchParams?.get("slug") as string; const slug = searchParams?.get("slug") as string;
const { status } = useSession(); const { status } = useSession();
@@ -1,10 +1,10 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next"; import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { getAppRegistry } from "@calcom/app-store/_appRegistry"; import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import Shell from "@calcom/features/shell/Shell"; import Shell from "@calcom/features/shell/Shell";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { AppCategories } from "@calcom/prisma/enums"; import { AppCategories } from "@calcom/prisma/enums";
@@ -13,7 +13,7 @@ import { AppCard, SkeletonText } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) { export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { t, isLocaleReady } = useLocale(); const { t, isLocaleReady } = useLocale();
const category = searchParams?.get("category"); const category = searchParams?.get("category");
+2 -2
View File
@@ -1,8 +1,8 @@
import { useSearchParams } from "next/navigation";
import { useReducer } from "react"; import { useReducer } from "react";
import { z } from "zod"; import { z } from "zod";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums"; import { AppCategories } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
@@ -122,7 +122,7 @@ type ModalState = {
}; };
export default function InstalledApps() { export default function InstalledApps() {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { t } = useLocale(); const { t } = useLocale();
const category = searchParams?.get("category") as querySchemaType["category"]; const category = searchParams?.get("category") as querySchemaType["category"];
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => { const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { GetStaticPropsContext } from "next"; import type { GetStaticPropsContext } from "next";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation";
import z from "zod"; import z from "zod";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui"; import { Button } from "@calcom/ui";
import { X } from "@calcom/ui/components/icon"; import { X } from "@calcom/ui/components/icon";
@@ -18,7 +18,7 @@ const querySchema = z.object({
export default function Error() { export default function Error() {
const { t } = useLocale(); const { t } = useLocale();
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { error } = querySchema.parse(searchParams); const { error } = querySchema.parse(searchParams);
const isTokenVerificationError = error?.toLowerCase() === "verification"; const isTokenVerificationError = error?.toLowerCase() === "verification";
const errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login"); 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 type { GetServerSidePropsContext } from "next";
import { getCsrfToken, signIn } from "next-auth/react"; import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter } from "next/navigation";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useState } from "react"; import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; 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 { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -53,7 +54,7 @@ export default function Login({
totpEmail, totpEmail,
}: // eslint-disable-next-line @typescript-eslint/ban-types }: // eslint-disable-next-line @typescript-eslint/ban-types
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) { inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const formSchema = z 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 // TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) { const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
const { req, res } = context; const { req, res, query } = context;
const session = await getServerSession({ req, res }); const session = await getServerSession({ req, res });
const ssr = await ssrInit(context); const ssr = await ssrInit(context);
@@ -318,6 +319,24 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
} }
if (session) { 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 { return {
redirect: { redirect: {
destination: "/", destination: "/",
+2 -2
View File
@@ -1,9 +1,9 @@
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Select } from "@calcom/ui"; import { Avatar, Button, Select } from "@calcom/ui";
@@ -16,7 +16,7 @@ export default function Authorize() {
const { status } = useSession(); const { status } = useSession();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const client_id = searchParams?.get("client_id") as string; const client_id = searchParams?.get("client_id") as string;
const state = searchParams?.get("state") as string; const state = searchParams?.get("state") as string;
+3 -2
View File
@@ -1,12 +1,13 @@
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
// To handle the IdP initiated login flow callback // To handle the IdP initiated login flow callback
export default function Page() { export default function Page() {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
useEffect(() => { useEffect(() => {
const code = searchParams?.get("code"); const code = searchParams?.get("code");
+3 -2
View File
@@ -1,11 +1,12 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import AdminAppsList from "@calcom/features/apps/AdminAppsList"; import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey"; import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums"; import { UserPermissionRole } from "@calcom/prisma/enums";
@@ -21,7 +22,7 @@ import { ssrInit } from "@server/lib/ssr";
function useSetStep() { function useSetStep() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const pathname = usePathname(); const pathname = usePathname();
const setStep = (newStep = 1) => { const setStep = (newStep = 1) => {
const _searchParams = new URLSearchParams(searchParams ?? undefined); const _searchParams = new URLSearchParams(searchParams ?? undefined);
+3 -2
View File
@@ -1,6 +1,6 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; 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 stripe from "@calcom/features/ee/payments/server/stripe";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml"; import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso"; import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { checkUsername } from "@calcom/lib/server/checkUsername"; import { checkUsername } from "@calcom/lib/server/checkUsername";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -22,7 +23,7 @@ import { ssrInit } from "@server/lib/ssr";
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>; export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Provider(props: SSOProviderPageProps) { export default function Provider(props: SSOProviderPageProps) {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
+3 -2
View File
@@ -1,10 +1,11 @@
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import Head from "next/head"; 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 { useEffect, useRef, useState } from "react";
import z from "zod"; import z from "zod";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Button, showToast } from "@calcom/ui"; import { Button, showToast } from "@calcom/ui";
@@ -54,7 +55,7 @@ const querySchema = z.object({
}); });
export default function Verify() { export default function Verify() {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const routerQuery = useRouterQuery(); 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 { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; 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 Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib"; import { classNames } from "@calcom/lib";
import { availabilityAsString } from "@calcom/lib/availability"; import { availabilityAsString } from "@calcom/lib/availability";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
@@ -83,7 +84,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
}; };
export default function Availability() { export default function Availability() {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const router = useRouter(); const router = useRouter();
const utils = trpc.useContext(); const utils = trpc.useContext();
+3 -2
View File
@@ -1,11 +1,12 @@
import { useAutoAnimate } from "@formkit/auto-animate/react"; 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 { useCallback } from "react";
import { getLayout } from "@calcom/features/MainLayout"; import { getLayout } from "@calcom/features/MainLayout";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules"; import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { ShellMain } from "@calcom/features/shell/Shell"; import { ShellMain } from "@calcom/features/shell/Shell";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable"; import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
@@ -131,7 +132,7 @@ const WithQuery = withQuery(trpc.viewer.availability.list as any);
export default function AvailabilityPage() { export default function AvailabilityPage() {
const { t } = useLocale(); const { t } = useLocale();
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
+11 -130
View File
@@ -1,139 +1,20 @@
import dayjs from "@calcom/dayjs"; import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter";
import Shell from "@calcom/features/shell/Shell"; import { getLayout } from "@calcom/features/troubleshooter/layout";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react"; import { HeadSeo } from "@calcom/ui";
import { trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
type User = RouterOutputs["viewer"]["me"]; function TroubleshooterPage() {
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();
const { t } = useLocale(); const { t } = useLocale();
return ( return (
<div> <>
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}> <HeadSeo title={t("troubleshoot")} description={t("troubleshoot_availability")} />
{!isLoading && data && <AvailabilityView user={data} />} <Troubleshooter month={null} />
</Shell> </>
</div>
); );
} }
Troubleshoot.PageWrapper = PageWrapper;
function convertMinsToHrsMins(mins: number) { TroubleshooterPage.getLayout = getLayout;
const h = Math.floor(mins / 60); TroubleshooterPage.PageWrapper = PageWrapper;
const m = mins % 60; export default TroubleshooterPage;
const hs = h < 10 ? `0${h}` : h;
const ms = m < 10 ? `0${m}` : m;
return `${hs}:${ms}`;
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { createEvent } from "ics";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { RRule } from "rrule"; import { RRule } from "rrule";
import { z } from "zod"; import { z } from "zod";
@@ -36,6 +36,7 @@ import {
} from "@calcom/lib/date-fns"; } from "@calcom/lib/date-fns";
import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import useGetBrandingColours from "@calcom/lib/getBrandColours"; import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme"; import useTheme from "@calcom/lib/hooks/useTheme";
@@ -98,7 +99,7 @@ export default function Success(props: SuccessProps) {
const router = useRouter(); const router = useRouter();
const routerQuery = useRouterQuery(); const routerQuery = useRouterQuery();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { const {
allRemainingBookings, allRemainingBookings,
isSuccessBookingPage, isSuccessBookingPage,
+24 -2
View File
@@ -5,6 +5,7 @@ import React from "react";
import { z } from "zod"; import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components"; import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import { getLayout } from "@calcom/features/MainLayout"; import { getLayout } from "@calcom/features/MainLayout";
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer"; import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery"; 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 { Calendar } from "@calcom/ui/components/icon";
import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import useMeQuery from "@lib/hooks/useMeQuery";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
import BookingListItem from "@components/booking/BookingListItem"; import BookingListItem from "@components/booking/BookingListItem";
@@ -78,6 +80,7 @@ export default function Bookings() {
const { data: filterQuery } = useFilterQuery(); const { data: filterQuery } = useFilterQuery();
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const }; const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
const { t } = useLocale(); const { t } = useLocale();
const user = useMeQuery().data;
const query = trpc.viewer.bookings.get.useInfiniteQuery( const query = trpc.viewer.bookings.get.useInfiniteQuery(
{ {
@@ -119,7 +122,10 @@ export default function Bookings() {
} }
shownBookings[booking.recurringEventId] = [booking]; shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") { } 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; return true;
}; };
@@ -132,7 +138,11 @@ export default function Bookings() {
recurringInfoToday = page.recurringInfo.find( recurringInfoToday = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId (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] || []; )[0] || [];
@@ -166,6 +176,12 @@ export default function Bookings() {
{bookingsToday.map((booking: BookingOutput) => ( {bookingsToday.map((booking: BookingOutput) => (
<BookingListItem <BookingListItem
key={booking.id} key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status} listingStatus={status}
recurringInfo={recurringInfoToday} recurringInfo={recurringInfoToday}
{...booking} {...booking}
@@ -190,6 +206,12 @@ export default function Bookings() {
return ( return (
<BookingListItem <BookingListItem
key={booking.id} key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status} listingStatus={status}
recurringInfo={recurringInfo} recurringInfo={recurringInfo}
{...booking} {...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 { useAutoAnimate } from "@formkit/auto-animate/react";
import type { User } from "@prisma/client"; import type { User } from "@prisma/client";
import { Trans } from "next-i18next"; import { Trans } from "next-i18next";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import type { FC } from "react"; import type { FC } from "react";
import { memo, useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { z } from "zod"; import { z } from "zod";
@@ -19,6 +21,7 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF
import { ShellMain } from "@calcom/features/shell/Shell"; import { ShellMain } from "@calcom/features/shell/Shell";
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
@@ -208,7 +211,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding(); const orgBranding = useOrgBranding();
const [parent] = useAutoAnimate<HTMLUListElement>(); const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); 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; type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
return ( return (
<li key={type.id}> <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"> <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) && ( {!(firstItem && firstItem.id === type.id) && (
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" /> <ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
@@ -840,7 +843,7 @@ const Main = ({
filters: ReturnType<typeof getTeamsFiltersFromQuery>; filters: ReturnType<typeof getTeamsFiltersFromQuery>;
}) => { }) => {
const isMobile = useMediaQuery("(max-width: 768px)"); const isMobile = useMediaQuery("(max-width: 768px)");
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding(); const orgBranding = useOrgBranding();
if (!data || status === "loading") { if (!data || status === "loading") {
@@ -904,7 +907,7 @@ const Main = ({
const EventTypesPage = () => { const EventTypesPage = () => {
const { t } = useLocale(); const { t } = useLocale();
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const { open } = useIntercom(); const { open } = useIntercom();
const { data: user } = useMeQuery(); const { data: user } = useMeQuery();
const [showProfileBanner, setShowProfileBanner] = useState(false); const [showProfileBanner, setShowProfileBanner] = useState(false);
+1
View File
@@ -46,6 +46,7 @@ export default function InsightsPage() {
<div> <div>
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}> <ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
<UpgradeTip <UpgradeTip
plan="team"
title={t("make_informed_decisions")} title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")} description={t("make_informed_decisions_description")}
features={features} features={features}
@@ -250,10 +250,7 @@ const AppearanceView = ({
/> />
{lightModeError ? ( {lightModeError ? (
<div className="mt-4"> <div className="mt-4">
<Alert <Alert severity="warning" message={t("light_theme_contrast_error")} />
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -282,10 +279,7 @@ const AppearanceView = ({
/> />
{darkModeError ? ( {darkModeError ? (
<div className="mt-4"> <div className="mt-4">
<Alert <Alert severity="warning" message={t("dark_theme_contrast_error")} />
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div> </div>
) : null} ) : null}
</div> </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 SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; 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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt"; import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService"; import turndown from "@calcom/lib/turndownService";
@@ -72,37 +72,22 @@ interface DeleteAccountValues {
type FormValues = { type FormValues = {
username: string; username: string;
avatar: string | null; avatar: string;
name: string; name: string;
email: string; email: string;
bio: string; bio: string;
}; };
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK);
};
const ProfileView = () => { const ProfileView = () => {
const { t } = useLocale(); const { t } = useLocale();
const utils = trpc.useContext(); const utils = trpc.useContext();
const { update } = useSession(); const { update } = useSession();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>(undefined); const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
enabled: !isLoading && !user?.avatarUrl,
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 updateProfileMutation = trpc.viewer.updateProfile.useMutation({ const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => { onSuccess: async (res) => {
await update(res); await update(res);
@@ -234,7 +219,7 @@ const ProfileView = () => {
const defaultValues = { const defaultValues = {
username: user.username || "", username: user.username || "",
avatar: user.avatar || "", avatar: getUserAvatarUrl(user),
name: user.name || "", name: user.name || "",
email: user.email || "", email: user.email || "",
bio: user.bio || "", bio: user.bio || "",
@@ -251,8 +236,7 @@ const ProfileView = () => {
key={JSON.stringify(defaultValues)} key={JSON.stringify(defaultValues)}
defaultValues={defaultValues} defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading} isLoading={updateProfileMutation.isLoading}
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)} isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
userAvatar={user.avatar}
user={user} user={user}
userOrganization={user.organization} userOrganization={user.organization}
onSubmit={(values) => { onSubmit={(values) => {
@@ -398,7 +382,6 @@ const ProfileForm = ({
extraField, extraField,
isLoading = false, isLoading = false,
isFallbackImg, isFallbackImg,
userAvatar,
user, user,
userOrganization, userOrganization,
}: { }: {
@@ -407,7 +390,6 @@ const ProfileForm = ({
extraField?: React.ReactNode; extraField?: React.ReactNode;
isLoading: boolean; isLoading: boolean;
isFallbackImg: boolean; isFallbackImg: boolean;
userAvatar: string;
user: RouterOutputs["viewer"]["me"]; user: RouterOutputs["viewer"]["me"];
userOrganization: RouterOutputs["viewer"]["me"]["organization"]; userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => { }) => {
@@ -416,7 +398,7 @@ const ProfileForm = ({
const profileFormSchema = z.object({ const profileFormSchema = z.object({
username: z.string(), username: z.string(),
avatar: z.string().nullable(), avatar: z.string(),
name: z name: z
.string() .string()
.trim() .trim()
@@ -438,7 +420,6 @@ const ProfileForm = ({
} = formMethods; } = formMethods;
const isDisabled = isSubmitting || !isDirty; const isDisabled = isSubmitting || !isDirty;
return ( return (
<Form form={formMethods} handleSubmit={onSubmit}> <Form form={formMethods} handleSubmit={onSubmit}>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6"> <div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
@@ -447,7 +428,7 @@ const ProfileForm = ({
control={formMethods.control} control={formMethods.control}
name="avatar" name="avatar"
render={({ field: { value } }) => { render={({ field: { value } }) => {
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value); const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
const organization = const organization =
userOrganization && userOrganization.id userOrganization && userOrganization.id
? { ? {
@@ -474,7 +455,7 @@ const ProfileForm = ({
handleAvatarChange={(newAvatar) => { handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true }); formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}} }}
imageSrc={value || undefined} imageSrc={value}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"} triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
/> />
@@ -482,7 +463,7 @@ const ProfileForm = ({
<Button <Button
color="secondary" color="secondary"
onClick={() => { onClick={() => {
formMethods.setValue("avatar", null, { shouldDirty: true }); formMethods.setValue("avatar", "", { shouldDirty: true });
}}> }}>
{t("remove")} {t("remove")}
</Button> </Button>
+4 -4
View File
@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { SubmitHandler } from "react-hook-form"; import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } 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 { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; 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 { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify"; import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; 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 checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain] = email.split("@"); const [emailUser, emailDomain = ""] = email.split("@");
const username = const username =
emailDomain === autoAcceptEmailDomain emailDomain === autoAcceptEmailDomain
? slugify(emailUser) ? slugify(emailUser)
@@ -53,7 +53,7 @@ function addOrUpdateQueryParam(url: string, key: string, value: string) {
} }
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) { export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
const searchParams = useSearchParams(); const searchParams = useCompatSearchParams();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const flags = useFlagMap(); const flags = useFlagMap();
@@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
methods.clearErrors("apiError"); methods.clearErrors("apiError");
} }
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) { if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
methods.setValue( methods.setValue(
"username", "username",
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) 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]"))); .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) // .* 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 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 // 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).*`; exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes); /**
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`; * Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
exports.orgUserTypeRoutePath = `/:user((?!${beforeRewriteExcludePages.join( */
"/|" function getRegExpMatchingAllReservedRoutes(suffix) {
)}|_next/|public/)[^/]+)/:type((?!avatar\.png)[^/]+)`; // Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
exports.orgUserTypeEmbedRoutePath = `/:user((?!${beforeRewriteExcludePages.join( // Make sure to keep it upto date as more nonExistingRouteRewrites are added.
"/|" const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
)}|_next/|public/)[^/]+)/:type/embed`; 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 date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`; const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
await page.goto(troubleshooterURL); await page.goto(troubleshooterURL);
await page.waitForLoadState("networkidle");
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1); await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
}); });
}); });
+91
View File
@@ -1,6 +1,8 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random"; 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 { test } from "./lib/fixtures";
import { import {
@@ -342,3 +344,92 @@ test.describe("Booking on different layouts", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible(); 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
});
});
+126 -2
View File
@@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { randomString } from "@calcom/lib/random"; import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" }); 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("Event Types tests", () => {
test.describe("user", () => { testBothFutureAndLegacyRoutes.describe("user", () => {
test.beforeEach(async ({ page, users }) => { test.beforeEach(async ({ page, users }) => {
const user = await users.create(); const user = await users.create();
await user.apiLogin(); await user.apiLogin();
await page.goto("/event-types"); await page.goto("/event-types");
// We wait until loading is finished // We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]'); 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=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/); 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(); 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 // Except the first location, dropdown automatically opens when adding another location
if (index == 0) { if (index == 0) {
await page.locator("#location-select").last().click(); 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`; const locationInputName = `locations[${index}].address`;
await page.locator(`input[name="${locationInputName}"]`).waitFor(); await page.locator(`input[name="${locationInputName}"]`).waitFor();
await page.locator(`input[name="locations[${index}].address"]`).fill(inputText); await page.locator(`input[name="locations[${index}].address"]`).fill(inputText);
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 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 placeholder?: string
) => { ) => {
await page.getByTestId("add-field").click(); 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.getByTestId(`select-option-${questionType}`).click();
await page.getByLabel("Identifier").dblclick(); await page.getByLabel("Identifier").dblclick();
await page.getByLabel("Identifier").fill(identifier); 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 { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { Schedule } from "@calcom/types/schedule";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils"; import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import { TimeZoneEnum } from "./types"; import { TimeZoneEnum } from "./types";
@@ -46,6 +47,7 @@ const createTeamEventType = async (
schedulingType?: SchedulingType; schedulingType?: SchedulingType;
teamEventTitle?: string; teamEventTitle?: string;
teamEventSlug?: string; teamEventSlug?: string;
teamEventLength?: number;
} }
) => { ) => {
return await prisma.eventType.create({ return await prisma.eventType.create({
@@ -65,10 +67,16 @@ const createTeamEventType = async (
id: user.id, id: user.id,
}, },
}, },
hosts: {
create: {
userId: user.id,
isFixed: scenario?.schedulingType === SchedulingType.COLLECTIVE ? true : false,
},
},
schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE, schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE,
title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`, title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`,
slug: scenario?.teamEventSlug ?? `${teamEventSlug}-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, user,
isUnpublished, isUnpublished,
isOrg, isOrg,
isOrgVerified,
hasSubteam, hasSubteam,
organizationId, organizationId,
}: { }: {
user: { id: number; username: string | null; role?: MembershipRole }; user: { id: number; email: string; username: string | null; role?: MembershipRole };
isUnpublished?: boolean; isUnpublished?: boolean;
isOrg?: boolean; isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true; hasSubteam?: true;
organizationId?: number | null; organizationId?: number | null;
}, },
@@ -95,7 +105,14 @@ const createTeamAndAddUser = async (
}; };
data.metadata = { data.metadata = {
...(isUnpublished ? { requestedSlug: slug } : {}), ...(isUnpublished ? { requestedSlug: slug } : {}),
...(isOrg ? { isOrganization: true } : {}), ...(isOrg
? {
isOrganization: true,
isOrganizationVerified: !!isOrgVerified,
orgAutoAcceptEmail: user.email.split("@")[1],
isOrganizationConfigured: false,
}
: {}),
}; };
data.slug = !isUnpublished ? slug : undefined; data.slug = !isUnpublished ? slug : undefined;
if (isOrg && hasSubteam) { if (isOrg && hasSubteam) {
@@ -135,7 +152,9 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
schedulingType?: SchedulingType; schedulingType?: SchedulingType;
teamEventTitle?: string; teamEventTitle?: string;
teamEventSlug?: string; teamEventSlug?: string;
teamEventLength?: number;
isOrg?: boolean; isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true; hasSubteam?: true;
isUnpublished?: true; isUnpublished?: true;
} = {} } = {}
@@ -283,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
if (scenario.hasTeam) { if (scenario.hasTeam) {
const team = await createTeamAndAddUser( 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, isUnpublished: scenario.isUnpublished,
isOrg: scenario.isOrg, isOrg: scenario.isOrg,
isOrgVerified: scenario.isOrgVerified,
hasSubteam: scenario.hasSubteam, hasSubteam: scenario.hasSubteam,
organizationId: opts?.organizationId, organizationId: opts?.organizationId,
}, },
@@ -376,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
await prisma.user.delete({ where: { id } }); await prisma.user.delete({ where: { id } });
store.users = store.users.filter((b) => b.id !== 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, eventTypes: user.eventTypes,
routingForms: user.routingForms, routingForms: user.routingForms,
self, 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), login: async () => login({ ...(await self()), password: user.username }, store.page),
logout: async () => { logout: async () => {
await page.goto("/auth/logout"); await page.goto("/auth/logout");
@@ -489,6 +522,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
// ignores adding the worker-index after username // ignores adding the worker-index after username
useExactUsername?: boolean; useExactUsername?: boolean;
roleInOrganization?: MembershipRole; roleInOrganization?: MembershipRole;
schedule?: Schedule;
}; };
// creates the actual user in the db. // creates the actual user in the db.
@@ -520,7 +554,7 @@ const createUser = (
timeZone: opts?.timeZone ?? TimeZoneEnum.UK, timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
availability: { availability: {
createMany: { 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) { export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
await page.goto(`/event-types/${eventType?.id}?tabName=apps`); 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.getByPlaceholder("Price").fill("100");
await page.getByTestId("update-eventtype").click(); await page.getByTestId("update-eventtype").click();
} }
+94 -1
View File
@@ -1,6 +1,10 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import type Prisma from "@prisma/client"; 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 { test } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures"; import type { Fixtures } from "./lib/fixtures";
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; 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 }) => { test("Can book a paid booking", async ({ page, users }) => {
const user = await users.create(); const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; 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"); await page.getByTestId("price-input-stripe").fill("200");
// Select currency in dropdown // 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-input").fill("mexi");
await page.locator("#react-select-2-option-81").click(); 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 mailhog from "mailhog";
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config"; import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings"; import { createBookingsFixture } from "../fixtures/bookings";
import { createClipboardFixture } from "../fixtures/clipboard";
import { createEmbedsFixture } from "../fixtures/embeds"; import { createEmbedsFixture } from "../fixtures/embeds";
import { createOrgsFixture } from "../fixtures/orgs"; import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments"; import { createPaymentsFixture } from "../fixtures/payments";
@@ -28,6 +30,7 @@ export interface Fixtures {
emails?: API; emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>; routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>; bookingPage: ReturnType<typeof createBookingPageFixture>;
clipboard: ReturnType<typeof createClipboardFixture>;
} }
declare global { declare global {
@@ -85,6 +88,8 @@ export const test = base.extend<Fixtures>({
const mailhogAPI = mailhog(); const mailhogAPI = mailhog();
await use(mailhogAPI); await use(mailhogAPI);
} else { } 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); await use(undefined);
} }
}, },
@@ -92,4 +97,8 @@ export const test = base.extend<Fixtures>({
const bookingPage = createBookingPageFixture(page); const bookingPage = createBookingPageFixture(page);
await use(bookingPage); 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 type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { createHash } from "crypto";
import EventEmitter from "events"; import EventEmitter from "events";
import type { IncomingMessage, ServerResponse } from "http"; import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http"; import { createServer } from "http";
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { noop } from "lodash"; import { noop } from "lodash";
import type { API, Messages } from "mailhog"; import type { API, Messages } from "mailhog";
import { totp } from "otplib";
import type { Prisma } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums";
@@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees(
}); });
return { user, eventType, booking }; 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(); const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const userPassword = user.username!; const userPassword = user.username!;
await user.login(); await user.apiLogin();
// expects the home page for an authorized user // expects the home page for an authorized user
await page.goto("/settings/security/two-factor-auth"); await page.goto("/settings/security/two-factor-auth");
@@ -94,7 +94,7 @@ test.describe("2FA Tests", async () => {
const user = await users.create(); const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const userPassword = user.username!; const userPassword = user.username!;
await user.login(); await user.apiLogin();
// expects the home page for an authorized user // expects the home page for an authorized user
await page.goto("/settings/security/two-factor-auth"); await page.goto("/settings/security/two-factor-auth");
+30 -2
View File
@@ -1,6 +1,9 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
import { localize } from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll()); test.afterEach(({ users }) => users.deleteAll());
@@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => {
await page.goto("/event-types"); await page.goto("/event-types");
await page.getByTestId("event-types").locator('a[title="managed"]').click(); await page.getByTestId("event-types").locator('a[title="managed"]').click();
await page.getByTestId("vertical-tab-assignment").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.getByTestId(`select-option-${memberUser.id}`).click();
await page.locator('[type="submit"]').click(); await page.locator('[type="submit"]').click();
await page.getByTestId("toast-success").waitFor(); 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 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 // Coming back as member user to see if there is a managed event present after assignment
await memberUser.apiLogin(); await memberUser.apiLogin();
await page.goto("/event-types"); 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.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click(); 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("select-option-usd").click();
await page.getByTestId("price-input-stripe").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").click();
await page.getByPlaceholder("Price").fill("150"); 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("#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.getByText("$MXNCurrencyMexican pesoPayment option").click();
await page.getByTestId("update-eventtype").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. // Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent(); const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull(); 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 // TODO: Assert whether the user received an email
}); });
@@ -351,7 +351,7 @@ test.describe("Teams - Org", () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`); 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( await doOnOrgDomain(
{ {
orgSlug: org.slug, orgSlug: org.slug,
@@ -370,7 +370,7 @@ test.describe("Teams - Org", () => {
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// All the teammates should be in the booking // 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(); 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. // Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent(); const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull(); 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 // 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.videoCallData = dynamic;
body.payload.appsStatus = dynamic; body.payload.appsStatus = dynamic;
body.payload.metadata.videoCallUrl = dynamic; body.payload.metadata.videoCallUrl = dynamic;
expect(body).toMatchObject({ expect(body).toMatchObject({
triggerEvent: "BOOKING_CREATED", triggerEvent: "BOOKING_CREATED",
createdAt: "[redacted/dynamic]", createdAt: "[redacted/dynamic]",
payload: { payload: {
type: "30 min", type: "30-min",
title: "30 min between Nameless and Test Testson", title: "30 min between Nameless and Test Testson",
description: "", description: "",
additionalNotes: "", additionalNotes: "",
@@ -236,7 +235,7 @@ test.describe("BOOKING_REJECTED", async () => {
triggerEvent: "BOOKING_REJECTED", triggerEvent: "BOOKING_REJECTED",
createdAt: "[redacted/dynamic]", createdAt: "[redacted/dynamic]",
payload: { payload: {
type: "Opt in", type: "opt-in",
title: "Opt in between Nameless and Test Testson", title: "Opt in between Nameless and Test Testson",
customInputs: {}, customInputs: {},
startTime: "[redacted/dynamic]", startTime: "[redacted/dynamic]",
@@ -357,7 +356,7 @@ test.describe("BOOKING_REQUESTED", async () => {
triggerEvent: "BOOKING_REQUESTED", triggerEvent: "BOOKING_REQUESTED",
createdAt: "[redacted/dynamic]", createdAt: "[redacted/dynamic]",
payload: { payload: {
type: "Opt in", type: "opt-in",
title: "Opt in between Nameless and Test Testson", title: "Opt in between Nameless and Test Testson",
customInputs: {}, customInputs: {},
startTime: "[redacted/dynamic]", startTime: "[redacted/dynamic]",
+1 -1
View File
@@ -17,7 +17,7 @@ test.describe("Workflow tests", () => {
async ({ page, users }) => { async ({ page, users }) => {
const user = await users.create(); const user = await users.create();
const [eventType] = user.eventTypes; const [eventType] = user.eventTypes;
await user.login(); await user.apiLogin();
await page.goto(`/workflows`); await page.goto(`/workflows`);
await page.click('[data-testid="create-button"]'); await page.click('[data-testid="create-button"]');
@@ -268,6 +268,7 @@
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا", "set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
"availability_settings": "إعدادات التوافرية", "availability_settings": "إعدادات التوافرية",
"continue_without_calendar": "المتابعة من دون تقويم", "continue_without_calendar": "المتابعة من دون تقويم",
"continue_with": "الاستمرار مع {{appName}}",
"connect_your_calendar": "ربط التقويم لديك", "connect_your_calendar": "ربط التقويم لديك",
"connect_your_video_app": "اربط تطبيقات الفيديو لديك", "connect_your_video_app": "اربط تطبيقات الفيديو لديك",
"connect_your_video_app_instructions": "قم بتوصيل تطبيقات الفيديو لديك لاستخدامها في أنواع الأحداث الخاصة بك.", "connect_your_video_app_instructions": "قم بتوصيل تطبيقات الفيديو لديك لاستخدامها في أنواع الأحداث الخاصة بك.",
@@ -288,6 +289,8 @@
"when": "متى", "when": "متى",
"where": "أين", "where": "أين",
"add_to_calendar": "إضافة إلى التقويم", "add_to_calendar": "إضافة إلى التقويم",
"add_to_calendar_description": "اختر أين ستُضاف الأحداث الجديدة عندما يتم الحجز لديك.",
"add_events_to": "إضافة أحداث إلى",
"add_another_calendar": "إضافة تقويم آخر", "add_another_calendar": "إضافة تقويم آخر",
"other": "آخر", "other": "آخر",
"email_sign_in_subject": "رابط تسجيل الدخول بك لـ{{appName}}", "email_sign_in_subject": "رابط تسجيل الدخول بك لـ{{appName}}",
@@ -422,6 +425,7 @@
"booking_created": "تم إنشاء الحجز", "booking_created": "تم إنشاء الحجز",
"booking_rejected": "تم رفض الحجز", "booking_rejected": "تم رفض الحجز",
"booking_requested": "تم طلب الحجز", "booking_requested": "تم طلب الحجز",
"booking_payment_initiated": "بدء الدفع للحجز",
"meeting_ended": "انتهى الاجتماع", "meeting_ended": "انتهى الاجتماع",
"form_submitted": "تم إرسال النموذج", "form_submitted": "تم إرسال النموذج",
"booking_paid": "تم الدفع لقاء الحجز", "booking_paid": "تم الدفع لقاء الحجز",
@@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "لم يقم هذا المستخدم بإعداد أي أنواع للحدث حتى الآن.", "no_event_types_have_been_setup": "لم يقم هذا المستخدم بإعداد أي أنواع للحدث حتى الآن.",
"edit_logo": "تعديل الشعار", "edit_logo": "تعديل الشعار",
"upload_a_logo": "تحميل شعار", "upload_a_logo": "تحميل شعار",
"upload_logo": "تحميل شعار",
"remove_logo": "إزالة الشعار", "remove_logo": "إزالة الشعار",
"enable": "تمكين", "enable": "تمكين",
"code": "الرمز", "code": "الرمز",
@@ -568,6 +573,7 @@
"your_team_name": "اسم فريقك", "your_team_name": "اسم فريقك",
"team_updated_successfully": "تم تحديث الفريق بنجاح", "team_updated_successfully": "تم تحديث الفريق بنجاح",
"your_team_updated_successfully": "تم تحديث فريقك بنجاح.", "your_team_updated_successfully": "تم تحديث فريقك بنجاح.",
"your_org_updated_successfully": "تم تحديث منظمتك بنجاح.",
"about": "حول", "about": "حول",
"team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.", "team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.",
"org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.", "org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.",
@@ -599,6 +605,7 @@
"hide_book_a_team_member": "إخفاء الزر \"حجز عضو فريق\"", "hide_book_a_team_member": "إخفاء الزر \"حجز عضو فريق\"",
"hide_book_a_team_member_description": "إخفاء الزر \"حجز عضو فريق\" من صفحاتك العامة.", "hide_book_a_team_member_description": "إخفاء الزر \"حجز عضو فريق\" من صفحاتك العامة.",
"danger_zone": "منطقة خطر", "danger_zone": "منطقة خطر",
"account_deletion_cannot_be_undone": "احترس. لا يمكن التراجع عن حذف الحساب.",
"back": "عودة", "back": "عودة",
"cancel": "إلغاء", "cancel": "إلغاء",
"cancel_all_remaining": "إلغاء كل ما تبقى", "cancel_all_remaining": "إلغاء كل ما تبقى",
@@ -688,6 +695,7 @@
"people": "الأشخاص", "people": "الأشخاص",
"your_email": "بريدك الإلكتروني", "your_email": "بريدك الإلكتروني",
"change_avatar": "تغيير الصورة الرمزية", "change_avatar": "تغيير الصورة الرمزية",
"upload_avatar": "تحميل صورة رمزية",
"language": "اللغة", "language": "اللغة",
"timezone": "المنطقة الزمنية", "timezone": "المنطقة الزمنية",
"first_day_of_week": "أول يوم في الأسبوع", "first_day_of_week": "أول يوم في الأسبوع",
@@ -778,6 +786,7 @@
"disable_guests": "تعطيل خاصية الضيوف", "disable_guests": "تعطيل خاصية الضيوف",
"disable_guests_description": "قم بتعطيل إضافة مزيد من الضيوف أثناء الحجز.", "disable_guests_description": "قم بتعطيل إضافة مزيد من الضيوف أثناء الحجز.",
"private_link": "إنشاء رابط خاص", "private_link": "إنشاء رابط خاص",
"enable_private_url": "تمكين عنوان URL خاص",
"private_link_label": "رابط خاص", "private_link_label": "رابط خاص",
"private_link_hint": "سيتم إعادة توليد الرابط الخاص بك بعد كل استخدام", "private_link_hint": "سيتم إعادة توليد الرابط الخاص بك بعد كل استخدام",
"copy_private_link": "نسخ الرابط الخاص", "copy_private_link": "نسخ الرابط الخاص",
@@ -840,6 +849,7 @@
"next_step": "تخطي الخطوة", "next_step": "تخطي الخطوة",
"prev_step": "الخطوة السابقة", "prev_step": "الخطوة السابقة",
"install": "تثبيت", "install": "تثبيت",
"install_paid_app": "اشتراك",
"installed": "تم التثبيت", "installed": "تم التثبيت",
"active_install_one": "{{count}} تثبيت نشط", "active_install_one": "{{count}} تثبيت نشط",
"active_install_other": "{{count}} تثبيت نشط", "active_install_other": "{{count}} تثبيت نشط",
@@ -1088,6 +1098,7 @@
"developer_documentation": "مستندات المطور", "developer_documentation": "مستندات المطور",
"get_in_touch": "تواصل معنا", "get_in_touch": "تواصل معنا",
"contact_support": "الاتصال بالدعم", "contact_support": "الاتصال بالدعم",
"community_support": "الدعم المجتمعي",
"feedback": "الملاحظات", "feedback": "الملاحظات",
"submitted_feedback": "نشكرك على ملاحظاتك!", "submitted_feedback": "نشكرك على ملاحظاتك!",
"feedback_error": "حدث خطأ عند إرسال الملاحظات", "feedback_error": "حدث خطأ عند إرسال الملاحظات",
@@ -1213,6 +1224,7 @@
"organizer_name_variable": "اسم المنظم", "organizer_name_variable": "اسم المنظم",
"app_upgrade_description": "لاستخدام هذه الميزة، تحتاج إلى الترقية إلى حساب Pro.", "app_upgrade_description": "لاستخدام هذه الميزة، تحتاج إلى الترقية إلى حساب Pro.",
"invalid_number": "رقم الهاتف غير صالح", "invalid_number": "رقم الهاتف غير صالح",
"invalid_url_error_message": "عنوان URL غير صالح من أجل {{label}}. عنوان URL للعينة: {{sampleUrl}}",
"navigate": "تنقّل", "navigate": "تنقّل",
"open": "فتح", "open": "فتح",
"close": "إغلاق", "close": "إغلاق",
@@ -1276,6 +1288,7 @@
"personal_cal_url": "عنوان {{appName}} URL الخاص بي", "personal_cal_url": "عنوان {{appName}} URL الخاص بي",
"bio_hint": "اكتب بضع جمل عن نفسك، والتي ستظهر على صفحة عنوان Url الشخصية لديك.", "bio_hint": "اكتب بضع جمل عن نفسك، والتي ستظهر على صفحة عنوان Url الشخصية لديك.",
"user_has_no_bio": "لم يُضف هذا المستخدم لمحة عنه بعد.", "user_has_no_bio": "لم يُضف هذا المستخدم لمحة عنه بعد.",
"bio": "سيرة ذاتية",
"delete_account_modal_title": "حذف الحساب", "delete_account_modal_title": "حذف الحساب",
"confirm_delete_account_modal": "هل أنت متأكد من أنك تريد حذف حساب {{appName}} الخاص بك؟", "confirm_delete_account_modal": "هل أنت متأكد من أنك تريد حذف حساب {{appName}} الخاص بك؟",
"delete_my_account": "حذف حسابي", "delete_my_account": "حذف حسابي",
@@ -1286,6 +1299,7 @@
"select_calendars": "حدد الرزنامات التي تريد التحقق منها بحثًا عن تضاربات لمنع الحجوزات المزدوجة.", "select_calendars": "حدد الرزنامات التي تريد التحقق منها بحثًا عن تضاربات لمنع الحجوزات المزدوجة.",
"check_for_conflicts": "التحقق من وجود تضاربات", "check_for_conflicts": "التحقق من وجود تضاربات",
"view_recordings": "عرض التسجيلات", "view_recordings": "عرض التسجيلات",
"check_for_recordings": "تحقق من وجود تسجيلات",
"adding_events_to": "إضافة أحداث إلى", "adding_events_to": "إضافة أحداث إلى",
"follow_system_preferences": "اتبع تفضيلات النظام", "follow_system_preferences": "اتبع تفضيلات النظام",
"custom_brand_colors": "ألوان العلامة التجارية المخصصة", "custom_brand_colors": "ألوان العلامة التجارية المخصصة",
@@ -1530,6 +1544,7 @@
"problem_registering_domain": "حدثت مشكلة في تسجيل المجال الفرعي، يُرجى المحاولة لاحقاً أو الاتصال بالمشرف", "problem_registering_domain": "حدثت مشكلة في تسجيل المجال الفرعي، يُرجى المحاولة لاحقاً أو الاتصال بالمشرف",
"team_publish": "نشر فريق", "team_publish": "نشر فريق",
"number_text_notifications": "رقم الهاتف (إشعارات الرسائل النصية)", "number_text_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
"number_sms_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
"attendee_email_variable": "اسم الحاضر", "attendee_email_variable": "اسم الحاضر",
"attendee_email_info": "البريد الإلكتروني للشخص الحجز", "attendee_email_info": "البريد الإلكتروني للشخص الحجز",
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...", "kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
@@ -1594,6 +1609,7 @@
"options": "خيارات", "options": "خيارات",
"enter_option": "أدخل خيار {{index}}", "enter_option": "أدخل خيار {{index}}",
"add_an_option": "إضافة خيار", "add_an_option": "إضافة خيار",
"location_already_exists": "هذا الموقع موجود بالفعل. الرجاء اختيار موقع جديد",
"radio": "الراديو", "radio": "الراديو",
"google_meet_warning": "من أجل استخدام Google Meet، يجب عليك تعيين تقويم وجهتك على تقويم Google", "google_meet_warning": "من أجل استخدام Google Meet، يجب عليك تعيين تقويم وجهتك على تقويم Google",
"individual": "فرد", "individual": "فرد",
@@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "وضع علامة غير متاح على التواريخ المحددة", "date_overrides_mark_all_day_unavailable_other": "وضع علامة غير متاح على التواريخ المحددة",
"date_overrides_add_btn": "إضافة تجاوز", "date_overrides_add_btn": "إضافة تجاوز",
"date_overrides_update_btn": "تحديث التجاوز", "date_overrides_update_btn": "تحديث التجاوز",
"date_successfully_added": "تم إضافة تخطّي التاريخ بنجاح",
"event_type_duplicate_copy_text": "{{slug}}-نسخ", "event_type_duplicate_copy_text": "{{slug}}-نسخ",
"set_as_default": "تعيين كافتراضي", "set_as_default": "تعيين كافتراضي",
"hide_eventtype_details": "إخفاء تفاصيل نوع الحدث", "hide_eventtype_details": "إخفاء تفاصيل نوع الحدث",
@@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "عدد المضيفين المطلوب حضورهم", "minimum_round_robin_hosts_count": "عدد المضيفين المطلوب حضورهم",
"hosts": "المضيفون", "hosts": "المضيفون",
"upgrade_to_enable_feature": "تحتاج إلى إنشاء فريق لتمكين هذه الميزة. انقر لإنشاء فريق.", "upgrade_to_enable_feature": "تحتاج إلى إنشاء فريق لتمكين هذه الميزة. انقر لإنشاء فريق.",
"orgs_upgrade_to_enable_feature": "تحتاج إلى الترقية إلى خطة enterprise لتفعيل هذه الميزة.",
"new_attendee": "حضور جديد", "new_attendee": "حضور جديد",
"awaiting_approval": "في انتظار الموافقة", "awaiting_approval": "في انتظار الموافقة",
"requires_google_calendar": "يتطلب هذا التطبيق اتصال تقويم Google", "requires_google_calendar": "يتطلب هذا التطبيق اتصال تقويم Google",
@@ -1743,6 +1761,7 @@
"show_on_booking_page": "إظهار في صفحة الحجز", "show_on_booking_page": "إظهار في صفحة الحجز",
"get_started_zapier_templates": "البدء في استخدام قوالب Zapier", "get_started_zapier_templates": "البدء في استخدام قوالب Zapier",
"team_is_unpublished": "لم يُنشر {{team}}", "team_is_unpublished": "لم يُنشر {{team}}",
"org_is_unpublished_description": "رابط هذه المنظمة غير متاح حاليًا. يرجى الاتصال بمالك المنظمة أو طلب النشر منه.",
"team_is_unpublished_description": "رابط هذا {{entity}} غير متاح حاليًا. يرجى الاتصال بمالك {{entity}} أو طلب نشره منه.", "team_is_unpublished_description": "رابط هذا {{entity}} غير متاح حاليًا. يرجى الاتصال بمالك {{entity}} أو طلب نشره منه.",
"team_member": "عضو الفريق", "team_member": "عضو الفريق",
"a_routing_form": "نموذج توجيه", "a_routing_form": "نموذج توجيه",
@@ -1877,6 +1896,7 @@
"edit_invite_link": "تعديل إعدادات الرابط", "edit_invite_link": "تعديل إعدادات الرابط",
"invite_link_copied": "تم نسخ رابط الدعوة", "invite_link_copied": "تم نسخ رابط الدعوة",
"invite_link_deleted": "تم حذف رابط الدعوة", "invite_link_deleted": "تم حذف رابط الدعوة",
"api_key_deleted": "تم حذف مفتاح API",
"invite_link_updated": "تم حفظ إعدادات رابط الدعوة", "invite_link_updated": "تم حفظ إعدادات رابط الدعوة",
"link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...", "link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...",
"one_day": "1 يوم", "one_day": "1 يوم",
@@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "اسم العائلة للحاضر", "attendee_last_name_variable": "اسم العائلة للحاضر",
"attendee_first_name_info": "الاسم الأول للشخص صاحب الحجز", "attendee_first_name_info": "الاسم الأول للشخص صاحب الحجز",
"attendee_last_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": "أنا", "me": "أنا",
"monthly_digest_email": "الرسالة الإلكترونية للموجز الشهري",
"monthly_digest_email_for_teams": "رسالة إلكترونية بالموجز الشهري للفرق",
"verify_team_tooltip": "تأكيد فريقك لتمكين إرسال الرسائل إلى الحاضرين", "verify_team_tooltip": "تأكيد فريقك لتمكين إرسال الرسائل إلى الحاضرين",
"member_removed": "تمت إزالة العضو", "member_removed": "تمت إزالة العضو",
"my_availability": "أوقاتي المتاحة", "my_availability": "أوقاتي المتاحة",
@@ -2039,12 +2065,41 @@
"team_no_event_types": "ليس لدى هذا الفريق أنواع من الفعاليات", "team_no_event_types": "ليس لدى هذا الفريق أنواع من الفعاليات",
"seat_options_doesnt_multiple_durations": "لا يدعم خيار المقعد فترات متعددة", "seat_options_doesnt_multiple_durations": "لا يدعم خيار المقعد فترات متعددة",
"include_calendar_event": "إدراج فعاليات في التقويم", "include_calendar_event": "إدراج فعاليات في التقويم",
"oAuth": "OAuth",
"recently_added": "تمت الإضافة مؤخراً", "recently_added": "تمت الإضافة مؤخراً",
"no_members_found": "لم يُعثر على أعضاء", "no_members_found": "لم يُعثر على أعضاء",
"event_setup_length_error": "إعداد الفعالية: يجب أن تكون المدة لدقيقة على الأقل.", "event_setup_length_error": "إعداد الفعالية: يجب أن تكون المدة لدقيقة على الأقل.",
"availability_schedules": "جدولة التوافر", "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_not_onboarded": "لم يكمل هذا المستخدم عملية الانضمام. لن تتمكن من تعيين توافره حتى يكتمل انضمامه.",
"view_only_edit_availability": "أنت تشاهد توافر هذا المستخدم. يمكنك تعديل توافرك فقط.", "view_only_edit_availability": "أنت تشاهد توافر هذا المستخدم. يمكنك تعديل توافرك فقط.",
"you_can_override_calendar_in_advanced_tab": "يمكنك تجاوز هذا لكل حدث على حدة في الإعدادات المتقدمة في كل نوع من أنواع الأحداث.",
"edit_users_availability": "تعديل توافر المستخدم: {{username}}", "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": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }
@@ -268,6 +268,7 @@
"set_availability": "Nastavte, jak jste dostupní", "set_availability": "Nastavte, jak jste dostupní",
"availability_settings": "Nastavení dostupnosti", "availability_settings": "Nastavení dostupnosti",
"continue_without_calendar": "Pokračovat bez kalendáře", "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_calendar": "Připojit svůj kalendář",
"connect_your_video_app": "Propojte své video aplikace", "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í.", "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", "when": "Kdy",
"where": "Kde", "where": "Kde",
"add_to_calendar": "Přidat do kalendáře", "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ář", "add_another_calendar": "Přidat další kalendář",
"other": "Ostatní", "other": "Ostatní",
"email_sign_in_subject": "Váš přihlašovací odkaz pro {{appName}}", "email_sign_in_subject": "Váš přihlašovací odkaz pro {{appName}}",
@@ -422,6 +425,7 @@
"booking_created": "Rezervace vytvořena", "booking_created": "Rezervace vytvořena",
"booking_rejected": "Rezervace byla zamítnuta", "booking_rejected": "Rezervace byla zamítnuta",
"booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán", "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", "meeting_ended": "Schůzka skončila",
"form_submitted": "Formulář byl odeslán", "form_submitted": "Formulář byl odeslán",
"booking_paid": "Rezervace uhrazena", "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í.", "no_event_types_have_been_setup": "Tento uživatel zatím nezaložil žádné typy událostí.",
"edit_logo": "Upravit logo", "edit_logo": "Upravit logo",
"upload_a_logo": "Nahrát logo", "upload_a_logo": "Nahrát logo",
"upload_logo": "Nahrát logo",
"remove_logo": "Odstranit logo", "remove_logo": "Odstranit logo",
"enable": "Povolit", "enable": "Povolit",
"code": "Kód", "code": "Kód",
@@ -568,6 +573,7 @@
"your_team_name": "Název vašeho týmu", "your_team_name": "Název vašeho týmu",
"team_updated_successfully": "Tým byl úspěšně aktualizován", "team_updated_successfully": "Tým byl úspěšně aktualizován",
"your_team_updated_successfully": "Váš 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", "about": "O aplikaci",
"team_description": "Pár vět o vašem týmu. Objeví se na URL stránce vašeho týmu.", "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.", "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": "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.", "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", "danger_zone": "Nebezpečná zóna",
"account_deletion_cannot_be_undone": "Pozor. Odstranění účtu nelze vrátit.",
"back": "Zpět", "back": "Zpět",
"cancel": "Zrušit", "cancel": "Zrušit",
"cancel_all_remaining": "Zrušit vše zbývající", "cancel_all_remaining": "Zrušit vše zbývající",
@@ -688,6 +695,7 @@
"people": "Lidé", "people": "Lidé",
"your_email": "Váš e-mail", "your_email": "Váš e-mail",
"change_avatar": "Změnit avatar", "change_avatar": "Změnit avatar",
"upload_avatar": "Nahrát avatar",
"language": "Jazyk", "language": "Jazyk",
"timezone": "Časová zóna", "timezone": "Časová zóna",
"first_day_of_week": "První den v týdnu", "first_day_of_week": "První den v týdnu",
@@ -778,6 +786,7 @@
"disable_guests": "Zakázat hosty", "disable_guests": "Zakázat hosty",
"disable_guests_description": "Zakázat přidávání dalších hostů během rezervace.", "disable_guests_description": "Zakázat přidávání dalších hostů během rezervace.",
"private_link": "Vygenerovat soukromou adresu URL", "private_link": "Vygenerovat soukromou adresu URL",
"enable_private_url": "Zapnout soukromou adresu URL",
"private_link_label": "Soukromý odkaz", "private_link_label": "Soukromý odkaz",
"private_link_hint": "Váš soukromý odkaz se po každém použití obnoví", "private_link_hint": "Váš soukromý odkaz se po každém použití obnoví",
"copy_private_link": "Zkopírovat soukromý odkaz", "copy_private_link": "Zkopírovat soukromý odkaz",
@@ -840,6 +849,7 @@
"next_step": "Přeskočit krok", "next_step": "Přeskočit krok",
"prev_step": "Předchozí krok", "prev_step": "Předchozí krok",
"install": "Nainstalovat", "install": "Nainstalovat",
"install_paid_app": "Odebírat",
"installed": "Nainstalováno", "installed": "Nainstalováno",
"active_install_one": "Aktivní instalace: {{count}}", "active_install_one": "Aktivní instalace: {{count}}",
"active_install_other": "Aktivní instalace: {{count}}", "active_install_other": "Aktivní instalace: {{count}}",
@@ -1088,6 +1098,7 @@
"developer_documentation": "Dokumentace vývojáře", "developer_documentation": "Dokumentace vývojáře",
"get_in_touch": "Kontaktujte nás", "get_in_touch": "Kontaktujte nás",
"contact_support": "Kontaktujte podporu", "contact_support": "Kontaktujte podporu",
"community_support": "Podpora komunity",
"feedback": "Zpětná vazba", "feedback": "Zpětná vazba",
"submitted_feedback": "Děkujeme za vaši zpětnou vazbu!", "submitted_feedback": "Děkujeme za vaši zpětnou vazbu!",
"feedback_error": "Chyba při odesílání zpětné vazby", "feedback_error": "Chyba při odesílání zpětné vazby",
@@ -1213,6 +1224,7 @@
"organizer_name_variable": "Jméno organizátora", "organizer_name_variable": "Jméno organizátora",
"app_upgrade_description": "Pokud chcete použít tuto funkci, musíte provést aktualizaci na účet Pro.", "app_upgrade_description": "Pokud chcete použít tuto funkci, musíte provést aktualizaci na účet Pro.",
"invalid_number": "Neplatné telefonní číslo", "invalid_number": "Neplatné telefonní číslo",
"invalid_url_error_message": "Neplatná adresa URL pro {{label}}. Příklad URL: {{sampleUrl}}",
"navigate": "Navigace", "navigate": "Navigace",
"open": "Otevřít", "open": "Otevřít",
"close": "Zavřít", "close": "Zavřít",
@@ -1276,6 +1288,7 @@
"personal_cal_url": "Moje osobní adresa URL {{appName}}", "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.", "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.", "user_has_no_bio": "Tento uživatel zatím nepřidal životopis.",
"bio": "Bio",
"delete_account_modal_title": "Odstranit účet", "delete_account_modal_title": "Odstranit účet",
"confirm_delete_account_modal": "Opravdu chcete odstranit svůj účet {{appName}}?", "confirm_delete_account_modal": "Opravdu chcete odstranit svůj účet {{appName}}?",
"delete_my_account": "Odstranit můj účet", "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í.", "select_calendars": "Vyberte kalendáře, u kterých chcete kontrolovat konflikty v zájmu prevence dvojích rezervací.",
"check_for_conflicts": "Zkontrolovat konflikty", "check_for_conflicts": "Zkontrolovat konflikty",
"view_recordings": "Zobrazit nahrávky", "view_recordings": "Zobrazit nahrávky",
"check_for_recordings": "Zkontrolovat nahrávky",
"adding_events_to": "Přidání událostí do:", "adding_events_to": "Přidání událostí do:",
"follow_system_preferences": "Řídit se předvolbami systému", "follow_system_preferences": "Řídit se předvolbami systému",
"custom_brand_colors": "Vlastní barvy značky", "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", "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", "team_publish": "Zveřejnit tým",
"number_text_notifications": "Telefonní číslo (SMS oznámení)", "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_variable": "E-mail účastníka",
"attendee_email_info": "E-mail osoby provádějící rezervaci", "attendee_email_info": "E-mail osoby provádějící rezervaci",
"kbar_search_placeholder": "Zadejte příkaz nebo vyhledejte...", "kbar_search_placeholder": "Zadejte příkaz nebo vyhledejte...",
@@ -1594,6 +1609,7 @@
"options": "Možnosti", "options": "Možnosti",
"enter_option": "Zadejte možnost {{index}}", "enter_option": "Zadejte možnost {{index}}",
"add_an_option": "Přidat možnost", "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č", "radio": "Přepínač",
"google_meet_warning": "Abyste mohli používat službu Google Meet, musíte jako cílový kalendář nastavit Kalendář Google", "google_meet_warning": "Abyste mohli používat službu Google Meet, musíte jako cílový kalendář nastavit Kalendář Google",
"individual": "Jedinec", "individual": "Jedinec",
@@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "Zadat nedostupnost ve vybraných datech", "date_overrides_mark_all_day_unavailable_other": "Zadat nedostupnost ve vybraných datech",
"date_overrides_add_btn": "Přidat změnu", "date_overrides_add_btn": "Přidat změnu",
"date_overrides_update_btn": "Aktualizovat 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", "event_type_duplicate_copy_text": "{{slug}} – kopie",
"set_as_default": "Nastavit jako výchozí", "set_as_default": "Nastavit jako výchozí",
"hide_eventtype_details": "Skrýt podrobnosti o typu události", "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", "minimum_round_robin_hosts_count": "Počet hostitelů, kteří se musí zúčastnit",
"hosts": "Hostitelé", "hosts": "Hostitelé",
"upgrade_to_enable_feature": "K povolení této funkce je třeba vytvořit tým. Tým vytvoříte kliknutím.", "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", "new_attendee": "Nový účastník",
"awaiting_approval": "Čeká na schválení", "awaiting_approval": "Čeká na schválení",
"requires_google_calendar": "Tato aplikace vyžaduje připojení ke Kalendáři Google", "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", "show_on_booking_page": "Zobrazit na stránce rezervace",
"get_started_zapier_templates": "Začněte používat šablony Zapier", "get_started_zapier_templates": "Začněte používat šablony Zapier",
"team_is_unpublished": "Tým {{team}} není zveřejněn", "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_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", "team_member": "Člen týmu",
"a_routing_form": "Směrovací formulář", "a_routing_form": "Směrovací formulář",
@@ -1877,6 +1896,7 @@
"edit_invite_link": "Upravit nastavení odkazu", "edit_invite_link": "Upravit nastavení odkazu",
"invite_link_copied": "Odkaz pozvánky byl zkopírován", "invite_link_copied": "Odkaz pozvánky byl zkopírován",
"invite_link_deleted": "Odkaz pozvánky byl odstraně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", "invite_link_updated": "Nastavení odkazu pozvánky bylo uloženo",
"link_expires_after": "Platnost odkazů vyprší za...", "link_expires_after": "Platnost odkazů vyprší za...",
"one_day": "1 den", "one_day": "1 den",
@@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "Příjmení účastníka", "attendee_last_name_variable": "Příjmení účastníka",
"attendee_first_name_info": "Jméno rezervující osoby", "attendee_first_name_info": "Jméno rezervující osoby",
"attendee_last_name_info": "Příjmení 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á", "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", "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", "member_removed": "Člen odstraněn",
"my_availability": "Moje dostupnost", "my_availability": "Moje dostupnost",
@@ -2039,12 +2065,41 @@
"team_no_event_types": "Tento tým nemá žádné typy událostí", "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í", "seat_options_doesnt_multiple_durations": "Volba místa nepodporuje více dob trvání",
"include_calendar_event": "Zahrnout událost kalendáře", "include_calendar_event": "Zahrnout událost kalendáře",
"oAuth": "OAuth",
"recently_added": "Nedávno přidáno", "recently_added": "Nedávno přidáno",
"no_members_found": "Nenalezeni žádní členové", "no_members_found": "Nenalezeni žádní členové",
"event_setup_length_error": "Nastavení události: Doba trvání musí být alespoň 1 minuta.", "event_setup_length_error": "Nastavení události: Doba trvání musí být alespoň 1 minuta.",
"availability_schedules": "Plány dostupnosti", "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_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.", "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}}", "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "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", "next_step": "Spring trin over",
"prev_step": "Forrige trin", "prev_step": "Forrige trin",
"install": "Installér", "install": "Installér",
"install_paid_app": "Abonnér",
"installed": "Installeret", "installed": "Installeret",
"active_install_one": "{{count}} aktiv installation", "active_install_one": "{{count}} aktiv installation",
"active_install_other": "{{count}} aktive installationer", "active_install_other": "{{count}} aktive installationer",
@@ -268,6 +268,7 @@
"set_availability": "Verfügbarkeit festlegen", "set_availability": "Verfügbarkeit festlegen",
"availability_settings": "Verfügbarkeitseinstellungen", "availability_settings": "Verfügbarkeitseinstellungen",
"continue_without_calendar": "Ohne Kalender fortfahren", "continue_without_calendar": "Ohne Kalender fortfahren",
"continue_with": "Mit {{appName}} fortfahren",
"connect_your_calendar": "Kalender verbinden", "connect_your_calendar": "Kalender verbinden",
"connect_your_video_app": "Verbinden Sie Ihre Video-Apps", "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.", "connect_your_video_app_instructions": "Verbinden Sie Ihre Video-Apps, um sie für Ihre Termintypen zu verwenden.",
@@ -289,6 +290,7 @@
"where": "Wo", "where": "Wo",
"add_to_calendar": "Zum Kalender hinzufügen", "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_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", "add_another_calendar": "Einen weiteren Kalender hinzufügen",
"other": "Sonstige", "other": "Sonstige",
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}", "email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
@@ -423,6 +425,7 @@
"booking_created": "Termin erstellt", "booking_created": "Termin erstellt",
"booking_rejected": "Termin abgelehnt", "booking_rejected": "Termin abgelehnt",
"booking_requested": "Buchung angefragt", "booking_requested": "Buchung angefragt",
"booking_payment_initiated": "Buchungszahlung eingeleitet",
"meeting_ended": "Meeting beendet", "meeting_ended": "Meeting beendet",
"form_submitted": "Formular gesendet", "form_submitted": "Formular gesendet",
"booking_paid": "Buchung bezahlt", "booking_paid": "Buchung bezahlt",
@@ -457,6 +460,7 @@
"no_event_types_have_been_setup": "Dieser Benutzer hat noch keine Termintypen eingerichtet.", "no_event_types_have_been_setup": "Dieser Benutzer hat noch keine Termintypen eingerichtet.",
"edit_logo": "Logo bearbeiten", "edit_logo": "Logo bearbeiten",
"upload_a_logo": "Logo hochladen", "upload_a_logo": "Logo hochladen",
"upload_logo": "Logo hochladen",
"remove_logo": "Logos entfernen", "remove_logo": "Logos entfernen",
"enable": "Aktivieren", "enable": "Aktivieren",
"code": "Code", "code": "Code",
@@ -569,6 +573,7 @@
"your_team_name": "Ihr Teamname", "your_team_name": "Ihr Teamname",
"team_updated_successfully": "Team erfolgreich aktualisiert", "team_updated_successfully": "Team erfolgreich aktualisiert",
"your_team_updated_successfully": "Ihr Team wurde erfolgreich aktualisiert.", "your_team_updated_successfully": "Ihr Team wurde erfolgreich aktualisiert.",
"your_org_updated_successfully": "Ihre Org wurde erfolgreich aktualisiert.",
"about": "Beschreibung", "about": "Beschreibung",
"team_description": "Ein paar Sätze über Ihr Team auf der öffentlichen Teamseite.", "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.", "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": "Gäste deaktivieren",
"disable_guests_description": "Das Hinzufügen zusätzlicher Gäste deaktivieren.", "disable_guests_description": "Das Hinzufügen zusätzlicher Gäste deaktivieren.",
"private_link": "Privaten Link generieren", "private_link": "Privaten Link generieren",
"enable_private_url": "Private URL aktivieren",
"private_link_label": "Privater Link", "private_link_label": "Privater Link",
"private_link_hint": "Ihr privater Link wird nach jeder Nutzung neu generiert", "private_link_hint": "Ihr privater Link wird nach jeder Nutzung neu generiert",
"copy_private_link": "Privaten Link kopieren", "copy_private_link": "Privaten Link kopieren",
@@ -843,6 +849,7 @@
"next_step": "Schritt überspringen", "next_step": "Schritt überspringen",
"prev_step": "Vorheriger Schritt", "prev_step": "Vorheriger Schritt",
"install": "Installieren", "install": "Installieren",
"install_paid_app": "Abonnieren",
"installed": "Installiert", "installed": "Installiert",
"active_install_one": "{{count}} aktive Installation", "active_install_one": "{{count}} aktive Installation",
"active_install_other": "{{count}} aktive Installationen", "active_install_other": "{{count}} aktive Installationen",
@@ -1091,6 +1098,7 @@
"developer_documentation": "Entwickler-Dokumentation", "developer_documentation": "Entwickler-Dokumentation",
"get_in_touch": "Kontakt aufnehmen", "get_in_touch": "Kontakt aufnehmen",
"contact_support": "Support kontaktieren", "contact_support": "Support kontaktieren",
"community_support": "Community Support",
"feedback": "Feedback", "feedback": "Feedback",
"submitted_feedback": "Vielen Dank für Ihr Feedback!", "submitted_feedback": "Vielen Dank für Ihr Feedback!",
"feedback_error": "Fehler beim Senden des Feedbacks", "feedback_error": "Fehler beim Senden des Feedbacks",
@@ -1216,6 +1224,7 @@
"organizer_name_variable": "Organisator Name", "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.", "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_number": "Ungültige Telefonnummer",
"invalid_url_error_message": "Ungültige URL für {{label}}. Beispiel-URL: {{sampleUrl}}",
"navigate": "Navigieren", "navigate": "Navigieren",
"open": "Öffnen", "open": "Öffnen",
"close": "Schließen", "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.", "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", "check_for_conflicts": "Auf Konflikte prüfen",
"view_recordings": "Aufnahmen anzeigen", "view_recordings": "Aufnahmen anzeigen",
"check_for_recordings": "Nach Aufnahmen suchen",
"adding_events_to": "Termine hinzufügen zu", "adding_events_to": "Termine hinzufügen zu",
"follow_system_preferences": "Systemeinstellungen folgen", "follow_system_preferences": "Systemeinstellungen folgen",
"custom_brand_colors": "Eigene Marken-Farben", "custom_brand_colors": "Eigene Marken-Farben",
@@ -1599,6 +1609,7 @@
"options": "Optionen", "options": "Optionen",
"enter_option": "Option {{index}} eingeben", "enter_option": "Option {{index}} eingeben",
"add_an_option": "Option hinzufügen", "add_an_option": "Option hinzufügen",
"location_already_exists": "Dieser Standort existiert bereits. Bitte wählen Sie einen neuen Standort",
"radio": "Radio", "radio": "Radio",
"google_meet_warning": "Um Google Meet nutzen zu können, müssen Sie Ihren Zielkalender zu einem Google Calendar ändern", "google_meet_warning": "Um Google Meet nutzen zu können, müssen Sie Ihren Zielkalender zu einem Google Calendar ändern",
"individual": "Person", "individual": "Person",
@@ -1618,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "An ausgewählten Daten als „nicht verfügbar“ markieren", "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_add_btn": "Überschreibung hinzufügen",
"date_overrides_update_btn": "Überschreiben aktualisieren", "date_overrides_update_btn": "Überschreiben aktualisieren",
"date_successfully_added": "Datumsüberbrückung erfolgreich hinzugefügt",
"event_type_duplicate_copy_text": "{{slug}}-Kopie", "event_type_duplicate_copy_text": "{{slug}}-Kopie",
"set_as_default": "Als Standard festlegen", "set_as_default": "Als Standard festlegen",
"hide_eventtype_details": "Ereignistyp-Einzelheiten ausblenden", "hide_eventtype_details": "Ereignistyp-Einzelheiten ausblenden",
@@ -2062,6 +2074,7 @@
"access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen", "access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen",
"select_account_team": "Konto oder Team auswählen", "select_account_team": "Konto oder Team auswählen",
"allow_client_to": "Dies wird {{clientName}} erlauben", "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_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", "see_primary_email_address": "Ihre primäre E-Mail-Adresse einzusehen",
"connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden", "connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden",
@@ -2069,12 +2082,24 @@
"access_availability": "Lesen, Bearbeiten, Löschen Ihrer Verfügbarkeiten", "access_availability": "Lesen, Bearbeiten, Löschen Ihrer Verfügbarkeiten",
"access_bookings": "Lesen, Bearbeiten, Löschen Ihrer Termine", "access_bookings": "Lesen, Bearbeiten, Löschen Ihrer Termine",
"allow_client_to_do": "{{clientName}} zulassen, dies zu tun?", "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", "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_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.", "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}}", "edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
"resend_invitation": "Einladung erneut senden", "resend_invitation": "Einladung erneut senden",
"invitation_resent": "Die Einladung wurde erneut gesendet.", "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", "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "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_one": "{{count}} ημέρα",
"day_other": "{{count}} ημέρες", "day_other": "{{count}} ημέρες",
"second_one": "{{count}} δευτερόλεπτο", "second_one": "{{count}} δευτερόλεπτο",
@@ -7,8 +8,13 @@
"accept_invitation": "Αποδοχή Πρόσκλησης", "accept_invitation": "Αποδοχή Πρόσκλησης",
"have_any_questions": "Έχετε ερωτήσεις? Είμαστε εδώ για να βοηθήσουμε.", "have_any_questions": "Έχετε ερωτήσεις? Είμαστε εδώ για να βοηθήσουμε.",
"reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης", "reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης",
"verify_email_email_header": "Επιβεβαιώστε τη διεύθυνση ηλεκτρονικού ταχυδρομείου σας",
"verify_email_email_button": "Επιβεβαίωση ηλεκτρονικού ταχυδρομείου",
"copy_somewhere_safe": "Αποθηκεύστε αυτό το κλειδί API κάπου ασφαλές. Δεν θα μπορείτε να το δείτε ξανά.",
"event_declined_subject": "Απορρίφθηκε: {{title}} στις {{date}}", "event_declined_subject": "Απορρίφθηκε: {{title}} στις {{date}}",
"organizer": "Διοργανωτής",
"need_to_reschedule_or_cancel": "Χρειάζεται να επαναπρογραμματίσετε ή να ακυρώσετε;", "need_to_reschedule_or_cancel": "Χρειάζεται να επαναπρογραμματίσετε ή να ακυρώσετε;",
"no_options_available": "Καμία διαθέσιμη επιλογή",
"cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)", "cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)",
"rejection_reason": "Λόγος απόρριψης", "rejection_reason": "Λόγος απόρριψης",
"rejection_reason_title": "Απόρριψη του αιτήματος κράτησης;", "rejection_reason_title": "Απόρριψη του αιτήματος κράτησης;",
@@ -21,6 +27,8 @@
"meeting_awaiting_payment": "Η πληρωμή της συνάντησής σας εκκρεμεί", "meeting_awaiting_payment": "Η πληρωμή της συνάντησής σας εκκρεμεί",
"help": "Βοήθεια", "help": "Βοήθεια",
"price": "Τιμή", "price": "Τιμή",
"paid": "Πληρώθηκε",
"refunded": "Πιστώθηκε",
"payment": "Πληρωμή", "payment": "Πληρωμή",
"missing_card_fields": "Λείπουν τα πεδία της κάρτας", "missing_card_fields": "Λείπουν τα πεδία της κάρτας",
"pay_now": "Πληρωμή τώρα", "pay_now": "Πληρωμή τώρα",
@@ -56,6 +56,17 @@
"a_refund_failed": "A refund failed", "a_refund_failed": "A refund failed",
"awaiting_payment_subject": "Awaiting Payment: {{title}} on {{date}}", "awaiting_payment_subject": "Awaiting Payment: {{title}} on {{date}}",
"meeting_awaiting_payment": "Your meeting is awaiting payment", "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", "help": "Help",
"price": "Price", "price": "Price",
"paid": "Paid", "paid": "Paid",
@@ -606,6 +617,7 @@
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.", "hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
"danger_zone": "Danger zone", "danger_zone": "Danger zone",
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.", "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", "back": "Back",
"cancel": "Cancel", "cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining", "cancel_all_remaining": "Cancel all remaining",
@@ -849,6 +861,8 @@
"next_step": "Skip step", "next_step": "Skip step",
"prev_step": "Prev step", "prev_step": "Prev step",
"install": "Install", "install": "Install",
"install_paid_app": "Subscribe",
"start_paid_trial": "Start free Trial",
"installed": "Installed", "installed": "Installed",
"active_install_one": "{{count}} active install", "active_install_one": "{{count}} active install",
"active_install_other": "{{count}} active installs", "active_install_other": "{{count}} active installs",
@@ -1097,6 +1111,8 @@
"developer_documentation": "Developer Documentation", "developer_documentation": "Developer Documentation",
"get_in_touch": "Get in touch", "get_in_touch": "Get in touch",
"contact_support": "Contact Support", "contact_support": "Contact Support",
"premium_support": "Premium Support",
"community_support": "Community Support",
"feedback": "Feedback", "feedback": "Feedback",
"submitted_feedback": "Thank you for your feedback!", "submitted_feedback": "Thank you for your feedback!",
"feedback_error": "Error sending feedback", "feedback_error": "Error sending feedback",
@@ -1361,6 +1377,7 @@
"event_name_info": "The event type name", "event_name_info": "The event type name",
"event_date_info": "The event date", "event_date_info": "The event date",
"event_time_info": "The event start time", "event_time_info": "The event start time",
"event_type_not_found": "EventType not Found",
"location_info": "The location of the event", "location_info": "The location of the event",
"additional_notes_info": "The additional notes of booking", "additional_notes_info": "The additional notes of booking",
"attendee_name_info": "The person booking's name", "attendee_name_info": "The person booking's name",
@@ -1401,6 +1418,7 @@
"slot_length": "Slot length", "slot_length": "Slot length",
"booking_appearance": "Booking Appearance", "booking_appearance": "Booking Appearance",
"appearance_team_description": "Manage settings for your team's 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 ", "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}}'", "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}}", "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":"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.", "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.", "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", "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.", "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }
Loaded 100 of 363 files, more files were not shown because too many files have changed in this diff. Show more