Compare commits

...
81 Commits
Author SHA1 Message Date
Alex van Andel 22c51ba483 v3.0.16
release.yml / release (push) Canceled after 0s
2023-07-13 14:40:20 +02:00
Alimurtuza b6eaafa39f fix: "change username" dialog design (#10095) 2023-07-13 14:28:16 +02:00
98707e66e1 fix: Refresh session (#10019)
* Refresh session 🔄

* Update apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx

Co-authored-by: Omar López <zomars@me.com>

* Update apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx

Co-authored-by: Omar López <zomars@me.com>

* Update packages/features/auth/lib/next-auth-options.ts

Co-authored-by: Omar López <zomars@me.com>

---------

Co-authored-by: Ansari <thameem.ns@zohocorp.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2023-07-13 14:21:28 +02:00
Crowdin Bot 44308cbfa0 New Crowdin translations by Github Action 2023-07-13 12:12:23 +00:00
Purushottam Khedre 352a9e2fec fix: incorrect error messsage for invalid login credentials (#10117)
* fir ErrorCode enum

* update incorrect credentails message
2023-07-13 12:09:19 +00:00
Crowdin Bot c1a9f4b656 New Crowdin translations by Github Action 2023-07-13 12:02:12 +00:00
Leo GiovanettiandShivam Kalra 32c6ca0ad5 fix: Creating a default availability for org owner [CAL-2142] (#10108)
* Creating a default availability for org owner

* Full width empty state

---------

Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-07-13 13:59:12 +02:00
e79b4168f2 fix: Improved recurring event booking speed (#9600)
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-07-13 17:28:20 +05:30
Hariom Balhara 54833b65ff fix: Hotfix/consider new conferencing and old video app categories (#10134)
* Consider new conferencing and old video app categories

* Add comment
2023-07-13 11:57:49 +00:00
Rama Krishna Reddyandrkreddy99 26afc077a7 disable guests field when offer seats is enabled (#10132)
Co-authored-by: rkreddy99 <rreddy@e2clouds.com>
2023-07-13 13:57:30 +02:00
Jaideep Guntupalli 0093b6faa5 feat: Added e2e test paid booking should be able to be rescheduled (#10069)
* feat: Added e2e test paid booking should be able to be rescheduled

* refactor: abstracted and simplied the logic

* fix: stripe iframe isnt the first frame

* fix: import expect
2023-07-13 02:47:06 +02:00
Alex van Andel ef49fc4ce1 fix: Remove/Update behaviour of date-overrides
## What does this PR do?

This is not the cleanest approach, but the existing mechanism didn't work in a few situations because of the date sort. The update caused a re-render that caused an invalid unsortedFieldArrayMap.

Reproduction/how to test: 

**Updating bug was hit when you changed the date and it went before another date (re-ordering)**; which then modified the wrong date override. This PR replaces the entire array which retains the correct sort order by brute force.

**Delete bug was encountered if the sorted order was not the same as the filtered order, which depends on the order you created the date overrides in**. This PR implements an original order before the sort causing the right item to be removed.

Fixes #8043
2023-07-12 17:45:30 -07:00
Alex van Andel 605f7d35ec fix: Ensure rendering as UTC to prevent browser tz from messing up times
## What does this PR do?

Fixes all date shifts that are due browser time, by ensuring everything is UTC so it's not transformed to different times.

The times weren't actually shifting, dates were, but after casting these to UTC properly the times were also shifting; so that was a bug after a bugfix, time wise it was only a problem to determine all day events (which didn't completely work)

### Yes, but how do I test this?

Before:
Create a all day date override in +05:30
Shift your local system time to -04:00
Note that the day is different

After - do the same:
The day is the same!

Fixes #8365
Fixes #6978
Fixes #6482
2023-07-12 17:32:11 -07:00
Omar López 9b37d65503 fix: team invite links (#10110) 2023-07-12 14:24:47 -07:00
alannnc a1f9012501 fix: hasTeamPlan with accepted prop (#10109) 2023-07-12 16:40:41 -04:00
Ritik KumarandLeo Giovanetti 008016902e fix: incomplete vercel api error message on org creation (#9875)
* fix incomplete vercel api error message on org creation

* remove specified type from vercelCreateDomain

* have common error message for org domain registration

---------

Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-07-12 20:29:13 +00:00
Crowdin Bot 9f33d85903 New Crowdin translations by Github Action 2023-07-12 20:22:52 +00:00
alannnc 496b03e92f fix: onboard-pricing-options-self-host (#10105) 2023-07-12 20:20:03 +00:00
Keith Williams 358da42a9b fix: yaml syntax for new contributors action (#10094) 2023-07-12 19:17:40 +00:00
Carina WollendorferandCarinaWolli 8812580f72 fix: slot reservation uses wrong event length (#10103)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-07-12 12:06:15 -07:00
Alex van Andel 3009f17cb0 fix: Use user locale for sending password resets (#10104) 2023-07-12 12:04:07 -07:00
Crowdin Bot 3328cb2226 New Crowdin translations by Github Action 2023-07-12 17:30:09 +00:00
Alex van Andel 7b57b4bcda fix: Password reset tests & better expiry checking (#10102) 2023-07-12 10:27:41 -07:00
Peer Richelsen 6d413f5721 fix: ternary operator with lenght causing 0 (#10090) 2023-07-12 12:17:30 +00:00
Crowdin Bot c9fc88ddc7 New Crowdin translations by Github Action 2023-07-12 10:17:03 +00:00
6d284ae15c fix: Allow editing guests when rescheduling or editing a booking (#9878)
* Allow editing guests when rescheduling or editing

* lint fix

* modify reschedule logic that assigned originalRescheduledBooking.attendees to the "rescheduled" booking to ensure that guests added or removed are used for the "rescheduled" booking

---------

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-07-12 15:44:11 +05:30
Peer Richelsen c6791a8d3c fix: unnecessary whitespace in profile (#10071) 2023-07-11 21:26:28 +00:00
Leo Giovanetti aa1370b9c7 Enabling console for staging (#10072) 2023-07-11 21:21:30 +00:00
greatmaxy db27c7e4bc fix: Empty spaces in text field do not trigger inline field error (#10038) 2023-07-11 16:44:10 -04:00
Crowdin Bot 325c6ddc86 New Crowdin translations by Github Action 2023-07-11 20:28:56 +00:00
Udit TakkarandShivam Kalra 9c7428571d feat: added organizer daily video recording email (#9667)
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-07-11 16:26:30 -04:00
Crowdin Bot 9391bb5b64 New Crowdin translations by Github Action
release.yml / release (push) Canceled after 0s
2023-07-11 15:51:22 +00:00
d58924ecad feat: New workflow action to send Whatsapp message (#8818)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-07-11 15:48:44 +00:00
Hariom Balhara fdef15712a fix: Org booking pages link fixes for Routing Forms and Event type (#10052)
* Fix links

* Fix team event for org URL

* Fix avatar and user link
2023-07-11 17:46:13 +02:00
Joe Au-Yeung a5b5382306 fix: seats regression [CAL-2041]
## 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. -->

- Passes the proper seats data in the new booker component between states and to the backend

Fixes #9779
Fixes #9749
Fixes #7967 
Fixes #9942 

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

## Type of change

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

- 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 -->

**As the organizer** 
- Create a seated event type
- Book at least 2 seats
- Reschedule the booking
    - All attendees should be moved to the new booking
- Cancel the booking
    - The event should be cancelled for all attendees

**As an attendee**
- [x] Book a seated event
- [x] Reschedule that booking to an empty slot
    - [x] The attendee should be moved to that new slot
- [x] Reschedule onto a booking with occupied seats
    - [x] The attendees should be merged
- [x] On that slot reschedule all attendees to a new slot
    - [x] The former booking should be deleted
- [x] As the attendee cancel the booking
    - [x] Only that attendee should be removed 

## Mandatory Tasks

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

## Checklist

<!-- Please remove all the irrelevant bullets to your PR -->
2023-07-11 20:41:08 +05:30
Hariom Balhara c2fceb2e0f fix: avatars not loading for org events (#10062) 2023-07-11 14:23:07 +00:00
Crowdin Bot a73065ba5d New Crowdin translations by Github Action 2023-07-11 14:22:36 +00:00
Alex van Andel 7b0f677bdb Revert "fix: TS error in main (#10063)"
This reverts commit da80a2bae8.
2023-07-11 16:19:59 +02:00
Peer Richelsen 4166a0ab02 Update package.json 2023-07-11 15:47:46 +02:00
Hariom BalharaandKeith Williams da80a2bae8 fix: TS error in main (#10063)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-07-11 15:32:52 +02:00
Leo GiovanettiandPeer Richelsen c6fd91d181 fix: 404 links issue (#9928)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-07-11 13:05:41 +00:00
f62e6d9586 feat: enable crowdin 2-way sync (#9353)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-07-11 13:01:24 +00:00
Crowdin Bot b03c6d5040 New Crowdin translations by Github Action 2023-07-11 12:58:36 +00:00
Jeroen ReumkensandPeer Richelsen 172d77dcc3 feat: Added back to today button for new booker week and column views (#9684)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-07-11 12:57:48 +00:00
Syed Ali Shahbaz 1a905aa330 Add team example to POST (#10041) 2023-07-11 13:40:41 +02:00
Alex van Andel a8da4b87f0 Meticulous experiment & audit (#10050) 2023-07-11 13:37:59 +02:00
Crowdin Bot eb679a8e7d New Crowdin translations by Github Action 2023-07-11 11:32:50 +00:00
b364a85ed8 chore: removed old booker and make new booker as a default (#10053)
* removed old booker and make new booker as a default

* fixes merge conflict

* fixed tests

* fixed tests for old-booker

* fixed typo in @calcom/lib/defaultEvents.ts

---------

Co-authored-by: René Müller <rene.mueller@clicksports.de>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-07-11 13:31:55 +02:00
Omar López a2e0d44fb8 fix: prevents warnings on branding call when unauthed (#10051) 2023-07-11 12:06:44 +02:00
Mehul a9ee5d6f7f fixes: about section unnecessary scrollbar (#10043) 2023-07-11 11:26:43 +02:00
sean-brydonandLeo Giovanetti 6d35750784 feat: Auto accept on org verify (#10037)
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-07-11 10:30:45 +02:00
Anik Dhabal BabuandLeo Giovanetti 4f423ba692 fix: Reject Organization button is not working (#10010)
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-07-11 10:27:53 +02:00
Pradumn Kumar d097c1fada fix: fixes issue with webhook not being triggered for managed events (#10025) 2023-07-11 13:16:24 +05:30
Crowdin Bot fc049e12b3 New Crowdin translations by Github Action 2023-07-11 07:42:07 +00:00
Joe Au-YeungandLeo Giovanetti 267422a1f4 fix: Enable Salesforce & CRMs for teams & orgs [CAL-2116] (#9973)
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-07-11 13:11:21 +05:30
Crowdin Bot 6e15de31aa New Crowdin translations by Github Action 2023-07-10 22:33:08 +00:00
Carina Wollendorfer 2f6b5ced04 fix: better slot starting times
## What does this PR do?

Currently, we start the first slot always at the nearest 15 minutes. This is not ideal as for some duration other slot starting time make more sense. So with this PR the starting times are defined as follow: 

- Frequency is exact hours (60, 120, 180, ...), slot start time is a full hour
- Frequency is half hours (30, 90, ...), slot start time is half or full hours (8:00, 8:30, ...)
- Same with 20-minute events (20, 40, ...) and 10-minute events 
- Everything else will start at the nearest 15 min slot

It also fixes that slot times are shifted when there is a busy slot with a different duration. Here is a before and after of a 30-min event with a 5-minute busy slot at 1:00 pm

Before: 
![Screenshot 2023-07-07 at 13 31 45](https://github.com/calcom/cal.com/assets/30310907/b92d4ff4-49f1-48f4-a973-99266f61d919)

After
![Screenshot 2023-07-07 at 13 34 01](https://github.com/calcom/cal.com/assets/30310907/042c7ef7-8c2a-4cd9-b663-183bc07b5864)


#### 30 Minute events, availability starting at 7:15

Before: 
![Screenshot 2023-07-06 at 12 40 00](https://github.com/calcom/cal.com/assets/30310907/752ed978-83cf-4ee9-a38d-b5795df6daec)

After:
![Screenshot 2023-07-06 at 12 40 42](https://github.com/calcom/cal.com/assets/30310907/5d51ec15-5be8-4f3b-b374-46dad35216b8)

## Type of change
- Bug fix (non-breaking change which fixes an issue)

## How should this be tested?

- Check if slot times are shown as described
- Test with different intervals/durations
- Test with busy times
- Test with different availabilities 

## Mandatory Tasks

- [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
2023-07-10 15:32:26 -07:00
Omar López cf57be2aef fix: Calcom UI deployments (#10047) 2023-07-10 19:52:15 +00:00
Hariom Balhara c2996cd2f8 fix: 404 booking pages when org support is enabled locally
## What does this PR do?
Fix 404 booking pages when org support is enabled locally

## Type of change

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

- 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 -->

-  Add localhost:3000 to ALLOWED_HOSTNAMES which is the default recommendation for enabling org support locally.
<img width="1035" alt="Screenshot 2023-07-10 at 11 04 35 AM" src="https://github.com/calcom/cal.com/assets/1780212/7272437b-afae-4073-905b-eafc39f9fafc">
- Simply restart the server and visit http://localhost:3000/pro. It will give 404. After this change it would work

## Mandatory Tasks

- [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
2023-07-10 12:19:33 -07:00
Crowdin Bot 79944dfed1 New Crowdin translations by Github Action 2023-07-10 19:14:15 +00:00
sean-brydonandzomars e83647c652 feat: Add password checks for password-reset (#10042)
Co-authored-by: zomars <zomars@me.com>
2023-07-10 12:13:20 -07:00
Crowdin Bot 556bf346f0 New Crowdin translations by Github Action 2023-07-10 14:45:23 +00:00
Leo Giovanetti b98f2045d0 chore: Teams empty screen (#9997) 2023-07-10 10:44:22 -04:00
Eric DantasandPeer Richelsen a13c050e9d chore: Improve info that should be filled out for bugs and features (#9978)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-07-10 16:17:52 +02:00
Ujwal Kumar e088196c8d fix: vertical-scrollbar-issue-when-organization-table-contains-no-records (#10008) 2023-07-10 16:16:20 +02:00
Leo Giovanetti 9456ffbb48 chore: Credits with commit hash (#9999) 2023-07-10 16:10:30 +02:00
Sai Krishna 6d19cc22a5 fix: cancel button not working on payment page (#10006) 2023-07-10 15:58:38 +02:00
Akshay c69c9d6314 Fix #10021 Changes save button color from secondary to primary. (#10022) 2023-07-10 15:30:00 +02:00
e0ef30eb52 fix: /routing-form/single - Don't clear edits when moving from one tab to the other (#8125)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Nafees Nazik <84864519+G3root@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-07-10 09:24:04 -04:00
Vinoth Kumar VandVinoth Kumar V 045828d4ab fix: location icon (#9994)
Co-authored-by: Vinoth Kumar V <vinoth_kumar_v@Vinoths-MacBook-Pro.local>
2023-07-10 12:57:34 +02:00
Hariom Balhara 354d57f393 Add border in embed always (#10034) 2023-07-10 09:56:41 +00:00
650d0082a3 feat: from org team invite (#9569)
* Initial commit

* Adding feature flag

* Desktop first banner, mobile pending

* Removing dead code and img

* AppInstallButtonBase

* WIP

* Adds Email verification template+translations for organizations (#9202)

* feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209)

* Change scopedMembers to orgMembers

* Change to orgUsers

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Session logic to show org label

* Step 2 done, avatar not working

* List orgs and list teams specific if orgs exist

* Conditionally show org - fix settings layout - add labels for all pages

* Profile Page + update

* Org specific team creation

* appearance page

* Ensure members cant of org cant update settings in UI

* Fix update handler imports

* hide billing on sub teams

* Update profile slug page

* Letting duplicate slugs for teams to support orgs

* Add slug coliisions for org

* Covering null on unique clauses

* Covering null on unique clauses

* Extract to utils

* Update settings to use subdomain path in team url , team + org

* Supporting having the orgId in the session cookie

* Onboarding admins step

* Last step to create teams

* Update handler comments

* Upgrade ORG banner - disabled team banner for child teams

* Handle publishing ORGS

* Fix licenese issue

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

* Split into function calls to make this file more explisit

* Update parents stripe sub not teamID

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Create org membership also - billing portal page

* A11ly

* Hide create team if no valid permisisons

* Get Org members router

* Handle updating subscription if orgId

* Fix double upgrade banner

* Update constants

* Feedback

* Copy change

* Making an org avatar (temp)

* Add slug colission detection for user and team name

* Fix Import

* Remove update password func

* Fix module import over relative

* feat: organization event type filter (#9253)

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

* Missing changes to support orgs schema changes

* Fix import again

* Throw no team found before auth error

* Check if invited found user is already in differnt org

* feat: organization settings general page

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

* feat: add members page

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

* chore: remove

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

* fix: use invalidate

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

* Move to for of loop to throw errors in usenamelist

* fix: delete mutation

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

* fix: remove organization id

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

* chore

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

* Remove app install button sa its in 9337

* Remove i18n key not being used

* feat: Onboarding process to create an organization (#9184)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Making sure we check requestedSlug now

---------

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

* feat: [CAL-1816] Organization subdomain support (#9345)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* Covering users and subteams, excluding non-org users

* Unpublished teams shows correctly

* Create subdomain in Vercel

* feedback

* Renaming Vercel env vars

* Vercel domain check before creation

* Supporting cal-staging.com

* Change to have vercel detect it

* vercel domain check data message error

* Remove check domain

* Making sure we check requestedSlug now

* Feedback and unneeded code

* Reverting unneeded changes

* Unneeded changes

---------

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

* Vercel subdomain creation in PROD only

* Fix router

* fix: use zod schema

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

* feat: organization settings general and members page (#9266)

* feat: organization settings general page

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

* feat: add members page

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

* chore: remove

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

* fix: use invalidate

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

* fix: delete mutation

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

* fix: remove organization id

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

* chore

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

* fix: use zod schema

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

---------

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

* Type fixes

* Use org Stripe product when upgrading

* Removed unused code

* UI completed

* Reverting changes

* Update UsernameTextfield.tsx

* More reverts

* Update next-auth-options.ts

* Update common.json

* Type fixes

* Include invite token for orgs

* Update org schema

* Make token settings optional as it isnt used in orgs yet

* Reverts

* Add correct array logic and move to controlled component

* Fix toggle group default | Update toggle group bg

* Darkmode toggle group

* Distinct user

* Hide modal if no org members

* Extract toggle logic

* Update packages/features/ee/organizations/components/TeamInviteFromOrg.tsx

* remove yarn.lock from commit

* Fix types

* Add getMember router back

* As a query lol

* Fix types

* Fix accepted param defaulting to true as we want both

* Fix list not pulling back people who have not joined the org yet

* Fix tests to handle invite a existing org user to a team

* Fix test

* Fix an error sometimes when existing org user is invited

* Updates radix & fixes bug

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-07-10 15:16:51 +05:30
Crowdin Bot 7c4b7209c2 New Crowdin translations by Github Action 2023-07-07 23:58:52 +00:00
sydwardrae 5736821466 fixed typo string to strong (#10001) 2023-07-07 16:58:01 -07:00
Joe Au-Yeungandzomars 75f76c130a fix: Orgs/create child teams CAL-1986 (#9631)
* Re-applied valid changes

* Update packages/trpc/server/middlewares/sessionMiddleware.ts

* Type fix

* Type fix

---------

Co-authored-by: zomars <zomars@me.com>
2023-07-07 16:48:51 +00:00
Anik Dhabal Babu 936432a730 fix: Back to Bookings button only shows for organizer (#9983) 2023-07-07 11:50:44 -04:00
Hariom Balharaandalannnc 4fda760e5e fix: Organizations/ Fixes related to Routing Form, Booking Pages and Embeds (#9860)
* Fix Team Events not working

* Get embed for org events working

* Fix rewrites

* Update next-config.test.ts

Spacing changes

* Update next-config.test.ts

Scan the file through prettier and update

---------

Co-authored-by: alannnc <alannnc@gmail.com>
2023-07-07 21:20:15 +05:30
Ritik Kumar c467fd41ac fix: hide invite member button from list tile for team members (#9963) 2023-07-07 09:34:57 -04:00
sean-brydon 0969d5960a feat: temp admin user management
## What does this PR do?
Loom: https://www.loom.com/share/361ca6ca9b5748079cba59ed2f348d90

Not all items on edit from update the user. This is very temp page to help Milos

How to test:
Login as admin user
settings > admin > users
search - see filtered users
scroll to bottom of list - notice we refetch before you hit the bottom so you dont notice items loading
delete user -> user is deleted and removed from list (radix bug in main causes dialog to freeze refresh to make this go away)
edit user -> Edit users email/username/identityprovider. Not all items on this update work just yet. These will be picked up when we come to implement the proper user table.
2023-07-07 18:25:21 +05:30
Alex van Andel cbe1907118 Only enable when defaultScheduleId is truthy (#9968) 2023-07-06 17:06:37 -07:00
Alex van Andel afd41475c9 fix: update should be based on email only (#9977) 2023-07-06 23:55:00 +00:00
251 changed files with 4232 additions and 5413 deletions
+1 -6
View File
@@ -121,6 +121,7 @@ TWILIO_SID=
TWILIO_TOKEN=
TWILIO_MESSAGING_SID=
TWILIO_PHONE_NUMBER=
TWILIO_WHATSAPP_PHONE_NUMBER=
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID=
@@ -214,9 +215,3 @@ PROJECT_ID_VERCEL=
TEAM_ID_VERCEL=
# Get it from: https://vercel.com/account/tokens
AUTH_BEARER_TOKEN_VERCEL=
#Enables New booker for Embed only
NEW_BOOKER_ENABLED_FOR_EMBED=0
#Enables New booker for All but Embed requests
NEW_BOOKER_ENABLED_FOR_NON_EMBED=0
+15 -3
View File
@@ -20,8 +20,20 @@ A summary of the issue. This needs to be a clear detailed-rich summary.
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
### Actual Results
- What's happening right now that is different from what is expected
### Expected Results
- This is an ideal result that the system should get after the tests are performed
### Technical details
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
- Node.js version
- Anything else that you think could be an issue.
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
- Node.js version
- Anything else that you think could be an issue.
### Evidence
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
@@ -39,3 +39,11 @@ assignees: ""
-->
(Write your answer here.)
### Requirement/Document
<!--
Is there any type of document that could support that feature?
-->
(Share it here.)
+17 -9
View File
@@ -8,26 +8,34 @@ Fixes # (issue)
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. -->
- Bug fix (non-breaking change which fixes an issue)
- 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
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] 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 -->
<!-- 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 -->
- [ ] Test A
- [ ] Test B
- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR
## Mandatory Tasks
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
## Checklist
@@ -0,0 +1,23 @@
name: Cron - scheduleWhatsappReminders
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
- cron: "0,15,30,45 * * * *"
jobs:
cron-scheduleWhatsappReminders:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/workflows/scheduleWhatsappReminders \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail
+1
View File
@@ -22,6 +22,7 @@ jobs:
uses: crowdin/github-action@1.5.1
with:
upload_sources: true
upload_translations: true
download_translations: true
push_translations: true
commit_message: "New Crowdin translations by Github Action"
+15 -15
View File
@@ -1,7 +1,7 @@
name: "Welcome new contributors"
on:
issues:
on:
issues:
types: opened
pull_request:
types: opened
@@ -13,16 +13,16 @@ permissions:
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
needs: semantics
if: github.event.action == 'opened'
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
Feel free to join the conversation at [discord](https://go.cal.com/discord)
issue-message: |
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
runs-on: ubuntu-latest
timeout-minutes: 10
needs: semantics
if: github.event.action == 'opened'
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
Feel free to join the conversation at [discord](https://go.cal.com/discord)
issue-message: |
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
+38 -3
View File
@@ -47,7 +47,6 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
* slug:
* type: string
* description: Unique slug for the event type
* example: my-event
* hosts:
* type: array
* items:
@@ -168,9 +167,9 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
* type: string
* displayLocationPublicly:
* type: boolean
* example:
* examples:
* event-type:
* summary: An example of event type POST request
* summary: An example of an individual event type POST request
* value:
* title: Hello World
* slug: hello-world
@@ -205,6 +204,42 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
* }
* }
* }
* team-event-type:
* summary: An example of a team event type POST request
* value:
* title: "Tennis class"
* slug: "tennis-class-{{$guid}}"
* length: 60
* hidden: false
* position: 0
* teamId: 3
* eventName: null
* timeZone: null
* periodType: "UNLIMITED"
* periodStartDate: null
* periodEndDate: null
* periodDays: null
* periodCountCalendarDays: null
* requiresConfirmation: true
* recurringEvent:
* interval: 2
* count: 10
* freq: 2
* disableGuests: false
* hideCalendarNotes: false
* minimumBookingNotice: 120
* beforeEventBuffer: 0
* afterEventBuffer: 0
* schedulingType: "COLLECTIVE"
* price: 0
* currency: "usd"
* slotInterval: null
* successRedirectUrl: null
* description: null
* locations:
* - address: "London"
* type: "inPerson"
* metadata: {}
* tags:
* - event-types
* externalDocs:
+1 -1
View File
@@ -12,7 +12,7 @@
"@calcom/ui": "*",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-id": "^1.0.0",
"@radix-ui/react-popover": "^1.0.2",
+2 -6
View File
@@ -6,7 +6,7 @@ import Script from "next/script";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL, IS_CALCOM } from "@calcom/lib/constants";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import type { AppProps } from "@lib/app-providers";
@@ -53,10 +53,6 @@ function PageWrapper(props: AppProps) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page);
// Canonical: Check if the URL is from cal.com so that we set the canonical conditionally
const isCalcom =
WEBAPP_URL &&
(new URL(WEBAPP_URL).hostname.endsWith("cal.com") || new URL(WEBAPP_URL).hostname.endsWith("cal.dev"));
const path = router.asPath;
return (
@@ -70,7 +66,7 @@ function PageWrapper(props: AppProps) {
<DefaultSeo
// Set canonical to https://cal.com or self-hosted URL
canonical={
isCalcom
IS_CALCOM
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
}
@@ -1,53 +0,0 @@
import { getEventLocationType, getTranslatedLocation } from "@calcom/app-store/locations";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "@calcom/ui";
import { Link } from "@calcom/ui/components/icon";
import type { Props } from "./pages/AvailabilityPage";
const excludeNullValues = (value: unknown) => !!value;
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
const { t } = useLocale();
const renderLocations = locations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
// It's possible that the location app got uninstalled
return null;
}
if (eventLocationType.variable === "hostDefault") {
return null;
}
const translatedLocation = getTranslatedLocation(location, eventLocationType, t);
return (
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
{eventLocationType.iconUrl === "/link.svg" ? (
<Link className="text-default ml-[2px] h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
) : (
<img
src={eventLocationType.iconUrl}
className={classNames(
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
!eventLocationType.iconUrl?.startsWith("/app-store") ? "dark:invert-[.65]" : ""
)}
alt={`${eventLocationType.label} icon`}
/>
)}
<Tooltip content={translatedLocation}>
<p className="line-clamp-1">{translatedLocation}</p>
</Tooltip>
</div>
);
});
const filteredLocations = renderLocations.filter(excludeNullValues) as JSX.Element[];
return filteredLocations.length ? (
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm">
{filteredLocations}
</div>
) : null;
}
+21 -10
View File
@@ -66,6 +66,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
);
const reserveSlot = (slot: Slot) => {
// Prevent double clicking
if (reserveSlotMutation.isLoading || reserveSlotMutation.isSuccess) {
return;
}
reserveSlotMutation.mutate({
slotUtcStartDate: slot.time,
eventTypeId,
@@ -132,8 +136,20 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
let slotFull, notEnoughSeats;
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
if (slot.attendees && bookingAttendees && seatsPerTimeSlot) {
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
}
const isHalfFull =
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
const isNearlyFull =
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
const colorClass = isNearlyFull
? "text-rose-600"
: isHalfFull
? "text-yellow-500"
: "text-emerald-400";
return (
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
@@ -144,7 +160,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
className={classNames(
"text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25",
brand === "#fff" || brand === "#ffffff" ? "" : ""
)}>
)}
data-testid="time"
data-disabled="true">
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
{notEnoughSeats ? (
<p className="text-sm">{t("not_enough_seats")}</p>
@@ -165,14 +183,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
data-disabled="false">
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
{!!seatsPerTimeSlot && (
<p
className={`${
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
? "text-rose-600"
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-emerald-400"
} text-sm`}>
<p className={`${colorClass} text-sm`}>
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
{seatsPerTimeSlot}{" "}
{t("seats_available", {
@@ -1,162 +0,0 @@
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Badge } from "@calcom/ui";
import { CheckSquare, Clock } from "@calcom/ui/components/icon";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import { UserAvatars } from "@components/booking/UserAvatars";
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
import type { AvailabilityPageProps } from "../../pages/[user]/[type]";
import type { BookPageProps } from "../../pages/[user]/book";
import type { DynamicAvailabilityPageProps } from "../../pages/d/[link]/[slug]";
import type { HashLinkPageProps } from "../../pages/d/[link]/book";
import type { AvailabilityTeamPageProps } from "../../pages/team/[slug]/[type]";
import type { TeamBookingPageProps } from "../../pages/team/[slug]/book";
import { AvailableEventLocations } from "./AvailableEventLocations";
interface Props {
profile:
| AvailabilityPageProps["profile"]
| HashLinkPageProps["profile"]
| TeamBookingPageProps["profile"]
| BookPageProps["profile"]
| AvailabilityTeamPageProps["profile"]
| DynamicAvailabilityPageProps["profile"];
eventType:
| AvailabilityPageProps["eventType"]
| HashLinkPageProps["eventType"]
| TeamBookingPageProps["eventType"]
| BookPageProps["eventType"]
| AvailabilityTeamPageProps["eventType"]
| DynamicAvailabilityPageProps["eventType"];
isBookingPage?: boolean;
children: ReactNode;
isMobile?: boolean;
rescheduleUid?: string;
}
const BookingDescription: FC<Props> = (props) => {
const { profile, eventType, isBookingPage = false, children } = props;
const { date: bookingDate } = useRouterQuery("date");
const { t } = useLocale();
const { duration, setQuery: setDuration } = useRouterQuery("duration");
useEffect(() => {
if (
!duration ||
isNaN(Number(duration)) ||
(eventType.metadata?.multipleDuration &&
!eventType.metadata?.multipleDuration.includes(Number(duration)))
) {
setDuration(eventType.length);
}
}, [duration, setDuration, eventType.length, eventType.metadata?.multipleDuration]);
let requiresConfirmation = eventType?.requiresConfirmation;
let requiresConfirmationText = t("requires_confirmation");
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
if (rcThreshold) {
if (isBookingPage) {
if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
requiresConfirmation = false;
}
} else {
requiresConfirmationText = t("requires_confirmation_threshold", {
...rcThreshold,
unit: rcThreshold.unit.slice(0, -1),
});
}
}
return (
<>
<UserAvatars
profile={profile}
users={eventType.users}
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
size="sm"
truncateAfter={3}
/>
<h2 className="text-default mb-2 mt-1 break-words text-sm font-medium ">
{eventType.team?.parent?.name} {profile.name}
</h2>
<h1 className="font-cal text-emphasis mb-6 break-words text-2xl font-semibold leading-none">
{eventType.title}
</h1>
<div className=" text-default flex flex-col space-y-4 text-sm font-medium">
{eventType?.description && (
<div
className={classNames(
"scroll-bar scrollbar-track-w-20 -mx-5 flex max-h-[180px] overflow-y-scroll px-5 ",
isBookingPage && "text-default text-sm font-medium"
)}>
{/* TODO: Fix colors when token is introdcued to DS */}
<div className="max-w-full flex-shrink break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
<EventTypeDescriptionSafeHTML eventType={eventType} />
</div>
</div>
)}
{requiresConfirmation && (
<div className={classNames("items-top flex", isBookingPage && "text-default text-sm font-medium")}>
<div>
<CheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
</div>
{requiresConfirmationText}
</div>
)}
<AvailableEventLocations
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
/>
<div
className={classNames(
"flex flex-nowrap text-sm font-medium",
isBookingPage && "text-default",
!eventType.metadata?.multipleDuration && "items-center"
)}>
<Clock
className={classNames(
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
isBookingPage && "mt-[2px]"
)}
/>
{eventType.metadata?.multipleDuration !== undefined ? (
!isBookingPage ? (
<ul className="-mt-1 flex flex-wrap gap-1">
{eventType.metadata.multipleDuration.map((dur, idx) => (
<li key={idx}>
<Badge
variant="gray"
className={classNames(
duration === dur.toString()
? "bg-emphasis border-emphasis text-emphasis "
: "bg-subtle text-default border-transparent ",
"cursor-pointer border"
)}
onClick={() => {
setDuration(dur);
}}>
{dur} {t("minute_timeUnit")}
</Badge>
</li>
))}
</ul>
) : (
`${duration} ${t("minutes")}`
)
) : (
`${eventType.length} ${t("minutes")}`
)}
</div>
{children}
</div>
</>
);
};
export default BookingDescription;
@@ -1,298 +0,0 @@
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { z } from "zod";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import dayjs from "@calcom/dayjs";
import {
useEmbedNonStylesConfig,
useEmbedStyles,
useEmbedUiConfig,
useIsBackgroundTransparent,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import classNames from "@calcom/lib/classNames";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import notEmpty from "@calcom/lib/notEmpty";
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
import { trpc } from "@calcom/trpc";
import { HeadSeo, NumberInput, useCalcomTheme } from "@calcom/ui";
import { CreditCard, User, RefreshCcw } from "@calcom/ui/components/icon";
import { timeZone as localStorageTimeZone } from "@lib/clock";
import BookingDescription from "@components/booking/BookingDescription";
import { SlotPicker } from "@components/booking/SlotPicker";
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
/*const SlotPicker = dynamic(() => import("../SlotPicker").then((mod) => mod.SlotPicker), {
ssr: false,
loading: () => <div className="mt-8 px-4 pb-4 sm:mt-0 sm:p-4 md:min-w-[300px] md:px-5 lg:min-w-[455px]" />,
});*/
const TimezoneDropdown = dynamic(() => import("../TimezoneDropdown").then((mod) => mod.TimezoneDropdown), {
ssr: false,
});
const dateQuerySchema = z.object({
rescheduleUid: z.string().optional().default(""),
date: z.string().optional().default(""),
timeZone: z.string().optional().default(""),
seatReferenceUid: z.string().optional(),
});
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor: string; darkBrandColor: string }) => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
useCalcomTheme(brandTheme);
};
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
const router = useRouter();
const isEmbed = useIsEmbed(restProps.isEmbed);
const query = dateQuerySchema.parse(router.query);
const { rescheduleUid } = query;
useTheme(profile.theme);
useBrandColors({
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
});
const { t, i18n } = useLocale();
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const isBackgroundTransparent = useIsBackgroundTransparent();
const [timeZone, setTimeZone] = useState<string>();
const [timeFormat, setTimeFormat] = useState<TimeFormat>(detectBrowserTimeFormat);
const onTimeFormatChange = (is24Hours: boolean) => {
setTimeFormat(is24Hours ? TimeFormat.TWENTY_FOUR_HOUR : TimeFormat.TWELVE_HOUR);
setIs24hClockInLocalStorage(is24Hours);
};
useEffect(() => {
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
}, []);
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
/*
const telemetry = useTelemetry();
useEffect(() => {
if (top !== window) {
//page_view will be collected automatically by _middleware.ts
telemetry.event(
telemetryEventTypes.embedView,
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
);
}
}, [telemetry]); */
const embedUiConfig = useEmbedUiConfig();
// get dynamic user list here
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
const timezoneDropdown = useMemo(
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
[timeZone]
);
const paymentAppData = getPaymentAppData(eventType);
const rawSlug = profile.slug ? profile.slug.split("/") : [];
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
{
seatReferenceUid: rescheduleUid,
},
{
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
}
);
return (
<>
<HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
meeting={{
title: eventType.title,
profile: { name: `${profile.name}`, image: profile.image },
users: [
...(eventType.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
}}
nextSeoProps={{
nofollow: eventType.hidden,
noindex: eventType.hidden,
}}
isBrandingHidden={restProps.isBrandingHidden}
/>
<BookingPageTagManager eventType={eventType} />
<div>
<main
className={classNames(
"flex flex-col md:mx-4",
shouldAlignCentrally ? "items-center" : "items-start",
!isEmbed && classNames("bg-subtle dark:bg-default mx-auto my-0 ease-in-out md:my-24")
)}>
<div>
<div
style={availabilityDatePickerEmbedStyles}
className={classNames(
isBackgroundTransparent ? "" : "bg-default dark:bg-muted pb-4 md:pb-0",
"border-booker md:border-booker-width md:rounded-md",
isEmbed && "mx-auto"
)}>
<div className="md:flex">
{showEventTypeDetails && (
<div
className={classNames(
" border-subtle flex flex-col p-5 sm:border-r",
"min-w-full md:w-[230px] md:min-w-[230px]",
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
)}>
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
{rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && (
<div
className={classNames(
"flex flex-nowrap items-center text-sm font-medium",
" text-default",
"ltr:mr-[10px] rtl:ml-[10px]"
)}>
<User
className={classNames(
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
"mt-[2px]"
)}
/>{" "}
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
</div>
)}
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-start text-sm font-medium">
<RefreshCcw className="float-left ml-[2px] mt-[7px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
<div>
<p className="-ml-2 mb-1 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
</p>
<NumberInput
defaultValue={eventType.recurringEvent.count}
min="1"
max={eventType.recurringEvent.count}
isFullWidth={false}
className="me-2 inline w-16"
onChange={(event) => {
const count =
eventType?.recurringEvent?.count &&
parseInt(event?.target.value) > eventType?.recurringEvent?.count
? eventType.recurringEvent.count
: parseInt(event?.target.value);
setRecurringEventCount(count);
}}
/>
<p className="inline">
{t("occurrence", {
count: recurringEventCount,
})}
</p>
</div>
</div>
)}
{paymentAppData.price > 0 && (
<p className="-ml-2 px-2 text-sm font-medium">
<CreditCard className="-mt-1 ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
{paymentAppData.paymentOption === "HOLD" ? (
<>
{t("no_show_fee_amount", {
amount: paymentAppData.price / 100.0,
formatParams: { amount: { currency: paymentAppData.currency } },
})}
</>
) : (
<>
{new Intl.NumberFormat(i18n.language, {
style: "currency",
currency: paymentAppData.currency,
}).format(paymentAppData.price / 100)}
</>
)}
</p>
)}
{timezoneDropdown}
</BookingDescription>
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
<div>
<p
className="mt-4 mb-3 text-default"
data-testid="former_time_p_desktop">
{t("former_time")}
</p>
<p className="text-subtle line-through ">
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-subtle" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
</p>
</div>
)*/}
</div>
)}
<SlotPicker
weekStart={
typeof profile.weekStart === "string"
? ([
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
: profile.weekStart /* Allows providing weekStart as number */
}
eventType={eventType}
timeFormat={timeFormat}
onTimeFormatChange={onTimeFormatChange}
timeZone={timeZone}
users={userList}
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
bookingAttendees={bookingAttendees || undefined}
recurringEventCount={recurringEventCount}
/>
</div>
</div>
{/* FIXME: We don't show branding in Embed yet because we need to place branding on top of the main content. Keeping it outside the main content would have visibility issues because outside main content background is transparent */}
{!restProps.isBrandingHidden && !isEmbed && <PoweredBy />}
</div>
</main>
</div>
<Toaster position="bottom-right" />
</>
);
};
export default AvailabilityPage;
@@ -1,704 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import dynamic from "next/dynamic";
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import type { EventLocationType } from "@calcom/app-store/locations";
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import type { LocationObject } from "@calcom/core/location";
import dayjs from "@calcom/dayjs";
import {
useEmbedNonStylesConfig,
useEmbedUiConfig,
useIsBackgroundTransparent,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { createBooking, createRecurringBooking } from "@calcom/features/bookings/lib";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import {
getBookingFieldsWithSystemFields,
SystemField,
} from "@calcom/features/bookings/lib/getBookingFields";
import getBookingResponsesSchema, {
getBookingResponsesPartialSchema,
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilder";
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, MINUTES_TO_BOOK } from "@calcom/lib/constants";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { parseDate, parseDateTimeWithTimeZone, parseRecurringDates } from "@calcom/lib/parse-dates";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { trpc } from "@calcom/trpc";
import { Button, Form, Tooltip, useCalcomTheme } from "@calcom/ui";
import { AlertTriangle, Calendar, RefreshCw, User } from "@calcom/ui/components/icon";
import { timeZone } from "@lib/clock";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import BookingDescription from "@components/booking/BookingDescription";
import type { BookPageProps } from "../../../pages/[user]/book";
import type { HashLinkPageProps } from "../../../pages/d/[link]/book";
import type { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
/** These are like 40kb that not every user needs */
const BookingDescriptionPayment = dynamic(
() => import("@components/booking/BookingDescriptionPayment")
) as unknown as typeof import("@components/booking/BookingDescriptionPayment").default;
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor?: string; darkBrandColor?: string }) => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
useCalcomTheme(brandTheme);
};
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
const BookingFields = ({
fields,
locations,
rescheduleUid,
isDynamicGroupBooking,
}: {
fields: BookingPageProps["eventType"]["bookingFields"];
locations: LocationObject[];
rescheduleUid?: string;
isDynamicGroupBooking: boolean;
}) => {
const { t } = useLocale();
const { watch, setValue } = useFormContext();
const locationResponse = watch("responses.location");
const currentView = rescheduleUid ? "reschedule" : "";
return (
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
<div>
{fields.map((field, index) => {
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
let readOnly =
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
let noLabel = false;
let hidden = !!field.hidden;
const fieldViews = field.views;
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
return null;
}
if (field.name === SystemField.Enum.rescheduleReason) {
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
readOnly = false;
}
if (field.name === SystemField.Enum.smsReminderNumber) {
// `smsReminderNumber` and location.optionValue when location.value===phone are the same data point. We should solve it in a better way in the Form Builder itself.
// I think we should have a way to connect 2 fields together and have them share the same value in Form Builder
if (locationResponse?.value === "phone") {
setValue(`responses.${SystemField.Enum.smsReminderNumber}`, locationResponse?.optionValue);
// Just don't render the field now, as the value is already connected to attendee phone location
return null;
}
// `smsReminderNumber` can be edited during reschedule even though it's a system field
readOnly = false;
}
if (field.name === SystemField.Enum.guests) {
// No matter what user configured for Guests field, we don't show it for dynamic group booking as that doesn't support guests
hidden = isDynamicGroupBooking ? true : !!field.hidden;
}
// We don't show `notes` field during reschedule
if (
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
!!rescheduleUid
) {
return null;
}
// Dynamically populate location field options
if (field.name === SystemField.Enum.location && field.type === "radioInput") {
if (!field.optionsInputs) {
throw new Error("radioInput must have optionsInputs");
}
const optionsInputs = field.optionsInputs;
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
const options = getLocationOptionsForSelect(locations, t);
options.forEach((option) => {
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
if (optionInput) {
optionInput.placeholder = option.inputPlaceholder;
}
});
field.options = options.filter(
(location): location is NonNullable<(typeof options)[number]> => !!location
);
// If we have only one option and it has an input, we don't show the field label because Option name acts as label.
// e.g. If it's just Attendee Phone Number option then we don't show `Location` label
if (field.options.length === 1) {
if (field.optionsInputs[field.options[0].value]) {
noLabel = true;
} else {
// If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar
hidden = true;
}
}
}
const label = noLabel ? "" : field.label || t(field.defaultLabel || "");
const placeholder = field.placeholder || t(field.defaultPlaceholder || "");
return (
<FormBuilderField
className="mb-4"
field={{ ...field, label, placeholder, hidden }}
readOnly={readOnly}
key={index}
/>
);
})}
</div>
);
};
const routerQuerySchema = z
.object({
timeFormat: z.nativeEnum(TimeFormat),
rescheduleUid: z.string().optional(),
date: z
.string()
.optional()
.transform((date) => {
if (date === undefined) {
return null;
}
return date;
}),
})
.passthrough();
const BookingPage = ({
eventType,
booking,
currentSlotBooking,
profile,
isDynamicGroupBooking,
recurringEventCount,
hasHashedBookingLink,
hashedLink,
...restProps
}: BookingPageProps) => {
const removeSelectedSlotMarkMutation = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation();
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
const { t, i18n } = useLocale();
const { duration: queryDuration } = useRouterQuery("duration");
const { date: queryDate } = useRouterQuery("date");
const isEmbed = useIsEmbed(restProps.isEmbed);
const embedUiConfig = useEmbedUiConfig();
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const router = useRouter();
const { data: session } = useSession();
const isBackgroundTransparent = useIsBackgroundTransparent();
const telemetry = useTelemetry();
const { timezone } = useTimePreferences();
const reserveSlot = () => {
if (queryDuration) {
reserveSlotMutation.mutate({
eventTypeId: eventType.id,
slotUtcStartDate: dayjs(queryDate).utc().format(),
slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(),
bookingUid: currentSlotBooking?.uid,
});
}
};
// Define duration now that we support multiple duration eventTypes
let duration = eventType.length;
if (
queryDuration &&
!isNaN(Number(queryDuration)) &&
eventType.metadata?.multipleDuration &&
eventType.metadata?.multipleDuration.includes(Number(queryDuration))
) {
duration = Number(queryDuration);
}
useEffect(() => {
/* if (top !== window) {
//page_view will be collected automatically by _middleware.ts
telemetry.event(
telemetryEventTypes.embedView,
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
);
} */
reserveSlot();
const interval = setInterval(reserveSlot, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000);
return () => {
clearInterval(interval);
removeSelectedSlotMarkMutation.mutate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const mutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
const { uid } = responseData;
if ("paymentUid" in responseData && !!responseData.paymentUid) {
return await router.push(
createPaymentLink({
paymentUid: responseData.paymentUid,
date,
name: bookingForm.getValues("responses.name"),
email: bookingForm.getValues("responses.email"),
absolute: false,
})
);
}
const query = {
isSuccessBookingPage: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventType.slug,
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
};
return bookingSuccessRedirect({
router,
successRedirectUrl: eventType.successRedirectUrl,
query,
bookingUid: uid,
});
},
});
const recurringMutation = useMutation(createRecurringBooking, {
onSuccess: async (responseData = []) => {
const { uid } = responseData[0] || {};
const query = {
isSuccessBookingPage: true,
allRemainingBookings: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventType.slug,
formerTime: booking?.startTime.toString(),
};
return bookingSuccessRedirect({
router,
successRedirectUrl: eventType.successRedirectUrl,
query,
bookingUid: uid,
});
},
});
const {
data: { timeFormat, rescheduleUid, date },
} = useTypedQuery(routerQuerySchema);
useTheme(profile.theme);
useBrandColors({
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
});
const querySchema = getBookingResponsesPartialSchema({
eventType: {
bookingFields: getBookingFieldsWithSystemFields(eventType),
},
view: rescheduleUid ? "reschedule" : "booking",
});
const parsedQuery = querySchema.parse({
...router.query,
// `guest` because we need to support legacy URL with `guest` query param support
// `guests` because the `name` of the corresponding bookingField is `guests`
guests: router.query.guests || router.query.guest,
});
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
const locations: LocationObject[] = useMemo(
() => (eventType.locations as LocationObject[]) || [],
[eventType.locations]
);
const [isClientTimezoneAvailable, setIsClientTimezoneAvailable] = useState(false);
useEffect(() => {
// THis is to fix hydration error that comes because of different timezone on server and client
setIsClientTimezoneAvailable(true);
}, []);
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
// There should only exists one default userData variable for primaryAttendee.
const defaultUserValues = {
email: rescheduleUid ? booking?.attendees[0].email : parsedQuery["email"],
name: rescheduleUid ? booking?.attendees[0].name : parsedQuery["name"],
};
const defaultValues = () => {
if (!rescheduleUid) {
const defaults = {
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: parsedQuery[field.name],
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
};
return defaults;
}
if (!booking || !booking.attendees.length) {
return {};
}
const primaryAttendee = booking.attendees[0];
if (!primaryAttendee) {
return {};
}
const defaults = {
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: booking.responses[field.name],
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
};
return defaults;
};
const bookingFormSchema = z
.object({
responses: getBookingResponsesSchema({
eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
view: rescheduleUid ? "reschedule" : "booking",
}),
})
.passthrough();
type BookingFormValues = {
locationType?: EventLocationType["type"];
responses: z.infer<typeof bookingFormSchema>["responses"];
};
const bookingForm = useForm<BookingFormValues>({
defaultValues: defaultValues(),
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
});
// Calculate the booking date(s)
let recurringStrings: string[] = [],
recurringDates: Date[] = [];
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
[recurringStrings, recurringDates] = parseRecurringDates(
{
startDate: date,
timeZone: timeZone(),
recurringEvent: eventType.recurringEvent,
recurringCount: parseInt(recurringEventCount.toString()),
selectedTimeFormat: timeFormat,
},
i18n.language
);
}
const bookEvent = (bookingValues: BookingFormValues) => {
telemetry.event(
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
{ isTeamBooking: document.URL.includes("team/") }
);
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
// @TODO: move to metadata
const metadata = Object.keys(router.query)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
}),
{}
);
if (recurringDates.length) {
// Identify set of bookings to one intance of recurring event to support batch changes
const recurringEventId = uuidv4();
const recurringBookings = recurringDates.map((recurringDate) => ({
...bookingValues,
start: dayjs(recurringDate).utc().format(),
end: dayjs(recurringDate).utc().add(duration, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
recurringEventId,
// Added to track down the number of actual occurrences selected by the user
recurringCount: recurringDates.length,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
user: router.query.user,
metadata,
hasHashedBookingLink,
hashedLink,
}));
recurringMutation.mutate(recurringBookings);
} else {
mutation.mutate({
...bookingValues,
start: dayjs(date).utc().format(),
end: dayjs(date).utc().add(duration, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
bookingUid: (router.query.bookingUid as string) || booking?.uid,
user: router.query.user,
metadata,
hasHashedBookingLink,
hashedLink,
seatReferenceUid: router.query.seatReferenceUid as string,
});
}
};
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
return (
<>
<Head>
<title>
{rescheduleUid
? t("booking_reschedule_confirmation", {
eventTypeTitle: eventType.title,
profileName: profile.name,
})
: t("booking_confirmation", {
eventTypeTitle: eventType.title,
profileName: profile.name,
})}{" "}
| {APP_NAME}
</title>
<link rel="icon" href="/favico.ico" />
</Head>
<BookingPageTagManager eventType={eventType} />
<main
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed ? "" : "sm:my-24",
"my-0 max-w-3xl"
)}>
<div
className={classNames(
"main",
isBackgroundTransparent ? "" : "bg-default dark:bg-muted",
"border-booker sm:border-booker-width rounded-md"
)}>
<div className="sm:flex">
{showEventTypeDetails && (
<div className="sm:border-subtle text-default flex flex-col px-6 pb-0 pt-6 sm:w-1/2 sm:border-r sm:pb-6">
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
<BookingDescriptionPayment eventType={eventType} t={t} i18n={i18n} />
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
<div className="text-default items-start text-sm font-medium">
<RefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<p className="-ml-2 inline-block items-center px-2">
{getEveryFreqFor({
t,
recurringEvent: eventType.recurringEvent,
recurringCount: recurringEventCount,
})}
</p>
</div>
)}
<div className="text-bookinghighlight flex items-start text-sm">
<Calendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<div className="text-sm font-medium">
{isClientTimezoneAvailable &&
(rescheduleUid || !eventType.recurringEvent?.freq) &&
`${parseDate(date, i18n.language, { selectedTimeFormat: timeFormat })}`}
{isClientTimezoneAvailable &&
!rescheduleUid &&
eventType.recurringEvent?.freq &&
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
return <p key={key}>{timeFormatted}</p>;
})}
{!rescheduleUid && eventType?.recurringEvent?.freq && recurringStrings.length > 5 && (
<div className="flex">
<Tooltip
content={recurringStrings.slice(5).map((timeFormatted, key) => (
<p key={key}>{timeFormatted}</p>
))}>
<p className=" text-sm">
+ {t("plus_more", { count: recurringStrings.length - 5 })}
</p>
</Tooltip>
</div>
)}
</div>
</div>
{booking?.startTime && rescheduleUid && (
<div>
<p className="mb-2 mt-8 text-sm " data-testid="former_time_p">
{t("former_time")}
</p>
<p className="line-through ">
<Calendar className="-mt-1 ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
{isClientTimezoneAvailable &&
typeof booking.startTime === "string" &&
parseDateTimeWithTimeZone(booking.startTime, i18n.language, timezone, {
selectedTimeFormat: timeFormat,
})}
</p>
</div>
)}
{!!eventType.seatsPerTimeSlot && (
<div className="text-bookinghighlight flex items-start text-sm">
<User
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
}`}
/>
<p
className={`${
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
} mb-2 font-medium`}>
{currentSlotBooking
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
: eventType.seatsPerTimeSlot}{" "}
/ {eventType.seatsPerTimeSlot}{" "}
{t("seats_available", {
count: currentSlotBooking
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
: eventType.seatsPerTimeSlot,
})}
</p>
</div>
)}
</BookingDescription>
</div>
)}
<div className={classNames("p-6", showEventTypeDetails ? "sm:w-1/2" : "w-full")}>
<Form form={bookingForm} noValidate handleSubmit={bookEvent}>
<BookingFields
isDynamicGroupBooking={isDynamicGroupBooking}
fields={eventType.bookingFields}
locations={locations}
rescheduleUid={rescheduleUid}
/>
<div
className={classNames(
"flex justify-end space-x-2 rtl:space-x-reverse",
// HACK: If the last field is guests, we need to move Cancel, Submit buttons up because "Add Guests" being present on the left and the buttons on the right, spacing is not required
eventType.bookingFields[eventType.bookingFields.length - 1].name ===
SystemField.Enum.guests
? "-mt-4"
: ""
)}>
<Button color="minimal" type="button" onClick={() => router.back()}>
{t("cancel")}
</Button>
<Button
type="submit"
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
loading={mutation.isLoading || recurringMutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
</Button>
</div>
</Form>
{mutation.isError || recurringMutation.isError ? (
<ErrorMessage error={mutation.error || recurringMutation.error} />
) : null}
</div>
</div>
</div>
</main>
<Toaster position="bottom-right" />
</>
);
};
export default BookingPage;
function ErrorMessage({ error }: { error: unknown }) {
const { t } = useLocale();
const { query: { rescheduleUid } = {} } = useRouter();
const router = useRouter();
return (
<div data-testid="booking-fail" className="mt-2 border-l-4 border-blue-400 bg-blue-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-blue-400" aria-hidden="true" />
</div>
<div className="ms-3">
<p className="text-sm text-blue-700">
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
{error instanceof HttpError || error instanceof Error ? (
<>
{t("can_you_try_again")}{" "}
<span className="cursor-pointer underline" onClick={() => router.back()}>
{t("go_back")}
</span>
.
</> /* t(error.message) */
) : (
"Unknown error"
)}
</p>
</div>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { z } from "zod";
import type { EventNameObjectType } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
@@ -16,6 +17,7 @@ import {
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema"
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import { classNames } from "@calcom/lib";
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
@@ -85,6 +87,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
return {
...field,
hidden: !enabled,
editable: (!enabled ? "system-but-hidden" : "system-but-optional") as z.infer<typeof EditableSchema>
};
}
return field;
@@ -7,6 +7,7 @@ import { useMemo, useState, Suspense } from "react";
import type { UseFormReturn } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
@@ -233,9 +234,11 @@ function EventTypeSingleLayout({
formMethods,
]);
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
const orgBranding = useOrgBrandingValues();
const isOrgEvent = orgBranding?.fullDomain;
const permalink = `${orgBranding?.fullDomain ?? CAL_URL}/${
team ? `${!isOrgEvent ? "team/" : ""}${team.slug}` : eventType.users[0].username
}/${eventType.slug}`;
const embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
@@ -22,15 +22,13 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
const { nextStep } = props;
const router = useRouter();
let queryAvailability;
if (defaultScheduleId) {
queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: defaultScheduleId },
{
enabled: router.isReady,
}
);
}
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: defaultScheduleId! },
{
enabled: router.isReady && !!defaultScheduleId,
}
);
const availabilityForm = useForm({
defaultValues: {
+14 -19
View File
@@ -1,11 +1,10 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import classNames from "classnames";
import Link from "next/link";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
const ChooseLicense = (
props: {
value: string;
@@ -50,23 +49,19 @@ const ChooseLicense = (
</ul>
</div>
</RadioGroup.Item>
<RadioGroup.Item value="EE">
<div
className={classNames(
"bg-default cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
value === "EE" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-emphasis text-xl">{t("ee_enterprise_license")}</h2>
<p className="font-medium text-green-800">
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
</p>
<p className="text-subtle">{t("enterprise_license_includes")}</p>
<ul className="text-subtle ml-4 list-disc text-left text-xs">
<li>{t("no_need_to_keep_your_code_open_source")}</li>
<li>{t("repackage_rebrand_resell")}</li>
<li>{t("a_vast_suite_of_enterprise_features")}</li>
</ul>
</div>
<RadioGroup.Item value="EE" disabled>
<Link href="https://cal.com/sales" target="_blank">
<div className={classNames("bg-default h-full cursor-pointer space-y-2 rounded-md border p-4")}>
<h2 className="font-cal text-emphasis text-xl">{t("custom_plan")}</h2>
<p className="font-medium text-green-800">{t("contact_sales")}</p>
<p className="text-subtle">Build on top of Cal.com</p>
<ul className="text-subtle ml-4 list-disc text-left text-xs">
<li>{t("no_need_to_keep_your_code_open_source")}</li>
<li>{t("repackage_rebrand_resell")}</li>
<li>{t("a_vast_suite_of_enterprise_features")}</li>
</ul>
</div>
</Link>
</RadioGroup.Item>
</RadioGroup.Root>
</form>
+2 -7
View File
@@ -1,7 +1,6 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
@@ -12,7 +11,7 @@ type TeamType = NonNullable<TeamWithMembers>;
type MembersType = TeamType["members"];
type MemberType = MembersType[number] & { safeBio: string | null };
type TeamTypeWithSafeHtml = Omit<TeamType, "members"> & { members: MemberType[] };
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale();
@@ -25,11 +24,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
return (
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
<Avatar
size="md"
alt={member.name || ""}
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
/>
<Avatar size="md" alt={member.name || ""} imageSrc={"/" + member.username + "/avatar.png"} />
<section className="mt-2 line-clamp-4 w-full space-y-1">
<p className="text-default font-medium">{member.name}</p>
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
@@ -1,5 +1,6 @@
import classNames from "classnames";
import { debounce, noop } from "lodash";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import type { RefCallback } from "react";
import { useEffect, useMemo, useState } from "react";
@@ -46,6 +47,7 @@ const obtainNewUsernameChangeCondition = ({
const PremiumTextfield = (props: ICustomUsernameProps) => {
const { t } = useLocale();
const { data: session, update } = useSession();
const {
currentUsername,
setCurrentUsername = noop,
@@ -94,6 +96,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
const updateUsername = trpc.viewer.updateProfile.useMutation({
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
await update();
setOpenDialogSaveUsername(false);
},
onError: (error) => {
@@ -1,5 +1,6 @@
import classNames from "classnames";
import { debounce, noop } from "lodash";
import { useSession } from "next-auth/react";
import type { RefCallback } from "react";
import { useEffect, useMemo, useState } from "react";
@@ -23,6 +24,7 @@ interface ICustomUsernameProps {
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
const { t } = useLocale();
const { data: session, update } = useSession();
const {
currentUsername,
setCurrentUsername = noop,
@@ -69,6 +71,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
onSuccessMutation && (await onSuccessMutation());
setOpenDialogSaveUsername(false);
setCurrentUsername(inputUsernameValue);
await update();
},
onError: (error) => {
onErrorMutation && onErrorMutation(error);
@@ -156,23 +159,23 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
<DialogContent type="confirmation" Icon={Edit2} title={t("confirm_username_change_dialog_title")}>
<div className="flex flex-row">
<div className="mb-4 w-full pt-1">
<div className="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
<div className="flex-1 px-2">
<div className="bg-subtle flex w-full flex-wrap gap-6 rounded-sm px-2 py-3 text-sm">
<div>
<p className="text-subtle">{t("current_username")}</p>
<p className="text-emphasis mt-1" data-testid="current-username">
{currentUsername}
</p>
</div>
<div className="ml-6 flex-1">
<div>
<p className="text-subtle" data-testid="new-username">
{t("new_username")}
</p>
<p className="text-emphasis">{inputUsernameValue}</p>
<p className="text-emphasis mt-1">{inputUsernameValue}</p>
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-row-reverse gap-x-2">
<div className="ml-4 mt-4 flex flex-row-reverse gap-x-2">
<Button
type="button"
loading={updateUsernameMutation.isLoading}
-4
View File
@@ -10,10 +10,6 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
/**
* We are using env variable to toggle new-booker because using flags would be an unnecessary delay for booking pages
* Also, we can't easily identify the booker page requests here(to just fetch the flags for those requests)
*/
if (isIpInBanlist(req) && url.pathname !== "/api/nope") {
// DDOS Prevention: Immediately end request with no response - Avoids a redirect as well initiated by NextAuth on invalid callback
-78
View File
@@ -5,11 +5,6 @@ const englishTranslation = require("./public/static/locales/en/common.json");
const { withAxiom } = require("next-axiom");
const { i18n } = require("./next-i18next.config");
const {
userTypeRoutePath,
teamTypeRoutePath,
privateLinkRoutePath,
embedUserTypeRoutePath,
embedTeamTypeRoutePath,
orgHostPath,
orgUserRoutePath,
orgUserTypeRoutePath,
@@ -285,85 +280,12 @@ const nextConfig = {
},
],
// Keep cookie based booker enabled just in case we disable new-booker globally
...[
{
source: userTypeRoutePath,
destination: "/new-booker/:user/:type",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
{
source: teamTypeRoutePath,
destination: "/new-booker/team/:slug/:type",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
{
source: privateLinkRoutePath,
destination: "/new-booker/d/:link/:slug",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
],
// Keep cookie based booker enabled to test new-booker embed in production
...[
{
source: embedUserTypeRoutePath,
destination: "/new-booker/:user/:type/embed",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
{
source: embedTeamTypeRoutePath,
destination: "/new-booker/team/:slug/:type/embed",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
],
/* TODO: have these files being served from another deployment or CDN {
source: "/embed/embed.js",
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
}, */
/**
* Enables new booker using cookie. It works even if NEW_BOOKER_ENABLED_FOR_NON_EMBED, NEW_BOOKER_ENABLED_FOR_EMBED are disabled
*/
];
// Enable New Booker for all Embed Requests
if (process.env.NEW_BOOKER_ENABLED_FOR_EMBED === "1") {
console.log("Enabling New Booker for Embed");
afterFiles.push(
...[
{
source: embedUserTypeRoutePath,
destination: "/new-booker/:user/:type/embed",
},
{
source: embedTeamTypeRoutePath,
destination: "/new-booker/team/:slug/:type/embed",
},
]
);
}
// Enable New Booker for All but embed Requests
if (process.env.NEW_BOOKER_ENABLED_FOR_NON_EMBED === "1") {
console.log("Enabling New Booker for Non-Embed");
afterFiles.push(
...[
{
source: userTypeRoutePath,
destination: "/new-booker/:user/:type",
},
{
source: teamTypeRoutePath,
destination: "/new-booker/team/:slug/:type",
},
{
source: privateLinkRoutePath,
destination: "/new-booker/d/:link/:slug",
},
]
);
}
return {
beforeFiles,
afterFiles,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.0.14",
"version": "3.0.16",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -47,7 +47,7 @@
"@next/bundle-analyzer": "^13.1.6",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-id": "^1.0.0",
"@radix-ui/react-popover": "^1.0.2",
@@ -89,7 +89,7 @@
"micro": "^10.0.1",
"mime-types": "^2.1.35",
"next": "^13.4.6",
"next-auth": "^4.20.1",
"next-auth": "^4.22.1",
"next-axiom": "^0.17.0",
"next-collect": "^0.2.1",
"next-i18next": "^13.2.2",
+13 -9
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DOCS_URL, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
import { DOCS_URL, JOIN_DISCORD, WEBSITE_URL, IS_CALCOM } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
import { BookOpen, Check, ChevronRight, FileText, Shield } from "@calcom/ui/components/icon";
@@ -58,16 +58,21 @@ export default function Custom404() {
// Accessing a non-existent team
setUsername(splitPath[2]);
setCurrentPageType(pageType.TEAM);
setUrl(`${WEBSITE_URL}/signup?callbackUrl=settings/teams/new%3Fslug%3D${username.replace("/", "")}`);
setUrl(
`${WEBSITE_URL}/signup?callbackUrl=settings/teams/new%3Fslug%3D${splitPath[2].replace("/", "")}`
);
} else {
setUsername(routerUsername);
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
setUrl(`${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`);
}
} else {
setUsername(currentOrgDomain);
setCurrentPageType(pageType.ORG);
setUrl(
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${username.replace("/", "")}`
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain.replace(
"/",
""
)}`
);
}
}, []);
@@ -75,7 +80,6 @@ export default function Custom404() {
const isSuccessPage = router.asPath.startsWith("/booking");
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
const isSignup = router.asPath.startsWith("/signup");
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
/**
* If we're on 404 and the route is insights it means it is disabled
* TODO: Abstract this for all disabled features
@@ -249,7 +253,7 @@ export default function Custom404() {
<span className="mt-2 inline-block text-lg ">
{t("check_spelling_mistakes_or_go_back")}
</span>
) : isCalcom ? (
) : IS_CALCOM ? (
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
<strong className="text-blue-500">
@@ -267,7 +271,7 @@ export default function Custom404() {
)}
</div>
<div className="mt-12">
{((!isSubpage && isCalcom) ||
{((!isSubpage && IS_CALCOM) ||
currentPageType === pageType.ORG ||
currentPageType === pageType.TEAM) && (
<ul role="list" className="my-4">
@@ -317,7 +321,7 @@ export default function Custom404() {
.filter((_, idx) => currentPageType === pageType.ORG || idx !== 0)
.map((link, linkIdx) => (
<li key={linkIdx} className="px-4 py-2">
<Link
<a
href={link.href}
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
<div className="flex-shrink-0">
@@ -337,7 +341,7 @@ export default function Custom404() {
<div className="flex-shrink-0 self-center">
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</Link>
</a>
</li>
))}
<li className="px-4 py-2">
+1 -2
View File
@@ -13,7 +13,6 @@ import {
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
import { WEBAPP_URL } from "@calcom/lib/constants";
import defaultEvents, {
getDynamicEventDescription,
getGroupName,
@@ -291,7 +290,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const users = usersWithoutAvatar.map((user) => ({
...user,
avatar: `${WEBAPP_URL}/${user.username}/avatar.png`,
avatar: `/${user.username}/avatar.png`,
}));
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
+122 -320
View File
@@ -1,281 +1,60 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { classNames } from "@calcom/lib";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { User } from "@calcom/prisma/client";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
import { ssrInit } from "@server/lib/ssr";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type(props: AvailabilityPageProps) {
const { t } = useLocale();
return props.away ? (
<div className="dark:bg-inverted h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
😴{" " + t("user_away")}
</h2>
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
</div>
</div>
</div>
</main>
</div>
) : props.isDynamic && !props.profile.allowDynamicBooking ? (
<div className="dark:bg-darkgray-50 h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
</main>
</div>
) : !props.isValidOrgDomain && props.organizationContext ? (
<div className="dark:bg-darkgray-50 h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_belongs_organization")}</p>
</div>
</div>
</div>
</main>
</div>
) : (
<AvailabilityPage {...props} />
export default function Type({ slug, user, booking, away, isBrandingHidden, rescheduleUid }: PageProps) {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
return (
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={rescheduleUid ?? undefined}
hideBranding={isBrandingHidden}
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
/>
</main>
);
}
Type.isBookingPage = true;
Type.PageWrapper = PageWrapper;
const paramsSchema = z.object({ type: z.string(), user: z.string() });
async function getUserPageProps(context: GetServerSidePropsContext) {
// load server side dependencies
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
const { EventTypeMetaDataSchema, teamMetadataSchema } = await import("@calcom/prisma/zod-utils");
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const ssr = await ssrInit(context);
const { type: slug, user: username } = paramsSchema.parse(context.query);
const user = await prisma.user.findFirst({
where: {
/** TODO: We should standarize this */
username: username.toLowerCase().replace(/( |%20)/g, "+"),
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
id: true,
username: true,
away: true,
name: true,
hideBranding: true,
timeZone: true,
theme: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
organizationId: true,
eventTypes: {
where: {
// Many-to-many relationship causes inclusion of the team events - cool -
// but to prevent these from being selected, make sure the teamId is NULL.
AND: [{ slug }, { teamId: null }],
},
select: {
title: true,
slug: true,
hidden: true,
recurringEvent: true,
length: true,
locations: true,
id: true,
description: true,
price: true,
currency: true,
requiresConfirmation: true,
schedulingType: true,
metadata: true,
seatsPerTimeSlot: true,
team: {
select: {
logo: true,
parent: {
select: {
logo: true,
name: true,
},
},
},
},
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
},
teams: {
include: {
team: true,
},
},
},
});
if (!user || !user.eventTypes.length) return { notFound: true };
const [eventType]: ((typeof user.eventTypes)[number] & {
users: Pick<User, "name" | "username" | "hideBranding" | "timeZone">[];
})[] = [
{
...user.eventTypes[0],
users: [
{
name: user.name,
username: user.username,
hideBranding: user.hideBranding,
timeZone: user.timeZone,
},
],
},
];
if (!eventType) return { notFound: true };
//TODO: Use zodSchema to verify it instead of using Type Assertion
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
const eventTypeObject = Object.assign({}, eventType, {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
});
// Check if the user you are logging into has any active teams or premium user name
const hasActiveTeam =
user.teams.filter((m) => {
if (!IS_TEAM_BILLING_ENABLED) return true;
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
if (metadata.success && metadata.data?.subscriptionId) return true;
return false;
}).length > 0;
const hasPremiumUserName = hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
return {
props: {
eventType: eventTypeObject,
profile: {
...eventType.users[0],
theme: user.theme,
allowDynamicBooking: false,
weekStart: user.weekStart,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
slug: `${user.username}/${eventType.slug}`,
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
},
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: user.username,
organizationContext: user?.organizationId !== null,
away: user?.away,
isDynamic: false,
trpcState: ssr.dehydrate(),
isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""),
isBrandingHidden: isBrandingHidden(user.hideBranding, hasActiveTeam || hasPremiumUserName),
},
};
}
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
// load server side dependencies
const { getDefaultEvent, getGroupName, getUsernameList } = await import("@calcom/lib/defaultEvents");
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const { EventTypeMetaDataSchema, userMetadata: userMetadataSchema } = await import(
"@calcom/prisma/zod-utils"
);
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid, bookingUid } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { getAppFromSlug } = await import("@calcom/app-store/utils");
const { type: typeParam, user: userParam } = paramsSchema.parse(context.params);
const usernameList = getUsernameList(userParam);
const length = parseInt(typeParam);
const eventType = getDefaultEvent("" + length);
const users = await prisma.user.findMany({
where: {
username: {
in: usernameList,
in: usernames,
},
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
startTime: true,
endTime: true,
timeZone: true,
weekStart: true,
availability: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
defaultScheduleId: true,
allowDynamicBooking: true,
metadata: true,
organizationId: true,
away: true,
schedules: {
select: {
availability: true,
timeZone: true,
id: true,
},
},
theme: true,
},
});
@@ -285,86 +64,109 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
};
}
// sort and be in the same order as usernameList so first user is the first user in the list
let sortedUsers: typeof users = [];
if (users.length > 1) {
sortedUsers = users.sort((a, b) => {
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
return aIndex - bIndex;
});
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`);
} else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`);
}
let locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username: usernames.join("+"), eventSlug: slug });
// Get the prefered location type from the first user
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
if (preferedLocationType?.appSlug) {
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
const appType = foundApp?.appData?.location?.type;
if (appType) {
// Replace the location with the prefered location type
// This will still be default to daily if the app is not found
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
}
if (!eventData) {
return {
notFound: true,
};
}
const eventTypeObject = Object.assign({}, eventType, {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations),
users: users.map((user) => {
return {
name: user.name,
username: user.username,
hideBranding: user.hideBranding,
timeZone: user.timeZone,
};
}),
});
const dynamicNames = users.map((user) => {
return user.name || "";
});
const profile = {
name: getGroupName(dynamicNames),
image: null,
slug: "" + length,
theme: null as string | null,
weekStart: "Sunday",
brandColor: "",
darkBrandColor: "",
allowDynamicBooking: !users.some((user) => {
return !user.allowDynamicBooking;
}),
};
return {
props: {
eventType: eventTypeObject,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: null,
isDynamic: true,
booking,
user: usernames.join("+"),
slug,
away: false,
organizationContext: !users.some((user) => user.organizationId === null),
trpcState: ssr.dehydrate(),
isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""),
isBrandingHidden: false, // I think we should always show branding for dynamic groups - saves us checking every single user
isBrandingHidden: false,
themeBasis: null,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
},
};
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { user: userParam } = paramsSchema.parse(context.params);
// dynamic groups are not generated at build time, but otherwise are probably cached until infinity.
const isDynamicGroup = getUsernameList(userParam).length > 1;
if (isDynamicGroup) {
return await getDynamicGroupPageProps(context);
} else {
return await getUserPageProps(context);
async function getUserPageProps(context: GetServerSidePropsContext) {
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0];
const { rescheduleUid, bookingUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
away: true,
hideBranding: true,
},
});
if (!user) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`);
} else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`);
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug });
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
booking,
away: user?.away,
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
themeBasis: username,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
},
};
}
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
user: z.string().transform((s) => getUsernameList(s)),
});
// Booker page fetches a tiny bit of data server side, to determine early
// whether the page should show an away state or dynamic booking not allowed.
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { user } = paramsSchema.parse(context.params);
const isDynamicGroup = user.length > 1;
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
};
+6 -14
View File
@@ -1,19 +1,11 @@
import type { GetServerSidePropsContext } from "next";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
export { default } from "../[type]";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssrResponse = await _getServerSideProps(context);
if (ssrResponse.notFound) {
return ssrResponse;
}
return {
...ssrResponse,
props: {
...ssrResponse.props,
isEmbed: true,
},
};
};
// Somehow these types don't accept the {notFound: true} return type.
// Probably still need to fix this. I don't know why this isn't allowed yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
-309
View File
@@ -1,309 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import getBooking from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import {
getDefaultEvent,
getDynamicEventName,
getGroupName,
getUsernameList,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import {
customInputSchema,
EventTypeMetaDataSchema,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import BookingPage from "@components/booking/pages/BookingPage";
import { ssrInit } from "@server/lib/ssr";
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Book(props: BookPageProps) {
const { t } = useLocale();
return props.away ? (
<div className="dark:bg-inverted h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-default mb-2 text-3xl">
😴{" " + t("user_away")}
</h2>
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
</div>
</div>
</div>
</main>
</div>
) : props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
<div className="dark:bg-inverted h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-default mb-2 text-3xl">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
</main>
</div>
) : (
<BookingPage {...props} />
);
}
Book.isBookingPage = true;
Book.PageWrapper = PageWrapper;
const querySchema = z.object({
bookingUid: z.string().optional(),
count: z.coerce.number().optional(),
embed: z.string().optional(),
rescheduleUid: z.string().optional(),
slug: z.string().optional(),
/** This is the event "type" ID */
type: z.coerce.number().optional(),
user: z.string(),
seatReferenceUid: z.string().optional(),
date: z.string().optional(),
duration: z.coerce.number().optional(),
});
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const query = querySchema.parse(context.query);
const usernameList = getUsernameList(query.user);
const eventTypeSlug = query.slug;
const recurringEventCountQuery = query.count;
const users = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
away: true,
metadata: true,
},
});
if (!users.length) return { notFound: true };
const [user] = users;
const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug;
// Dynamic Group link doesn't need a type but it must have a slug
if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) {
return { notFound: true };
}
const eventTypeRaw = isDynamicGroupBooking
? getDefaultEvent(eventTypeSlug)
: await prisma.eventType.findUnique({
where: {
id: query.type,
},
select: {
...bookEventTypeSelect,
},
});
if (!eventTypeRaw) return { notFound: true };
const eventType = {
...eventTypeRaw,
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
bookingFields: getBookingFieldsWithSystemFields(eventTypeRaw),
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
};
const getLocations = () => {
let locations = eventTypeRaw.locations || [];
if (!isDynamicGroupBooking) return locations;
let sortedUsers: typeof users = [];
// sort and be in the same order as usernameList so first user is the first user in the list
if (users.length > 1) {
sortedUsers = users.sort((a, b) => {
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
return aIndex - bIndex;
});
}
// Get the prefered location type from the first user
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
if (preferedLocationType?.appSlug) {
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
const appType = foundApp?.appData?.location?.type;
if (appType) {
// Replace the location with the prefered location type
// This will still be default to daily if the app is not found
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
}
}
return locations;
};
const eventTypeObject = [eventType].map((e) => {
let locations = getLocations();
locations = privacyFilteredLocations(locations as LocationObject[]);
return {
...e,
locations: locations,
periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
schedulingType: null,
customInputs: customInputSchema.array().parse(e.customInputs || []),
users: users.map((u) => ({
id: u.id,
name: u.name,
username: u.username,
avatar: u.avatar,
image: u.avatar,
slug: u.username,
theme: u.theme,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
// If rescheduleUid and event has seats lets convert Uid to bookingUid
let rescheduleUid = query.rescheduleUid;
const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot;
let attendeeEmail: string;
let bookingUidWithSeats: string | null = null;
if (rescheduleEventTypeHasSeats) {
const bookingSeat = await prisma.bookingSeat.findFirst({
where: {
referenceUid: query.rescheduleUid,
},
select: {
id: true,
attendee: true,
booking: {
select: {
uid: true,
},
},
},
});
if (bookingSeat) {
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
}
}
if (query.duration) {
// If it's not reschedule but event Type has seats we should obtain
// the bookingUid regardless and use it to get the booking
const currentSeats = await prisma.booking.findFirst({
where: {
eventTypeId: eventTypeRaw.id,
startTime: dayjs(query.date).toISOString(),
endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(),
},
select: {
uid: true,
},
});
if (currentSeats && currentSeats) {
bookingUidWithSeats = currentSeats.uid;
}
}
let booking: GetBookingType | null = null;
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
}
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
const currentAttendee = booking?.attendees.find((attendee) => {
return attendee.email === attendeeEmail;
});
if (currentAttendee) {
booking.attendees = [currentAttendee] || [];
}
}
const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : [];
const profile = isDynamicGroupBooking
? {
name: getGroupName(dynamicNames),
image: null,
slug: eventTypeSlug,
theme: null,
brandColor: "",
darkBrandColor: "",
allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking),
eventName: getDynamicEventName(dynamicNames, eventTypeSlug),
}
: {
name: user.name || user.username,
image: user.avatar,
slug: user.username,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
eventName: null,
};
// Checking if number of recurring event ocurrances is valid against event type configuration
const recurringEventCount =
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(recurringEventCountQuery <= eventType.recurringEvent.count
? recurringEventCountQuery
: eventType.recurringEvent.count)) ||
null;
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
return {
props: {
away: user.away,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: isDynamicGroupBooking ? null : user.username,
eventType: eventTypeObject,
booking,
currentSlotBooking: currentSlotBooking,
recurringEventCount,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
hasHashedBookingLink: false,
hashedLink: null,
isEmbed: !!query.embed,
},
};
}
+7
View File
@@ -48,6 +48,13 @@ class MyDocument extends Document<Props> {
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
{(process.env.NODE_ENV === "development" || process.env.VERCEL_ENV === "preview") && (
// eslint-disable-next-line @next/next/no-sync-scripts
<script
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
/>
)}
</Head>
<body
+6 -13
View File
@@ -11,8 +11,6 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common");
const email = z
.string()
.email()
@@ -47,6 +45,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
name: true,
identityProvider: true,
email: true,
locale: true,
},
});
@@ -57,6 +56,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
.json({ message: "If this email exists in our system, you should receive a Reset email." });
}
const t = await getTranslation(maybeUser.locale ?? "en", "common");
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
where: {
email: maybeUser.email,
@@ -88,17 +89,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
resetLink,
});
/** So we can test the password reset flow on CI */
if (process.env.NEXT_PUBLIC_IS_E2E) {
return res.status(201).json({
message: "If this email exists in our system, you should receive a Reset email.",
resetLink,
});
} else {
return res
.status(201)
.json({ message: "If this email exists in our system, you should receive a Reset email." });
}
return res
.status(201)
.json({ message: "If this email exists in our system, you should receive a Reset email." });
} catch (reason) {
// console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
+49 -38
View File
@@ -1,55 +1,66 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { validPassword } from "@calcom/features/auth/lib/validPassword";
import prisma from "@calcom/prisma";
const passwordResetRequestSchema = z.object({
password: z.string().refine(validPassword, () => ({
message: "Password does not meet the requirements",
})),
requestId: z.string(), // format doesn't matter.
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(400).json({ message: "" });
}
// Bad Method when not POST
if (req.method !== "POST") return res.status(405).end();
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(req.body);
// rate-limited there is a low, very low chance that a password request stays valid long enough
// to brute force 3.8126967e+40 options.
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
where: {
id: rawRequestId,
expires: {
gt: new Date(),
},
},
select: {
email: true,
},
});
const hashedPassword = await hashPassword(rawPassword);
// this can fail if a password request has been made for an email that has since changed or-
// never existed within Cal. In this case we do not want to disclose the email's existence.
// instead, we just return 404
try {
const rawPassword = req.body?.password;
const rawRequestId = req.body?.requestId;
if (!rawPassword || !rawRequestId) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const maybeRequest = await prisma.resetPasswordRequest.findUnique({
where: {
id: rawRequestId,
},
});
if (!maybeRequest) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const maybeUser = await prisma.user.findUnique({
where: {
email: maybeRequest.email,
},
});
if (!maybeUser) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const hashedPassword = await hashPassword(rawPassword);
await prisma.user.update({
where: {
id: maybeUser.id,
email: maybeRequest.email,
},
data: {
password: hashedPassword,
},
});
return res.status(201).json({ message: "Password reset." });
} catch (reason) {
console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
} catch (e) {
return res.status(404).end();
}
await expireResetPasswordRequest(rawRequestId);
return res.status(201).json({ message: "Password reset." });
}
async function expireResetPasswordRequest(rawRequestId: string) {
await prisma.resetPasswordRequest.update({
where: {
id: rawRequestId,
},
data: {
// We set the expiry to now to invalidate the request
expires: new Date(),
},
});
}
@@ -0,0 +1,59 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
import { defaultResponder } from "@calcom/lib/server";
import type { AppsStatus } from "@calcom/types/Calendar";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const data: RecurringBookingCreateBody[] = req.body;
const session = await getServerSession({ req, res });
const createdBookings: BookingResponse[] = [];
const allRecurringDates: string[] = data.map((booking) => booking.start);
let appsStatus: AppsStatus[] | undefined = undefined;
/* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1;
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
// emailed booking taking into account accumulated results to send app status accurately
for (let key = data.length - 1; key >= 0; key--) {
const booking = data[key];
if (key === 0) {
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
.reduce((prev, curr) => {
if (prev[curr.type]) {
prev[curr.type].failures += curr.failures;
prev[curr.type].success += curr.success;
} else {
prev[curr.type] = curr;
}
return prev;
}, {} as { [key: string]: AppsStatus });
appsStatus = Object.values(calcAppsStatus);
}
const recurringEventReq: NextApiRequest & { userId?: number } = req;
recurringEventReq.body = {
...booking,
appsStatus,
allRecurringDates,
currentRecurringIndex: key,
noEmail: key !== 0,
};
const eachRecurringBooking = await handleNewBooking(recurringEventReq, {
isNotAnApiCall: true,
});
createdBookings.push(eachRecurringBooking);
}
return createdBookings;
}
export default defaultResponder(handler);
@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/workflows/api/scheduleWhatsappReminders";
-26
View File
@@ -1,26 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { defaultResponder } from "@calcom/lib/server";
const newBookerSchema = z.object({
status: z.enum(["enable", "disable"]),
});
/**
* Very basic temporary api route to enable/disable new booker access.
*/
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { status } = newBookerSchema.parse(req.query);
if (status === "enable") {
const expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
res.setHeader("Set-Cookie", `new-booker-enabled=true; path=/; expires=${expires.toUTCString()}`);
} else {
res.setHeader("Set-Cookie", "new-booker-enabled=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
}
res.send({ status: 200, body: `Done – ${status}` });
}
export default defaultResponder(handler);
+4
View File
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { adminRouter } from "@calcom/trpc/server/routers/viewer/admin/_router";
export default createNextApiHandler(adminRouter);
+1 -1
View File
@@ -46,7 +46,7 @@ function getRoute(appName: string, pages: string[]) {
} as NotFound;
}
const mainPage = pages[0];
const appPage = routingConfig[mainPage] as AppPageType;
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
if (!appPage) {
return {
+61 -97
View File
@@ -1,62 +1,42 @@
import type { ResetPasswordRequest } from "@prisma/client";
import { debounce } from "lodash";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Link from "next/link";
import type { CSSProperties } from "react";
import React, { useMemo } from "react";
import React from "react";
import { useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Button, TextField } from "@calcom/ui";
import { Button, PasswordField, Form } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import AuthContainer from "@components/ui/AuthContainer";
type Props = {
id: string;
resetPasswordRequest: ResetPasswordRequest;
requestId: string;
isRequestExpired: boolean;
csrfToken: string;
};
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
export default function Page({ requestId, isRequestExpired, csrfToken }: Props) {
const { t } = useLocale();
const [loading, setLoading] = React.useState(false);
const [, setError] = React.useState<{ message: string } | null>(null);
const [success, setSuccess] = React.useState(false);
const [password, setPassword] = React.useState("");
const formMethods = useForm<{ new_password: string }>();
const success = formMethods.formState.isSubmitSuccessful;
const loading = formMethods.formState.isSubmitting;
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
body: JSON.stringify({ requestId: requestId, password: password }),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) {
setError(json);
} else {
setSuccess(true);
}
return json;
} catch (reason) {
setError({ message: t("unexpected_error_try_again") });
} finally {
setLoading(false);
}
const res = await fetch("/api/auth/reset-password", {
method: "POST",
body: JSON.stringify({ requestId, password }),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message });
};
const debouncedChangePassword = debounce(submitChangePassword, 250);
const Success = () => {
return (
<>
@@ -95,11 +75,6 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
);
};
const isRequestExpired = useMemo(() => {
const now = dayjs();
return dayjs(resetPasswordRequest.expires).isBefore(now);
}, [resetPasswordRequest]);
return (
<AuthContainer
showLogo
@@ -109,8 +84,9 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
{isRequestExpired && <Expired />}
{!isRequestExpired && !success && (
<>
<form
<Form
className="space-y-6"
form={formMethods}
style={
{
"--cal-brand": "#111827",
@@ -119,36 +95,26 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
onSubmit={async (e) => {
e.preventDefault();
if (!password) {
return;
}
if (loading) {
return;
}
setLoading(true);
setError(null);
setSuccess(false);
await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
}}
action="#">
handleSubmit={async (values) => {
await submitChangePassword({
password: values.new_password,
requestId,
});
}}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div className="mt-1">
<TextField
<PasswordField
{...formMethods.register("new_password", {
minLength: {
message: t("password_hint_min"),
value: 7, // We don't have user here so we can't check if they are admin or not
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
onChange={(e) => {
setPassword(e.target.value);
}}
id="password"
name="password"
type="password"
autoComplete="password"
required
/>
</div>
@@ -162,7 +128,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
{t("reset_password")}
</Button>
</div>
</form>
</Form>
</>
)}
{!isRequestExpired && success && (
@@ -179,31 +145,29 @@ Page.PageWrapper = PageWrapper;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const id = context.params?.id as string;
let resetPasswordRequest = await prisma.resetPasswordRequest.findFirst({
where: {
id,
expires: {
gt: new Date(),
},
},
select: {
email: true,
},
});
try {
const resetPasswordRequest = await prisma.resetPasswordRequest.findUniqueOrThrow({
where: {
id,
},
select: {
id: true,
expires: true,
},
});
return {
props: {
resetPasswordRequest: {
...resetPasswordRequest,
expires: resetPasswordRequest.expires.toString(),
},
id,
csrfToken: await getCsrfToken({ req: context.req }),
...(await serverSideTranslations(context.locale || "en", ["common"])),
},
};
} catch (reason) {
return {
notFound: true,
};
resetPasswordRequest &&
(await prisma.user.findUniqueOrThrow({ where: { email: resetPasswordRequest.email } }));
} catch (e) {
resetPasswordRequest = null;
}
return {
props: {
isRequestExpired: !resetPasswordRequest,
requestId: id,
csrfToken: await getCsrfToken({ req: context.req }),
...(await serverSideTranslations(context.locale || "en", ["common"])),
},
};
}
@@ -15,7 +15,7 @@ import PageWrapper from "@components/PageWrapper";
import AuthContainer from "@components/ui/AuthContainer";
export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const { t, i18n } = useLocale();
const { t } = useLocale();
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<{ message: string } | null>(null);
const [success, setSuccess] = React.useState(false);
@@ -31,7 +31,7 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email: email, language: i18n.language }),
body: JSON.stringify({ email }),
headers: {
"Content-Type": "application/json",
},
+1 -1
View File
@@ -69,7 +69,7 @@ export default function Login({
const errorMessages: { [key: string]: string } = {
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
// Don't leak information about whether an email is registered or not
[ErrorCode.IncorrectUsernamePassword]: t("incorrect_username_password"),
[ErrorCode.IncorrectEmailPassword]: t("incorrect_email_password"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
+6 -5
View File
@@ -3,11 +3,11 @@ import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
import Schedule from "@calcom/features/schedules/components/Schedule";
import Shell from "@calcom/features/shell/Shell";
import { availabilityAsString } from "@calcom/lib/availability";
import { yyyymmdd } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
@@ -53,9 +53,10 @@ type AvailabilityFormValues = {
};
const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
const { remove, append, update, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
const { remove, append, replace, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
name: "dateOverrides",
});
const excludedDates = fields.map((field) => dayjs(field.ranges[0].start).utc().format("YYYY-MM-DD"));
const { t } = useLocale();
return (
<div className="p-6">
@@ -70,15 +71,15 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
<p className="text-subtle mb-4 text-sm">{t("date_overrides_subtitle")}</p>
<div className="space-y-2">
<DateOverrideList
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
excludedDates={excludedDates}
remove={remove}
update={update}
replace={replace}
items={fields}
workingHours={workingHours}
/>
<DateOverrideInputDialog
workingHours={workingHours}
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
excludedDates={excludedDates}
onChange={(ranges) => append({ ranges })}
Trigger={
<Button color="secondary" StartIcon={Plus} data-testid="add-override">
+1
View File
@@ -95,6 +95,7 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
Icon={Clock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
className="w-full"
buttonRaw={<NewScheduleButton />}
/>
</div>
+2 -2
View File
@@ -185,7 +185,7 @@ export default function Success(props: SuccessProps) {
// - It's a paid event and payment is pending.
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING && eventType.requiresConfirmation;
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
const isLoggedIn = session?.user;
const isCancelled =
status === "CANCELLED" ||
status === "REJECTED" ||
@@ -310,7 +310,7 @@ export default function Success(props: SuccessProps) {
status={status}
/>
)}
{userIsOwner && !isEmbed && (
{isLoggedIn && !isEmbed && (
<div className="-mb-4 ml-4 mt-2">
<Link
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
+90 -127
View File
@@ -1,42 +1,50 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
import { ssrInit } from "@server/lib/ssr";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export type DynamicAvailabilityPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type(props: DynamicAvailabilityPageProps) {
return <AvailabilityPage {...props} />;
export default function Type({ slug, user, booking, away, isBrandingHidden, isTeamEvent }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent={isTeamEvent}
/>
</main>
);
}
Type.isBookingPage = true;
Type.PageWrapper = PageWrapper;
const querySchema = z.object({
link: z.string().optional().default(""),
slug: z.string().optional().default(""),
date: z.union([z.string(), z.null()]).optional().default(null),
});
async function getUserPageProps(context: GetServerSidePropsContext) {
const { link, slug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { link, slug, date } = querySchema.parse(context.query);
const hashedLink = await prisma.hashedLink.findUnique({
where: {
@@ -45,132 +53,87 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: {
eventTypeId: true,
eventType: {
select: availiblityPageEventTypeSelect,
},
},
});
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
if (!userId)
return {
notFound: true,
} as {
notFound: true;
};
if (hashedLink?.eventType.slug !== slug)
return {
notFound: true,
} as {
notFound: true;
};
const users = await prisma.user.findMany({
where: {
id: userId,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
startTime: true,
endTime: true,
timeZone: true,
weekStart: true,
availability: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
defaultScheduleId: true,
allowDynamicBooking: true,
away: true,
schedules: {
select: {
availability: true,
timeZone: true,
id: true,
users: {
select: {
username: true,
},
},
team: {
select: {
id: true,
},
},
},
},
theme: true,
},
});
if (!users || !users.length) {
const username = hashedLink?.eventType.users[0]?.username;
if (!hashedLink || !username) {
return {
notFound: true,
} as {
notFound: true;
};
}
const locations = hashedLink.eventType.locations
? (hashedLink.eventType.locations as LocationObject[])
: [];
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
metadata: EventTypeMetaDataSchema.parse(hashedLink.eventType.metadata || {}),
recurringEvent: parseRecurringEvent(hashedLink.eventType.recurringEvent),
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
slug,
locations: privacyFilteredLocations(locations),
users: users.map((u) => ({
name: u.name,
username: u.username,
hideBranding: u.hideBranding,
timeZone: u.timeZone,
})),
descriptionAsSafeHTML: markdownToSafeHTML(hashedLink.eventType.description),
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
away: true,
hideBranding: true,
},
});
const [user] = users;
if (!user) {
return {
notFound: true,
};
}
const schedule = {
...user.schedules.filter(
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
)[0],
};
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`);
}
const timeZone = schedule.timeZone || user.timeZone;
const isTeamEvent = !!hashedLink.eventType?.team?.id;
const workingHours = getWorkingHours(
{
timeZone,
},
schedule.availability || user.availability
);
eventTypeObject.schedule = null;
eventTypeObject.availability = [];
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent });
const booking: GetBookingType | null = null;
const profile = {
name: user.name || user.username,
image: user.avatar,
slug: user.username,
theme: user.theme,
weekStart: user.weekStart,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
};
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
away: user.away,
themeBasis: user.username,
isDynamicGroup: false,
profile,
date,
eventType: eventTypeObject,
workingHours,
trpcState: ssr.dehydrate(),
previousPage: context.req.headers.referer ?? null,
booking,
users: [user.username],
isBrandingHidden: user.hideBranding,
away: user?.away,
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
// Sending the team event from the server, because this template file
// is reused for both team and user events.
isTeamEvent,
},
};
}
const paramsSchema = z.object({ link: z.string(), slug: z.string().transform((s) => slugify(s)) });
// Booker page fetches a tiny bit of data server side, to determine early
// whether the page should show an away state or dynamic booking not allowed.
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
return await getUserPageProps(context);
};
-7
View File
@@ -1,7 +0,0 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[slug]";
export { default } from "../[slug]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
-137
View File
@@ -1,137 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { bookEventTypeSelect } from "@calcom/prisma/selects";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import BookingPage from "@components/booking/pages/BookingPage";
import { ssrInit } from "@server/lib/ssr";
export type HashLinkPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Book(props: HashLinkPageProps) {
return <BookingPage {...props} />;
}
Book.isBookingPage = true;
Book.PageWrapper = PageWrapper;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const link = asStringOrThrow(context.query.link as string);
const recurringEventCountQuery = asStringOrNull(context.query.count);
const hashedLink = await prisma.hashedLink.findUnique({
where: {
link,
},
select: {
eventTypeId: true,
eventType: {
select: bookEventTypeSelect,
},
},
});
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
if (!userId)
return {
notFound: true,
};
const users = await prisma.user.findMany({
where: {
id: userId,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
},
});
if (!users.length) return { notFound: true };
const [user] = users;
const eventTypeRaw = hashedLink?.eventType;
if (!eventTypeRaw) return { notFound: true };
const eventType = {
...eventTypeRaw,
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
};
const eventTypeObject = [eventType].map((e) => {
return {
...e,
periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
customInputs: customInputSchema.array().parse(e.customInputs || []),
schedulingType: null,
users: users.map((u) => ({
id: u.id,
name: u.name,
username: u.username,
avatar: u.avatar,
image: u.avatar,
slug: u.username,
theme: u.theme,
email: u.email,
brandColor: u.brandColor,
darkBrandColor: u.darkBrandColor,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
const profile = {
name: user.name || user.username,
image: user.avatar,
slug: user.username,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
eventName: null,
};
// Checking if number of recurring event ocurrances is valid against event type configuration
const recurringEventCount =
(eventTypeObject.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count
? parseInt(recurringEventCountQuery)
: eventType.recurringEvent?.count)) ||
null;
return {
props: {
profile,
themeBasis: user.username,
eventType: eventTypeObject,
booking: null,
currentSlotBooking: null,
trpcState: ssr.dehydrate(),
recurringEventCount,
isDynamicGroupBooking: false,
hasHashedBookingLink: true,
hashedLink: link,
isEmbed: typeof context.query.embed === "string",
},
};
}
+2 -2
View File
@@ -503,7 +503,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
</Form>
</EventTypeSingleLayout>
{slugExistsChildrenDialogOpen.length && (
{slugExistsChildrenDialogOpen.length ? (
<ManagedEventTypeDialog
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
isLoading={formMethods.formState.isSubmitting}
@@ -518,7 +518,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
setSlugExistsChildrenDialogOpen([]);
}}
/>
)}
) : null}
</>
);
};
+10 -5
View File
@@ -103,11 +103,11 @@ const querySchema = z.object({
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
const { eventTypeGroups } = props;
const orgBranding = useOrgBrandingValues();
const tabs = eventTypeGroups.map((item) => ({
name: item.profile.name ?? "",
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types",
avatar: item.profile.image ?? `${WEBAPP_URL}/${item.profile.slug}/avatar.png`,
avatar: item.profile.image ?? `${orgBranding?.fullDomain ?? WEBAPP_URL}/${item.profile.slug}/avatar.png`,
}));
const { data } = useTypedQuery(querySchema);
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
@@ -404,7 +404,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
items={type.users.map(
(organizer: { name: string | null; username: string | null }) => ({
alt: organizer.name || "",
image: `${WEBAPP_URL}/${organizer.username}/avatar.png`,
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
organizer.username
}/avatar.png`,
title: organizer.name || "",
})
)}
@@ -419,7 +421,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
.flatMap((ch) => ch.users)
.map((user: Pick<User, "name" | "username">) => ({
alt: user.name || "",
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${user.username}/avatar.png`,
title: user.name || "",
}))}
/>
@@ -714,7 +716,10 @@ const EventTypeListHeading = ({
<Avatar
alt={profile?.name || ""}
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
imageSrc={`${WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
imageSrc={
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${teamId ? "team/" : ""}${profile.slug}/avatar.png` ||
undefined
}
size="md"
className="mt-1 inline-flex justify-center"
/>
-164
View File
@@ -1,164 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { classNames } from "@calcom/lib";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
return (
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
/>
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
isAway={away}
hideBranding={isBrandingHidden}
/>
</main>
);
}
Type.PageWrapper = PageWrapper;
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const users = await prisma.user.findMany({
where: {
username: {
in: usernames,
},
},
select: {
allowDynamicBooking: true,
},
});
if (!users.length) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username: usernames.join("+"), eventSlug: slug });
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
booking,
user: usernames.join("+"),
slug,
away: false,
trpcState: ssr.dehydrate(),
isBrandingHidden: false,
themeBasis: null,
},
};
}
async function getUserPageProps(context: GetServerSidePropsContext) {
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0];
const { rescheduleUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
away: true,
hideBranding: true,
},
});
if (!user) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug });
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
booking,
away: user?.away,
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
themeBasis: username,
},
};
}
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
user: z.string().transform((s) => getUsernameList(s)),
});
// Booker page fetches a tiny bit of data server side, to determine early
// whether the page should show an away state or dynamic booking not allowed.
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { user } = paramsSchema.parse(context.params);
const isDynamicGroup = user.length > 1;
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
};
@@ -1,11 +0,0 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
export { default } from "../[type]";
// Somehow these types don't accept the {notFound: true} return type.
// Probably still need to fix this. I don't know why this isn't allowed yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
@@ -1,7 +0,0 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../../[user]";
export { default } from "../../[user]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
@@ -1,139 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away, isBrandingHidden, isTeamEvent }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
/>
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent={isTeamEvent}
/>
</main>
);
}
Type.PageWrapper = PageWrapper;
async function getUserPageProps(context: GetServerSidePropsContext) {
const { link, slug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const hashedLink = await prisma.hashedLink.findUnique({
where: {
link,
},
select: {
eventTypeId: true,
eventType: {
select: {
users: {
select: {
username: true,
},
},
team: {
select: {
id: true,
},
},
},
},
},
});
const username = hashedLink?.eventType.users[0]?.username;
if (!hashedLink || !username) {
return {
notFound: true,
};
}
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
away: true,
hideBranding: true,
},
});
if (!user) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
const isTeamEvent = !!hashedLink.eventType?.team?.id;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent });
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
booking,
away: user?.away,
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
// Sending the team event from the server, because this template file
// is reused for both team and user events.
isTeamEvent,
},
};
}
const paramsSchema = z.object({ link: z.string(), slug: z.string().transform((s) => slugify(s)) });
// Booker page fetches a tiny bit of data server side, to determine early
// whether the page should show an away state or dynamic booking not allowed.
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
return await getUserPageProps(context);
};
@@ -1,104 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { classNames } from "@calcom/lib";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
return (
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
isTeamEvent
/>
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
/>
</main>
);
}
Type.PageWrapper = PageWrapper;
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
slug: z.string().transform((s) => slugify(s)),
});
// Booker page fetches a tiny bit of data server side:
// 1. Check if team exists, to show 404
// 2. If rescheduling, get the booking details
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const team = await prisma.team.findFirst({
where: {
slug: teamSlug,
},
select: {
id: true,
hideBranding: true,
},
});
if (!team) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
});
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
booking,
away: false,
user: teamSlug,
teamId: team.id,
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
themeBasis: null,
},
};
};
@@ -1,9 +0,0 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
export { default } from "../[type]";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
@@ -1,7 +0,0 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../../../team/[slug]";
export { default } from "../../../team/[slug]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
@@ -4,10 +4,10 @@ import prisma from "@calcom/prisma";
import PageWrapper from "@components/PageWrapper";
import type { PageProps as UserTypePageProps } from "../../../new-booker/[user]/[type]";
import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../new-booker/[user]/[type]";
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../new-booker/team/[slug]/[type]";
import type { PageProps as TeamTypePageProps } from "../../../new-booker/team/[slug]/[type]";
import type { PageProps as UserTypePageProps } from "../../../[user]/[type]";
import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../[user]/[type]";
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]";
import type { PageProps as TeamTypePageProps } from "../../../team/[slug]/[type]";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const team = await prisma.team.findFirst({
@@ -6,7 +6,7 @@ import PageWrapper from "@components/PageWrapper";
import type { UserPageProps } from "../../../[user]";
import UserPage, { getServerSideProps as GSSUserPage } from "../../../[user]";
import type { TeamPageProps } from "../../../team/[slug]";
import type { PageProps as TeamPageProps } from "../../../team/[slug]";
import TeamPage, { getServerSideProps as GSSTeamPage } from "../../../team/[slug]";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
+8 -11
View File
@@ -12,14 +12,11 @@ export default function Type() {
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { uid: bookingId } = z
const { uid: bookingId, seatReferenceUid } = z
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
.parse(context.query);
let seatReferenceUid;
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
if (uid) {
seatReferenceUid = bookingId;
}
const booking = await prisma.booking.findUnique({
where: {
uid,
@@ -39,6 +36,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: true,
},
},
seatsPerTimeSlot: true,
},
},
dynamicEventSlugRef: true,
@@ -74,15 +72,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
"/" +
eventType?.slug;
const destinationUrl = new URLSearchParams();
if (seatReferenceUid) {
destinationUrl.set("rescheduleUid", seatReferenceUid);
} else {
destinationUrl.set("rescheduleUid", bookingId);
}
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
return {
redirect: {
destination: `/${eventPage}?${destinationUrl.toString()}`,
destination: `/${eventPage}?${destinationUrl.toString()}${
eventType.seatsPerTimeSlot ? "&bookingUid=null" : ""
}`,
permanent: false,
},
};
+2 -3
View File
@@ -6,7 +6,6 @@ import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
@@ -83,7 +82,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
};
return (
<LicenseRequired>
<>
<div
className="bg-muted flex min-h-screen flex-col justify-center "
style={
@@ -166,7 +165,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
</div>
</div>
</div>
</LicenseRequired>
</>
);
}
+16 -20
View File
@@ -7,7 +7,6 @@ import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
@@ -28,9 +27,9 @@ import Team from "@components/team/screens/Team";
import { ssrInit } from "@server/lib/ssr";
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
export type PageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: TeamPageProps) {
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: PageProps) {
useTheme(team.theme);
const showMembers = useToggleQuery("members");
const { t } = useLocale();
@@ -65,7 +64,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
}
// slug is a route parameter, we don't want to forward it to the next route
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = router.query;
const { slug: _slug, ...queryParamsToForward } = router.query;
const EventTypes = () => (
<ul className="border-subtle rounded-md border">
@@ -103,7 +102,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
items={type.users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: CAL_URL + "/" + user.username + "/avatar.png" || "",
image: "/" + user.username + "/avatar.png" || "",
}))}
/>
</div>
@@ -140,7 +139,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
truncateAfter={4}
items={ch.members.map(({ user: member }) => ({
alt: member.name || "",
image: `${WEBAPP_URL}/${member.username}/avatar.png`,
image: `/${member.username}/avatar.png`,
title: member.name || "",
}))}
/>
@@ -187,7 +186,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
{!isBioEmpty && (
<>
<div
className="text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: team.safeBio }}
/>
</>
@@ -197,26 +196,21 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<SubTeams />
) : (
<>
{(showMembers.isOn || !team.eventTypes.length) &&
(team.isPrivate ? (
<div className="w-full text-center">
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
</div>
) : (
<Team team={team} />
))}
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes />
{!(team.hideBookATeamMember || team.isPrivate) && (
{!team.hideBookATeamMember && (
<div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="border-subtle w-full border-t" />
</div>
<div className="relative flex justify-center">
<span className="bg-subtle text-subtle px-2 text-sm">{t("or")}</span>
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
{t("or")}
</span>
</div>
</div>
@@ -289,7 +283,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...type,
users: type.users.map((user) => ({
...user,
avatar: CAL_URL + "/" + user.username + "/avatar.png",
avatar: "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
}));
@@ -302,10 +296,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const markdownStrippedBio = stripMarkdown(team?.bio || "");
const { inviteToken: _inviteToken, ...serializableTeam } = team;
return {
props: {
team: { ...team, safeBio, members },
themeBasis: team.slug,
team: { ...serializableTeam, safeBio, members },
themeBasis: serializableTeam.slug,
trpcState: ssr.dehydrate(),
markdownStrippedBio,
isValidOrgDomain,
+64 -174
View File
@@ -1,214 +1,104 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import getBooking from "@calcom/features/bookings/lib/get-booking";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { classNames } from "@calcom/lib";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { asStringOrNull } from "@lib/asStringOrNull";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
import { ssgInit } from "@server/lib/ssg";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export type AvailabilityTeamPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function TeamType(props: AvailabilityTeamPageProps) {
return <AvailabilityPage {...props} />;
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
return (
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
isTeamEvent
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
/>
</main>
);
}
TeamType.isBookingPage = true;
TeamType.PageWrapper = PageWrapper;
Type.PageWrapper = PageWrapper;
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
slug: z.string().transform((s) => slugify(s)),
});
// Booker page fetches a tiny bit of data server side:
// 1. Check if team exists, to show 404
// 2. If rescheduling, get the booking details
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
const ssg = await ssgInit(context);
if (!slugParam || !typeParam) {
throw new Error(`File is not named [idOrSlug]/[user]`);
}
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const team = await prisma.team.findFirst({
where: {
slug: slugParam,
slug: teamSlug,
},
select: {
id: true,
name: true,
slug: true,
logo: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
eventTypes: {
where: {
slug: typeParam,
},
select: {
id: true,
slug: true,
hidden: true,
hosts: {
select: {
isFixed: true,
user: {
select: {
id: true,
name: true,
username: true,
timeZone: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
},
},
},
},
title: true,
availability: true,
description: true,
length: true,
disableGuests: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
recurringEvent: true,
requiresConfirmation: true,
locations: true,
price: true,
currency: true,
timeZone: true,
slotInterval: true,
metadata: true,
seatsPerTimeSlot: true,
bookingFields: true,
customInputs: true,
schedule: {
select: {
timeZone: true,
availability: true,
},
},
workflows: {
select: {
workflow: {
select: {
id: true,
steps: true,
},
},
},
},
team: {
select: {
members: {
where: {
role: "OWNER",
},
select: {
user: {
select: {
weekStart: true,
},
},
},
},
parent: {
select: {
logo: true,
name: true,
},
},
},
},
},
},
},
});
if (!team || team.eventTypes.length != 1) {
if (!team) {
return {
notFound: true,
} as {
notFound: true;
};
}
const [eventType] = team.eventTypes;
const timeZone = eventType.schedule?.timeZone || eventType.timeZone || undefined;
const workingHours = getWorkingHours(
{
timeZone,
},
eventType.schedule?.availability || eventType.availability
);
eventType.schedule = null;
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
const eventTypeObject = Object.assign({}, eventType, {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations),
users: eventType.hosts.map(({ user: { name, username, hideBranding, timeZone } }) => ({
name,
username,
hideBranding,
timeZone,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
});
eventTypeObject.availability = [];
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid);
booking = await getBookingForReschedule(`${rescheduleUid}`);
}
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
});
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
profile: {
name: team.name || team.slug,
slug: team.slug,
image: team.logo,
theme: team.theme,
weekStart: weekStart ?? "Sunday",
brandColor: team.brandColor,
darkBrandColor: team.darkBrandColor,
},
themeBasis: team.slug,
date: dateParam,
eventType: eventTypeObject,
workingHours,
previousPage: context.req.headers.referer ?? null,
booking,
trpcState: ssg.dehydrate(),
isBrandingHidden: team.hideBranding,
away: false,
user: teamSlug,
teamId: team.id,
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
themeBasis: null,
},
};
};
+101 -4
View File
@@ -1,7 +1,104 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSideProps as _getServerSideProps } from "../[type]";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { classNames } from "@calcom/lib";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
export { default } from "../[type]";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
import PageWrapper from "@components/PageWrapper";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
return (
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
isTeamEvent
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
/>
</main>
);
}
Type.PageWrapper = PageWrapper;
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
slug: z.string().transform((s) => slugify(s)),
});
// Booker page fetches a tiny bit of data server side:
// 1. Check if team exists, to show 404
// 2. If rescheduling, get the booking details
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const team = await prisma.team.findFirst({
where: {
slug: teamSlug,
},
select: {
id: true,
hideBranding: true,
},
});
if (!team) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`);
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
});
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
booking,
away: false,
user: teamSlug,
teamId: team.id,
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
themeBasis: null,
},
};
};
-173
View File
@@ -1,173 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import getBooking from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import BookingPage from "@components/booking/pages/BookingPage";
import { ssrInit } from "@server/lib/ssr";
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
export default function TeamBookingPage(props: TeamBookingPageProps) {
return <BookingPage {...props} />;
}
TeamBookingPage.isBookingPage = true;
TeamBookingPage.PageWrapper = PageWrapper;
const querySchema = z.object({
rescheduleUid: z.string().optional(),
bookingUid: z.string().optional(),
});
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
const recurringEventCountQuery = asStringOrNull(context.query.count);
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
return {
notFound: true,
} as const;
}
const eventTypeRaw = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
locations: true,
customInputs: true,
periodType: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
recurringEvent: true,
requiresConfirmation: true,
disableGuests: true,
price: true,
currency: true,
metadata: true,
seatsPerTimeSlot: true,
schedulingType: true,
bookingFields: true,
successRedirectUrl: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
team: {
select: {
slug: true,
name: true,
logo: true,
theme: true,
brandColor: true,
darkBrandColor: true,
parent: {
select: {
logo: true,
name: true,
},
},
},
},
users: {
select: {
id: true,
username: true,
avatar: true,
name: true,
},
},
},
});
if (!eventTypeRaw) return { notFound: true };
const eventType = {
...eventTypeRaw,
//TODO: Use zodSchema to verify it instead of using Type Assertion
locations: privacyFilteredLocations((eventTypeRaw.locations || []) as LocationObject[]),
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
};
const eventTypeObject = [eventType].map((e) => {
return {
...e,
metadata: EventTypeMetaDataSchema.parse(e.metadata || {}),
bookingFields: getBookingFieldsWithSystemFields(eventType),
periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
customInputs: customInputSchema.array().parse(e.customInputs || []),
users: eventType.users.map((u) => ({
id: u.id,
name: u.name,
username: u.username,
avatar: u.avatar,
image: u.avatar,
slug: u.username,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
let booking: GetBookingType | null = null;
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
if (rescheduleUid || bookingUid) {
booking = await getBooking(prisma, rescheduleUid || bookingUid || "");
}
// Checking if number of recurring event ocurrances is valid against event type configuration
const recurringEventCount =
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
? parseInt(recurringEventCountQuery)
: eventType.recurringEvent.count)) ||
null;
return {
props: {
trpcState: ssr.dehydrate(),
profile: {
...eventTypeObject.team,
// FIXME: This slug is used as username on success page which is wrong. This is correctly set as username for user booking.
slug: "team/" + eventTypeObject.slug,
image: eventTypeObject.team?.logo || null,
eventName: null,
},
themeBasis: eventTypeObject.team?.slug,
eventType: eventTypeObject,
recurringEventCount,
booking,
currentSlotBooking: null,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,
hashedLink: null,
isEmbed: typeof context.query.embed === "string",
},
};
}
+2 -2
View File
@@ -1,7 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[slug]";
import { getServerSideProps as _getServerSideProps } from "../../team/[slug]";
export { default } from "../[slug]";
export { default } from "../../team/[slug]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+20 -14
View File
@@ -1,41 +1,47 @@
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import type { GetServerSidePropsContext } from "next";
import { TeamsListing } from "@calcom/features/ee/teams/components";
import Shell from "@calcom/features/shell/Shell";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
function Teams() {
const { t } = useLocale();
const [user] = trpc.viewer.me.useSuspenseQuery();
return (
<Shell
heading={t("teams")}
hideHeadingOnMobile
subtitle={t("create_manage_teams_collaborative")}
CTA={
<Button
variant="fab"
StartIcon={Plus}
type="button"
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
{t("new")}
</Button>
(!user.organizationId || user.organization.isOrgAdmin) && (
<Button
variant="fab"
StartIcon={Plus}
type="button"
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
{t("new")}
</Button>
)
}>
<TeamsListing />
</Shell>
);
}
export const getStaticProps = async () => {
return {
props: {
...(await serverSideTranslations("en", ["common"])),
},
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
return { props: { trpcState: ssr.dehydrate() } };
};
Teams.requiresLicense = false;
-8
View File
@@ -22,14 +22,6 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
const afterFilesRewriteExcludePages = pages;
exports.userTypeRoutePath = `/:user((?!${afterFilesRewriteExcludePages.join(
"/|"
)})[^/]*)/:type((?!book$)[^/]+)`;
exports.teamTypeRoutePath = "/team/:slug/:type((?!book$)[^/]+)";
exports.privateLinkRoutePath = "/d/:link/:slug((?!book$)[^/]+)";
exports.embedUserTypeRoutePath = `/:user((?!${afterFilesRewriteExcludePages.join("/|")})[^/]*)/:type/embed`;
exports.embedTeamTypeRoutePath = "/team/:slug/:type/embed";
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
));
+68 -24
View File
@@ -1,49 +1,93 @@
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import prisma from "@calcom/prisma";
import { test } from "../lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
test("Can reset forgotten password", async ({ page, users }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_IS_E2E !== "1", "It shouldn't if we can't skip email");
const user = await users.create();
const newPassword = `${user.username}-123`;
// Got to reset password flow
await page.goto("/auth/forgot-password");
await page.waitForSelector("text=Forgot Password");
// Fill [placeholder="john.doe@example.com"]
await page.fill('input[name="email"]', `${user.username}@example.com`);
await page.press('input[name="email"]', "Enter");
// Press Enter
await Promise.all([
page.waitForURL((u) => u.pathname.startsWith("/auth/forgot-password")),
page.press('input[name="email"]', "Enter"),
]);
// wait for confirm page.
await page.waitForSelector("text=Reset link sent");
// As a workaround, we query the db for the last created password request
// there should be one, otherwise we throw
const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({
where: {
email: `${user.username}@example.com`,
},
select: {
id: true,
},
orderBy: {
createdAt: "desc",
},
});
// Test when a user changes his email after starting the password reset flow
await prisma.user.update({
where: {
email: `${user.username}@example.com`,
},
data: {
email: `${user.username}-2@example.com`,
},
});
await page.goto(`/auth/forgot-password/${id}`);
await page.waitForSelector("text=That request is expired.");
// Change the email back to continue testing.
await prisma.user.update({
where: {
email: `${user.username}-2@example.com`,
},
data: {
email: `${user.username}@example.com`,
},
});
await page.goto(`/auth/forgot-password/${id}`);
const newPassword = `${user.username}-123CAL-${uuid().toString()}`; // To match the password policy
// Wait for page to fully load
await page.waitForSelector("text=Reset Password");
// Fill input[name="password"]
await page.fill('input[name="password"]', newPassword);
// Click text=Submit
await page.fill('input[name="new_password"]', newPassword);
await page.click('button[type="submit"]');
await page.waitForSelector("text=Password updated");
await expect(page.locator(`text=Password updated`)).toBeVisible();
// Click button:has-text("Login")
await Promise.all([
page.waitForURL((u) => u.pathname.startsWith("/auth/login")),
page.click('a:has-text("Login")'),
]);
// now we check our DB to confirm the password was indeed updated.
// we're not logging in to the UI to speed up test performance.
const updatedUser = await prisma.user.findUniqueOrThrow({
where: {
email: `${user.username}@example.com`,
},
select: {
id: true,
password: true,
},
});
// Fill input[name="email"]
await page.fill('input[name="email"]', `${user.username}@example.com`);
await page.fill('input[name="password"]', newPassword);
await page.press('input[name="password"]', "Enter");
await page.waitForSelector("[data-testid=dashboard-shell]");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
await expect(page.locator("[data-testid=dashboard-shell]")).toBeVisible();
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
await page.goto(`/auth/forgot-password/${id}`);
await page.waitForSelector("text=That request is expired.");
});
+70 -21
View File
@@ -1,13 +1,11 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import {
bookFirstEvent,
bookOptinEvent,
bookTimeSlot,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
testEmail,
testName,
} from "./lib/testUtils";
@@ -15,7 +13,7 @@ import {
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users }) => users.deleteAll());
testBothBookers.describe("free user", (bookerVariant) => {
test.describe("free user", () => {
test.beforeEach(async ({ page, users }) => {
const free = await users.create();
await page.goto(`/${free.username}`);
@@ -27,23 +25,11 @@ testBothBookers.describe("free user", (bookerVariant) => {
await selectFirstAvailableTimeSlotNextMonth(page);
// Kept in if statement here, since it's only temporary
// until the old booker isn't used anymore, and I wanted
// to change the test as little as possible.
// eslint-disable-next-line playwright/no-conditional-in-test
if (bookerVariant !== "new-booker") {
// Navigate to book page
await page.waitForURL((url) => {
return url.pathname.endsWith("/book");
});
}
await bookTimeSlot(page);
// save booking url
const bookingUrl: string = page.url();
// book same time spot twice
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
@@ -53,12 +39,11 @@ testBothBookers.describe("free user", (bookerVariant) => {
// book same time spot again
await bookTimeSlot(page);
// check for error message
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible();
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible({ timeout: 1000 });
});
});
testBothBookers.describe("pro user", () => {
test.describe("pro user", () => {
test.beforeEach(async ({ page, users }) => {
const pro = await users.create();
await page.goto(`/${pro.username}`);
@@ -87,8 +72,8 @@ testBothBookers.describe("pro user", () => {
const bookingId = url.searchParams.get("rescheduleUid");
return !!bookingId;
});
await selectSecondAvailableTimeSlotNextMonth(page);
// --- fill form
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
@@ -163,4 +148,68 @@ testBothBookers.describe("pro user", () => {
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
});
});
test("Time slots should be reserved when selected", async ({ context, page }) => {
await page.click('[data-testid="event-type-link"]');
const initialUrl = page.url();
await selectFirstAvailableTimeSlotNextMonth(page);
const pageTwo = await context.newPage();
await pageTwo.goto(initialUrl);
await pageTwo.waitForURL(initialUrl);
await pageTwo.waitForSelector('[data-testid="event-type-link"]');
const eventTypeLink = pageTwo.locator('[data-testid="event-type-link"]').first();
await eventTypeLink.click();
await pageTwo.waitForLoadState("networkidle");
await pageTwo.locator('[data-testid="incrementMonth"]').waitFor();
await pageTwo.click('[data-testid="incrementMonth"]');
await pageTwo.waitForLoadState("networkidle");
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor();
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
// 9:30 should be the first available time slot
await pageTwo.locator('[data-testid="time"]').nth(0).waitFor();
const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0);
// Find text inside the element
const firstSlotAvailableText = await firstSlotAvailable.innerText();
expect(firstSlotAvailableText).toContain("9:30");
});
test("Time slots are not reserved when going back via Cancel button on Event Form", async ({
context,
page,
}) => {
const initialUrl = page.url();
await page.waitForSelector('[data-testid="event-type-link"]');
const eventTypeLink = page.locator('[data-testid="event-type-link"]').first();
await eventTypeLink.click();
await selectFirstAvailableTimeSlotNextMonth(page);
const pageTwo = await context.newPage();
await pageTwo.goto(initialUrl);
await pageTwo.waitForURL(initialUrl);
await pageTwo.waitForSelector('[data-testid="event-type-link"]');
const eventTypeLinkTwo = pageTwo.locator('[data-testid="event-type-link"]').first();
await eventTypeLinkTwo.click();
await page.locator('[data-testid="back"]').waitFor();
await page.click('[data-testid="back"]');
await pageTwo.waitForLoadState("networkidle");
await pageTwo.locator('[data-testid="incrementMonth"]').waitFor();
await pageTwo.click('[data-testid="incrementMonth"]');
await pageTwo.waitForLoadState("networkidle");
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor();
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
await pageTwo.locator('[data-testid="time"]').nth(0).waitFor();
const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0);
// Find text inside the element
const firstSlotAvailableText = await firstSlotAvailable.innerText();
expect(firstSlotAvailableText).toContain("9:00");
});
});
+48 -29
View File
@@ -8,7 +8,6 @@ import { BookingStatus } from "@calcom/prisma/enums";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import {
bookTimeSlot,
createNewSeatedEventType,
@@ -56,7 +55,7 @@ async function createUserWithSeatedEventAndAttendees(
return { user, eventType, booking };
}
testBothBookers.describe("Booking with Seats", (bookerVariant) => {
test.describe("Booking with Seats", () => {
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
const user = await users.create({ name: "Seated event" });
await user.apiLogin();
@@ -83,34 +82,56 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
],
});
await page.goto(`/${user.username}/${slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// Kept in if statement here, since it's only temporary
// until the old booker isn't used anymore, and I wanted
// to change the test as little as possible.
// eslint-disable-next-line playwright/no-conditional-in-test
if (bookerVariant === "old-booker") {
await page.waitForURL((url) => {
return url.pathname.endsWith("/book");
});
}
let bookingUrl = "";
const bookingUrl = page.url();
await test.step("Attendee #1 can book a seated event time slot", async () => {
await page.goto(bookingUrl);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
await test.step("Attendee #2 can book the same seated event time slot", async () => {
await page.goto(bookingUrl);
await page.goto(`/${user.username}/${slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForURL(/bookingUid/);
bookingUrl = page.url();
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
await test.step("Attendee #3 cannot book the same seated event time slot", async () => {
await test.step("Attendee #3 cannot click on the same seated event time slot", async () => {
await page.goto(`/${user.username}/${slug}`);
await page.click('[data-testid="incrementMonth"]');
// TODO: Find out why the first day is always booked on tests
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
await expect(page.locator('[data-testid="time"][data-disabled="true"]')).toBeVisible();
});
await test.step("Attendee #3 cannot book the same seated event time slot accessing via url", async () => {
await page.goto(bookingUrl);
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
});
await test.step("User owner should have only 1 booking with 3 attendees", async () => {
// Make sure user owner has only 1 booking with 3 attendees
const bookings = await prisma.booking.findMany({
where: { eventTypeId: user.eventTypes.find((e) => e.slug === slug)?.id },
select: {
id: true,
attendees: {
select: {
id: true,
},
},
},
});
expect(bookings).toHaveLength(1);
expect(bookings[0].attendees).toHaveLength(2);
});
});
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
@@ -145,7 +166,7 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
await page.locator('[data-testid="confirm_cancel"]').click();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
await expect(page).toHaveURL(/\/booking\/.*/);
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
@@ -169,9 +190,8 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
await page.locator('[data-testid="cancel"]').click();
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
await page.locator('[data-testid="confirm_cancel"]').click();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
await expect(page).toHaveURL(/\/booking\/.*/);
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
@@ -187,7 +207,7 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
});
});
testBothBookers.describe("Reschedule for booking with seats", () => {
test.describe("Reschedule for booking with seats", () => {
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: `first+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
@@ -227,9 +247,10 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForLoadState("networkidle");
// should wait for URL but that path starts with booking/
await page.waitForURL(/\/booking\/.*/);
await expect(page).toHaveURL(/.*booking/);
await expect(page).toHaveURL(/\/booking\/.*/);
// Should expect new booking to be created for John Third
const newBooking = await prisma.booking.findFirst({
@@ -292,7 +313,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForURL(/.*booking/);
await page.waitForURL(/\/booking\/.*/);
await page.goto(`/reschedule/${references[1].referenceUid}`);
@@ -301,7 +322,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
// Using waitForUrl here fails the assertion `expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);` probably because waitForUrl is considered complete before waitForNavigation and till that time the booking is not cancelled
await page.waitForNavigation({ url: /.*booking/ });
await page.waitForURL(/\/booking\/.*/);
// Should expect old booking to be cancelled
const oldBooking = await prisma.booking.findFirst({
@@ -351,9 +372,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
await page.locator('[data-testid="confirm_cancel"]').click();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
await expect(page).toHaveURL(/\/booking\/.*/);
// Should expect old booking to be cancelled
const updatedBooking = await prisma.booking.findFirst({
@@ -457,7 +476,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
await expect(page).toHaveURL(/\/booking\/.*/);
await page.goto(
`/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[1].referenceUid}`
@@ -468,7 +487,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
await expect(page).toHaveURL(/\/booking\/.*/);
});
test("Should book with seats and hide attendees info from showAttendees true", async ({
+1 -13
View File
@@ -4,13 +4,12 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Event Types tests", () => {
testBothBookers.describe("user", (bookerVariant) => {
test.describe("user", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
@@ -143,17 +142,6 @@ test.describe("Event Types tests", () => {
await selectFirstAvailableTimeSlotNextMonth(page);
// Navigate to book page
// Kept in if statement here, since it's only temporary
// until the old booker isn't used anymore, and I wanted
// to change the test as little as possible.
// eslint-disable-next-line playwright/no-conditional-in-test
if (bookerVariant === "old-booker") {
await page.waitForURL((url) => {
return url.pathname.endsWith("/book");
});
}
for (const location of locationData) {
await page.locator(`span:has-text("${location}")`).click();
}
+7 -1
View File
@@ -284,6 +284,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
setupEventWithPrice(eventType, store.page),
bookAndPaidEvent: async (eventType: Pick<Prisma.EventType, "slug">) =>
bookAndPaidEvent(user, eventType, store.page),
makePaymentUsingStripe: async () => makePaymentUsingStripe(store.page),
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
debug: async (message: string | Record<string, JSONValue>) => {
await prisma.user.update({
@@ -398,7 +399,12 @@ export async function bookAndPaidEvent(
await Promise.all([page.waitForURL("/payment/*"), page.press('[name="email"]', "Enter")]);
const stripeFrame = page.frameLocator("iframe").first();
await makePaymentUsingStripe(page);
}
export async function makePaymentUsingStripe(page: Page) {
const stripeElement = await page.locator(".StripeElement").first();
const stripeFrame = stripeElement.frameLocator("iframe").first();
await stripeFrame.locator('[name="number"]').fill("4242 4242 4242 4242");
const now = new Date();
await stripeFrame.locator('[name="expiry"]').fill(`${now.getMonth()} / ${now.getFullYear() + 1}`);
+23 -1
View File
@@ -69,9 +69,31 @@ test.describe("Stripe integration", () => {
await expect(page.getByText("Unconfirmed")).toBeVisible();
await expect(page.getByText("Pending payment").last()).toBeVisible();
});
test("Paid booking should be able to be rescheduled", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.setupEventWithPrice(eventType);
await user.bookAndPaidEvent(eventType);
// Rescheduling the event
await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]);
await selectFirstAvailableTimeSlotNextMonth(page);
await Promise.all([
page.waitForURL("/payment/*"),
page.click('[data-testid="confirm-reschedule-button"]'),
]);
await user.makePaymentUsingStripe();
});
todo("Payment should confirm pending payment booking");
todo("Payment should trigger a BOOKING_PAID webhook");
todo("Paid booking should be able to be rescheduled");
todo("Paid booking should be able to be cancelled");
todo("Cancelled paid booking should be refunded");
});
-31
View File
@@ -1,31 +0,0 @@
import { test } from "./fixtures";
export type BookerVariants = "new-booker" | "old-booker";
const bookerVariants = ["new-booker", "old-booker"];
/**
* Small wrapper around test.describe().
* When using testbothBookers.describe() instead of test.describe(), this will run the specified
* tests twice. One with the old booker, and one with the new booker. It will also add the booker variant
* name to the test name for easier debugging.
* Finally it also adds a parameter bookerVariant to your testBothBooker.describe() callback, which
* can be used to do any conditional rendering in the test for a specific booker variant (should be as little
* as possible).
*
* See apps/web/playwright/booking-pages.e2e.ts for an example.
*/
export const testBothBookers = {
describe: (testName: string, testFn: (bookerVariant: BookerVariants) => void) => {
bookerVariants.forEach((bookerVariant) => {
test.describe(`${testName} -- ${bookerVariant}`, () => {
if (bookerVariant === "new-booker") {
test.beforeEach(({ context }) => {
context.addCookies([{ name: "new-booker-enabled", value: "true", url: "http://localhost:3000" }]);
});
}
testFn(bookerVariant as BookerVariants);
});
});
},
};
+7 -24
View File
@@ -79,38 +79,21 @@ export async function waitFor(fn: () => Promise<unknown> | unknown, opts: { time
export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
// Let current month dates fully render.
// There is a bug where if we don't let current month fully render and quickly click go to next month, current month get's rendered
// This doesn't seem to be replicable with the speed of a person, only during automation.
// It would also allow correct snapshot to be taken for current month.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await page.click('[data-testid="incrementMonth"]');
// @TODO: Find a better way to make test wait for full month change render to end
// so it can click up on the right day, also when resolve remove other todos
// Waiting for full month increment
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
// TODO: Find out why the first day is always booked on tests
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
await page.locator('[data-testid="time"][data-disabled="false"]').nth(0).click();
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
await page.locator('[data-testid="time"]').nth(0).click();
}
export async function selectSecondAvailableTimeSlotNextMonth(page: Page) {
// Let current month dates fully render.
// There is a bug where if we don't let current month fully render and quickly click go to next month, current month get's rendered
// This doesn't seem to be replicable with the speed of a person, only during automation.
// It would also allow correct snapshot to be taken for current month.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await page.click('[data-testid="incrementMonth"]');
// @TODO: Find a better way to make test wait for full month change render to end
// so it can click up on the right day, also when resolve remove other todos
// Waiting for full month increment
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
// TODO: Find out why the first day is always booked on tests
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
await page.locator('[data-testid="time"][data-disabled="false"]').nth(1).click();
await page.locator('[data-testid="time"]').nth(0).click();
}
async function bookEventOnThisPage(page: Page) {
+2 -2
View File
@@ -58,7 +58,7 @@ test.describe("Login and logout tests", () => {
test.describe("Login flow validations", async () => {
test("Should warn when user does not exist", async ({ page }) => {
const alertMessage = (await localize("en"))("incorrect_username_password");
const alertMessage = (await localize("en"))("incorrect_email_password");
// Login with a non-existent user
const never = "never";
@@ -69,7 +69,7 @@ test.describe("Login and logout tests", () => {
});
test("Should warn when password is incorrect", async ({ page, users }) => {
const alertMessage = (await localize("en"))("incorrect_username_password");
const alertMessage = (await localize("en"))("incorrect_email_password");
// by default password===username with the users fixture
const pro = await users.create({ username: "pro" });
@@ -7,8 +7,6 @@ import prisma from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import type { BookerVariants } from "./lib/new-booker";
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
async function getLabelText(field: Locator) {
@@ -21,7 +19,7 @@ test.describe("Manage Booking Questions", () => {
await users.deleteAll();
});
testBothBookers.describe("For User EventType", (bookerVariant) => {
test.describe("For User EventType", () => {
test("Do a booking with a user added question and verify a few thing in b/w", async ({
page,
users,
@@ -40,11 +38,11 @@ test.describe("Manage Booking Questions", () => {
await firstEventTypeElement.click();
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant);
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
});
testBothBookers.describe("For Team EventType", (bookerVariant) => {
test.describe("For Team EventType", () => {
test("Do a booking with a user added question and verify a few thing in b/w", async ({
page,
users,
@@ -76,7 +74,7 @@ test.describe("Manage Booking Questions", () => {
await firstEventTypeElement.click();
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant);
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
});
});
@@ -90,8 +88,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
requestList: (import("http").IncomingMessage & { body?: any })[];
url: string;
},
bookerVariant: BookerVariants
}
) {
await page.click('[href$="tabName=advanced"]');
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
@@ -106,7 +103,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
},
});
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
await doOnFreshPreview(page, context, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
@@ -122,7 +119,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
@@ -136,7 +133,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
@@ -155,7 +152,6 @@ async function runTestStepsCommonForTeamAndUserEventType(
return await doOnFreshPreview(
page,
context,
bookerVariant,
async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible();
@@ -324,11 +320,10 @@ async function expectErrorToBeThereFor({ page, name }: { page: Page; name: strin
async function doOnFreshPreview(
page: Page,
context: PlaywrightTestArgs["context"],
bookerVariant: BookerVariants,
callback: (page: Page) => Promise<void>,
persistTab = false
) {
const previewTabPage = await openBookingFormInPreviewTab(context, page, bookerVariant);
const previewTabPage = await openBookingFormInPreviewTab(context, page);
await callback(previewTabPage);
if (!persistTab) {
await previewTabPage.close();
@@ -377,39 +372,19 @@ async function createAndLoginUserWithEventTypes({
return user;
}
async function rescheduleFromTheLinkOnPage({
page,
bookerVariant,
}: {
page: Page;
bookerVariant?: BookerVariants;
}) {
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
await page.locator('[data-testid="reschedule-link"]').click();
await page.waitForLoadState();
await selectFirstAvailableTimeSlotNextMonth(page);
if (bookerVariant === "old-booker") {
await page.waitForURL((url) => {
return url.pathname.endsWith("/book");
});
}
await page.click('[data-testid="confirm-reschedule-button"]');
}
async function openBookingFormInPreviewTab(
context: PlaywrightTestArgs["context"],
page: Page,
bookerVariant: BookerVariants
) {
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
const previewTabPromise = context.waitForEvent("page");
await page.locator('[data-testid="preview-button"]').click();
const previewTabPage = await previewTabPromise;
await previewTabPage.waitForLoadState();
await selectFirstAvailableTimeSlotNextMonth(previewTabPage);
if (bookerVariant === "old-booker") {
await previewTabPage.waitForURL((url) => {
return url.pathname.endsWith("/book");
});
}
return previewTabPage;
}
+1 -2
View File
@@ -4,7 +4,6 @@ import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
const IS_STRIPE_ENABLED = !!(
@@ -17,7 +16,7 @@ test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
testBothBookers.describe("Reschedule Tests", async () => {
test.describe("Reschedule Tests", async () => {
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "يجب أن يكون عدد الأحرف {{limit}} أو أقل",
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من {{passwordLength}} أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
"incorrect_password": "كلمة المرور غير صحيحة.",
"incorrect_username_password": "اسم المستخدم أو كلمة المرور غير صحيحة.",
"use_setting": "استخدام الإعداد",
"am_pm": "صباحًا/مساءً",
"time_options": "خيارات الوقت",
@@ -639,7 +638,6 @@
"create_new_team": "إنشاء فريق جديد",
"open_invitations": "الدعوات المفتوحة",
"new_team": "فريق جديد",
"create_first_team_and_invite_others": "قم بإنشاء فريقك الأول ودعوة المستخدمين الآخرين للعمل معك.",
"create_team_to_get_started": "قم بإنشاء فريق للبدء",
"teams": "الفرق",
"team": "فريق",
@@ -1073,8 +1071,8 @@
"specific_issue": "هل لديك مشكلة معينة؟",
"browse_our_docs": "تصفح مستنداتنا",
"choose_template": "اختيار قالب",
"reminder": "تذكير",
"custom": "مخصص",
"reminder": "تذكير",
"reminder_email": "تذكر {{eventType}} مع {{name}} في {{date}}",
"not_triggering_existing_bookings": "لن يتم تشغيل الحجوزات الموجودة مسبقاً حيث سيتم طلب رقم الهاتف عند حجز الحدث.",
"minute_one": "{{count}} دقيقة",
@@ -1528,9 +1526,6 @@
"choose_license_description": "يأتي Cal.com مع ترخيص AGPLv3 مجاني يمكن الوصول إليه ويمكن ترقيته إلى ترخيص Enterprise في أي وقت. يمكنك الترقية في أي وقت لاحق.",
"license": "الترخيص",
"agplv3_license": "ترخيص AGPLv3",
"ee_enterprise_license": "رخصة المؤسسة \"/ee\"",
"enterprise_booking_fee": "بدءًا من {{enterprise_booking_fee}}/ الشهر",
"enterprise_license_includes": "كل شيء لأغراض الاستخدام التجاري",
"no_need_to_keep_your_code_open_source": "لا حاجة للحفاظ على تعليماتك البرمجية مفتوحة المصدر",
"repackage_rebrand_resell": "يمكنك إعادة التعبئة وإعادة الترويج وإعادة البيع بسهولة",
"a_vast_suite_of_enterprise_features": "مجموعة كبيرة من ميزات المؤسسة",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "رابط /{{slug}} موجود بالفعل لـ {{count}} عضو. هل تريد استبداله؟",
"managed_event_dialog_title_other": "رابط /{{slug}} موجود بالفعل لـ {{count}} من الأعضاء. هل تريد استبداله؟",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> يستخدم الرابط <strong>/{{slug}}</strong> بالفعل.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong>يستخدمون الرابط <string>/{{slug}}</strong> بالفعل.",
"managed_event_dialog_clarification": "إذا اخترت استبداله، فسنخطرهم بذلك. ارجع وقم بإزالتهم إذا كنت لا تريد استبداله.",
"review_event_type": "مراجعة نوع الحدث",
"looking_for_more_analytics": "هل تبحث عن المزيد من التحليلات؟",
@@ -232,7 +232,7 @@
"continue_without_calendar": "Pokračovat bez kalendáře",
"connect_your_calendar": "Připojit svůj kalendář",
"connect_your_video_app": "Propojte své video aplikace",
"connect_your_video_app_instructions": "Propojte své video aplikace a používejte je v typech událostí.",
"connect_your_video_app_instructions": "Propojte své video aplikace a používejte je ve svých typech událostí.",
"connect_your_calendar_instructions": "Připojit kalendář pro automatickou kontrolu časů, kdy nejste dostupní, a nově naplánovaných událostí.",
"set_up_later": "Zprovoznit později",
"current_time": "Aktuální čas",
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Maximální povolená délka je {{limit}} znaků",
"invalid_password_hint": "Heslo musí být minimálně {{passwordLength}} znaků dlouhé, obsahovat alespoň jedno číslo a obsahovat kombinaci velkých a malých písmen",
"incorrect_password": "Heslo není správné.",
"incorrect_username_password": "Uživatelské jméno nebo heslo je nesprávné.",
"use_setting": "Použít nastavení",
"am_pm": "am/pm",
"time_options": "Nastavení času",
@@ -639,7 +638,6 @@
"create_new_team": "Vytvořit nový tým",
"open_invitations": "Otevřené pozvánky",
"new_team": "Nový tým",
"create_first_team_and_invite_others": "Vytvořte svůj první tým a pozvěte další uživatele, aby s vámi spolupracovali.",
"create_team_to_get_started": "Začněte vytvořením týmu",
"teams": "Týmy",
"team": "Tým",
@@ -1073,8 +1071,8 @@
"specific_issue": "Uveďte konkrétní problém",
"browse_our_docs": "procházet naše dokumenty",
"choose_template": "Vyberte si šablonu",
"reminder": "Upomínka",
"custom": "Vlastní",
"reminder": "Upomínka",
"reminder_email": "Upomínka: událost {{eventType}} s uživatelem {{name}} dne {{date}}",
"not_triggering_existing_bookings": "Nespustí se u již existujících rezervací, protože uživatel bude při rezervaci události požádán o zadání telefonního čísla.",
"minute_one": "{{count}} min",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com se dodává s dostupnou a bezplatnou licencí AGPLv3, která má určitá omezení. Kdykoli je ale možné provést upgrade na licenci Enterprise. Upgrade je možné provést kdykoli v pozdější době.",
"license": "Licence",
"agplv3_license": "Licence AGPLv3",
"ee_enterprise_license": "„/ee“ Licence Enterprise",
"enterprise_booking_fee": "Od {{{enterprise_booking_fee}}/měsíc",
"enterprise_license_includes": "Vše pro komerční použití",
"no_need_to_keep_your_code_open_source": "Není nutné, aby váš kód byl otevřený",
"repackage_rebrand_resell": "Snadné přebalení, rebranding a přeprodej",
"a_vast_suite_of_enterprise_features": "Rozsáhlá sada firemních funkcí",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "Adresa URL /{{slug}} již existuje pro {{count}} člena. Chcete ji nahradit?",
"managed_event_dialog_title_other": "Adresa URL /{{slug}} již existuje pro ({{count}}) členy. Chcete ji nahradit?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> již používá adresu URL <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> již používají adresu URL <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Pokud se rozhodnete ji nahradit, pošleme jim upozornění. Pokud ji nechcete přepsat, vraťte se a odstraňte ji.",
"review_event_type": "Revidovat typ události",
"looking_for_more_analytics": "Hledáte další analytické informace?",
@@ -414,7 +414,6 @@
"password_hint_num": "Indeholde mindst 1 tal",
"invalid_password_hint": "Adgangskoden skal være mindst {{passwordLength}} tegn langt, der indeholder mindst ét tal og har en blanding af store og små bogstaver",
"incorrect_password": "Adgangskoden er forkert.",
"incorrect_username_password": "Brugernavn eller adgangskode er forkert.",
"use_setting": "Brug indstilling",
"am_pm": "am/pm",
"time_options": "Tidsmuligheder",
@@ -608,7 +607,6 @@
"create_new_team": "Opret et nyt team",
"open_invitations": "Åbne Invitationer",
"new_team": "Nyt Team",
"create_first_team_and_invite_others": "Opret dit første team og invitér andre brugere til at arbejde sammen med dig.",
"create_team_to_get_started": "Opret et team for at komme i gang",
"teams": "Teams",
"team": "Team",
@@ -1030,8 +1028,8 @@
"specific_issue": "Har du et konkret spørgsmål?",
"browse_our_docs": "gennemse vores dokumenter",
"choose_template": "Vælg en skabelon",
"reminder": "Påmindelse",
"custom": "Brugerdefineret",
"reminder": "Påmindelse",
"reminder_email": "Påmindelse: {{eventType}} med {{name}} den {{date}}",
"not_triggering_existing_bookings": "Vil ikke udløses for allerede eksisterende bookinger, da brugeren vil blive bedt om telefonnummer, når de booker begivenheden.",
"minute_one": "{{count}} minut",
@@ -1455,9 +1453,6 @@
"choose_license_description": "Cal.com kommer med en tilgængelig og gratis AGPLv3-licens med nogle begrænsninger, som til enhver tid kan opgraderes til en Enterprise-licens. Du kan opgradere når som helst senere.",
"license": "Licens",
"agplv3_license": "AGPLv3 Licens",
"ee_enterprise_license": "“/ee” Virksomhedslicens",
"enterprise_booking_fee": "Fra {{enterprise_booking_fee}}/måned",
"enterprise_license_includes": "Alt til en kommerciel brugssag",
"repackage_rebrand_resell": "Ompak, rebrand og videresælg nemt",
"a_vast_suite_of_enterprise_features": "En bred vifte af virksomhedsfunktioner",
"free_license_fee": "0,00kr./måned",
+5 -11
View File
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Muss {{limit}} oder weniger Zeichen lang sein",
"invalid_password_hint": "Das Passwort muss mindestens {{passwordLength}} Zeichen lang sein und mindestens eine Nummer beinhalten, sowie eine Mischung aus Groß- und Kleinbuchstaben sein",
"incorrect_password": "Passwort ist falsch",
"incorrect_username_password": "Benutzername oder Passwort ist falsch.",
"use_setting": "Benutze Einstellung",
"am_pm": "am/pm",
"time_options": "Zeitoptionen",
@@ -639,7 +638,6 @@
"create_new_team": "Ein neues Team erstellen",
"open_invitations": "Offene Einladungen",
"new_team": "Neues Team",
"create_first_team_and_invite_others": "Erstellen Sie Ihr erstes Team und laden Sie andere Benutzer ein, mit Ihnen zusammenzuarbeiten.",
"create_team_to_get_started": "Erstellen Sie ein Team um loszulegen",
"teams": "Teams",
"team": "Team",
@@ -1073,8 +1071,8 @@
"specific_issue": "Haben Sie ein spezielles Problem",
"browse_our_docs": "Durchsuchen Sie unsere Dokumentation",
"choose_template": "Vorlage auswählen",
"reminder": "Erinnerung",
"custom": "Benutzerdefiniert",
"reminder": "Erinnerung",
"reminder_email": "Erinnerung: {{eventType}} mit {{name}} bei {{date}}",
"not_triggering_existing_bookings": "Wird nicht für bereits bestehende Buchungen ausgelöst, da der Benutzer bei der Buchung nach einer Telefonnummer gefragt wird.",
"minute_one": "{{count}} Minute",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com enthält eine kostenlose AGPLv3-Lizenz, mit einigen Einschränkungen, die jederzeit auf eine Enterprise-Lizenz aufgerüstet werden können. Sie können später jederzeit upgraden.",
"license": "Lizenz",
"agplv3_license": "AGPLv3 Lizenz",
"ee_enterprise_license": "„/ee“ Enterprise-Lizenz",
"enterprise_booking_fee": "Ab {{enterprise_booking_fee}}/Monat",
"enterprise_license_includes": "Alles für einen kommerziellen Anwendungsfall",
"no_need_to_keep_your_code_open_source": "Ihr Code muss nicht open-source bleiben",
"repackage_rebrand_resell": "Einfaches Neuverpacken, Rebranding und Weiterverkaufen",
"a_vast_suite_of_enterprise_features": "Eine große Auswahl an Enterprise-Funktionen",
@@ -1676,10 +1671,9 @@
"connect_now": "Jetzt verbinden",
"managed_event_dialog_confirm_button_one": "{{count}} Mitglied ersetzen & benachrichtigen",
"managed_event_dialog_confirm_button_other": "{{count}} Mitglieder ersetzen & benachrichtigen",
"managed_event_dialog_title_one": "Die URL /{{slug}} ist bereits für {{count}} Mitglied vorhanden. Möchten Sie sie ersetzen?",
"managed_event_dialog_title_other": "Die URL /{{slug}} ist bereits für {{count}} Mitglieder vorhanden. Möchten Sie sie ersetzen?",
"managed_event_dialog_title_one": "Die URL /{{slug}} wird bereits von {{count}} Mitglied genutzt. Möchten Sie sie ersetzen?",
"managed_event_dialog_title_other": "Die URL /{{slug}} wird bereits von {{count}} Mitgliedern genutzt. Möchten Sie sie ersetzen?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> verwendet bereits die URL <strong>/{{slug}}</strong>-.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> verwenden bereits die URL <strong>/{{slug}}</strong>-.",
"managed_event_dialog_clarification": "Wenn Sie die URL ersetzen, werden wir sie benachrichtigen. Gehen Sie zurück und entfernen Sie sie, wenn Sie sie nicht überschreiben möchten.",
"review_event_type": "Ereignistyp überprüfen",
"looking_for_more_analytics": "Suchen Sie nach weiteren Analysedaten?",
@@ -1733,7 +1727,7 @@
"payment_app_commission": "Zahlung erfordern ({{paymentFeePercentage}} % + {{fee, currency}} Provision pro Transaktion)",
"email_invite_team": "{{email}} wurde eingeladen",
"email_invite_team_bulk": "{{userCount}} Benutzer wurden eingeladen",
"error_collecting_card": "Fehler beim Erfassen der Karte",
"error_collecting_card": "Kartenfehler",
"image_size_limit_exceed": "Das hochgeladene Bild sollte die maximale Größe von 5 MB nicht überschreiten",
"inline_embed": "Inline-Einbettung",
"load_inline_content": "Laden Sie Ihren Termintyp direkt inline mit Ihren anderen Website-Inhalten.",
@@ -1749,7 +1743,7 @@
"disable_attendees_confirmation_emails_description": "Mindestens ein Workflow, durch den bei Buchung eine E-Mail an die Teilnehmer gesendet wird, ist für diesen Termintyp aktiv.",
"disable_host_confirmation_emails": "Standardmäßige Bestätigungs-E-Mails für Veranstalter deaktivieren",
"disable_host_confirmation_emails_description": "Mindestens ein Workflow, durch den bei Buchung eine E-Mail an den Veranstalter gesendet wird, ist für diesen Termintyp aktiv.",
"add_an_override": "Eine Ausnahmeregelung hinzufügen",
"add_an_override": "Eine Überschreibung hinzufügen",
"import_from_google_workspace": "Benutzer aus Google Workspace importieren",
"connect_google_workspace": "Google Workspace verbinden",
"google_workspace_admin_tooltip": "Sie müssen ein Workspace-Admin sein, um diese Funktion nutzen zu können",
+15 -11
View File
@@ -477,7 +477,7 @@
"max_limit_allowed_hint": "Must be {{limit}} or fewer characters long",
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
"incorrect_password": "Password is incorrect.",
"incorrect_username_password": "Username or password is incorrect.",
"incorrect_email_password": "Email or password is incorrect.",
"use_setting": "Use setting",
"am_pm": "am/pm",
"time_options": "Time options",
@@ -695,7 +695,7 @@
"create_new_team": "Create a new team",
"open_invitations": "Open Invitations",
"new_team": "New Team",
"create_first_team_and_invite_others": "Create your first team and invite other users to work together with you.",
"create_first_team_and_invite_others": "Create your first team and invite other users to work together.",
"create_team_to_get_started": "Create a team to get started",
"teams": "Teams",
"team": "Team",
@@ -1086,6 +1086,8 @@
"email_attendee_action": "send email to attendees",
"sms_attendee_action": "Send SMS to attendee",
"sms_number_action": "send SMS to a specific number",
"whatsapp_number_action": "send Whatsapp to a specific number",
"whatsapp_attendee_action": "send Whatsapp to attendee",
"workflows": "Workflows",
"new_workflow_btn": "New Workflow",
"add_new_workflow": "Add a new workflow",
@@ -1147,8 +1149,10 @@
"specific_issue": "Have a specific issue?",
"browse_our_docs": "browse our docs",
"choose_template": "Choose a template",
"reminder": "Reminder",
"custom": "Custom",
"reminder": "Reminder",
"rescheduled": "Rescheduled",
"completed": "Completed",
"reminder_email": "Reminder: {{eventType}} with {{name}} at {{date}}",
"not_triggering_existing_bookings": "Won't trigger for already existing bookings as user will be asked for phone number when booking the event.",
"minute_one": "{{count}} minute",
@@ -1490,8 +1494,9 @@
"must_enter_team_name": "Must enter a team name",
"team_url_required": "Must enter a team URL",
"url_taken": "This URL is already taken",
"problem_registering_domain": "There was a problem with registering the subdomain, please try again or contact an administrator",
"team_publish": "Publish team",
"number_sms_notifications": "Phone number (SMS notifications)",
"number_text_notifications": "Phone number (Text notifications)",
"attendee_email_variable": "Attendee email",
"attendee_email_info": "The person booking's email",
"kbar_search_placeholder": "Type a command or search...",
@@ -1619,12 +1624,9 @@
"teams_plan_required": "Teams plan required",
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.",
"choose_a_license": "Choose a license",
"choose_license_description": "Cal.com comes with an accessible and free AGPLv3 license which some limitations which can be upgraded to an Enterprise license at any time. You can upgrade at anytime later.",
"choose_license_description": "Cal.com comes with an accessible and free AGPLv3 license which has limitations. We are onboarding Enterprise customers for the commercial license which you can inquire about by contacting sales below.",
"license": "License",
"agplv3_license": "AGPLv3 License",
"ee_enterprise_license": "“/ee” Enterprise License",
"enterprise_booking_fee": "Starting at {{enterprise_booking_fee}}/month",
"enterprise_license_includes": "Everything for a commercial use case",
"no_need_to_keep_your_code_open_source": "No need to keep your code open source",
"repackage_rebrand_resell": "Repackage, rebrand and resell easily",
"a_vast_suite_of_enterprise_features": "A vast suite of enterprise features",
@@ -1785,7 +1787,7 @@
"managed_event_dialog_title_one": "The url /{{slug}} already exists for {{count}} member. Do you want to replace it?",
"managed_event_dialog_title_other": "The url /{{slug}} already exists for {{count}} members. Do you want to replace it?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> is already using the <strong>/{{slug}}</strong> url.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> are already using the <string>/{{slug}}</strong> url.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> are already using the <strong>/{{slug}}</strong> url.",
"managed_event_dialog_clarification": "If you choose to replace it, we will notify them. Go back and remove them if you don't want to overwrite it.",
"review_event_type": "Review Event Type",
"looking_for_more_analytics": "Looking for more analytics?",
@@ -1913,8 +1915,8 @@
"team_names_empty": "Team names can't be empty",
"team_names_repeated": "Team names can't be repeated",
"user_belongs_organization": "User belongs to an organization",
"no_teams_yet": "This organization has no teams yet",
"no_teams_yet_description": "if you are an administrator, be sure to create teams to be shown here.",
"org_no_teams_yet": "This organization has no teams yet",
"org_no_teams_yet_description": "If you are an administrator, be sure to create teams to be shown here.",
"set_up": "Set up",
"set_up_your_profile": "Set up your profile",
"set_up_your_profile_description": "Let people know who you are within {{orgName}}, and when they engage with your public link.",
@@ -1923,6 +1925,7 @@
"crm": "CRM",
"messaging": "Messaging",
"sender_id_info": "Name or number shown as the sender of an SMS (some countries do not allow alphanumeric sender IDs)",
"org_admins_can_create_new_teams": "Only the admin of your organization can create new teams",
"google_new_spam_policy": "Google’s new spam policy could prevent you from receiving any email and calendar notifications about this booking.",
"resolve": "Resolve",
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
@@ -1937,5 +1940,6 @@
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "User: {{userName}}",
"insights_subtitle": "View booking insights across your events",
"custom_plan": "Custom Plan",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Debe tener {{limit}} o menos caracteres",
"invalid_password_hint": "La contraseña debe tener un mínimo de {{passwordLength}} caracteres que contengan al menos un número y una mezcla de letras mayúsculas y minúsculas",
"incorrect_password": "La Contraseña es Incorrecta.",
"incorrect_username_password": "El nombre de usuario o la contraseña son incorrectos.",
"use_setting": "Usar Ajuste",
"am_pm": "am/pm",
"time_options": "Opciones Horas",
@@ -639,7 +638,6 @@
"create_new_team": "Crear Nuevo Equipo",
"open_invitations": "Invitaciones Abiertas",
"new_team": "Nuevo Equipo",
"create_first_team_and_invite_others": "Crea tu primer equipo e invita a otros usuarios a trabajar contigo.",
"create_team_to_get_started": "Crea un equipo para empezar",
"teams": "Equipos",
"team": "Equipo",
@@ -1073,8 +1071,8 @@
"specific_issue": "¿Tienes un problema específico?",
"browse_our_docs": "navega por nuestros documentos",
"choose_template": "Elija una plantilla",
"reminder": "Recordatorio",
"custom": "Personalizado",
"reminder": "Recordatorio",
"reminder_email": "Recordatorio: {{eventType}} con {{name}} el {{date}}",
"not_triggering_existing_bookings": "No se activará para las reservas ya existentes, ya que se le pedirá al usuario un número de teléfono al reservar el evento.",
"minute_one": "{{count}} minuto",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com viene con una licencia AGPLv3 accesible y gratuita, la cual tiene algunas limitaciones. Esta licencia se puede actualizar a una licencia Empresarial en cualquier momento. Puede actualizarla más tarde.",
"license": "Licencia",
"agplv3_license": "Licencia AGPLv3",
"ee_enterprise_license": "Licencia Empresarial \"/ee\"",
"enterprise_booking_fee": "A partir de {{enterprise_booking_fee}}/mes",
"enterprise_license_includes": "Todo para un caso de uso comercial",
"no_need_to_keep_your_code_open_source": "No es necesario mantener su código abierto",
"repackage_rebrand_resell": "Vuelva a empaquetar, cambie la marca y revenda fácilmente",
"a_vast_suite_of_enterprise_features": "Un amplio conjunto de funciones empresariales",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "La url /{{slug}} ya existe para {{count}} miembro. ¿Desea reemplazarla?",
"managed_event_dialog_title_other": "La url /{{slug}} ya existe para {{count}} miembros. ¿Desea reemplazarla?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> ya usa la url <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> ya usan la url <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Si elige reemplazarlo, se lo notificaremos. Regrese y elimínelos si no desea sobrescribirlo.",
"review_event_type": "Revisar tipo de evento",
"looking_for_more_analytics": "¿Busca más datos de análisis?",
+17 -11
View File
@@ -241,6 +241,7 @@
"done": "Terminé",
"all_done": "Terminé !",
"all": "Tous",
"yours": "Votre compte",
"available_apps": "Applications disponibles",
"check_email_reset_password": "Vérifiez vos e-mails. Nous vous avons envoyé un lien pour réinitialiser votre mot de passe.",
"finish": "Terminer",
@@ -476,7 +477,6 @@
"max_limit_allowed_hint": "Doit contenir {{limit}} caractères ou moins",
"invalid_password_hint": "Le mot de passe doit contenir au moins {{passwordLength}} caractères, un chiffre et un mélange de lettres majuscules et minuscules.",
"incorrect_password": "Le mot de passe est incorrect.",
"incorrect_username_password": "Le nom d'utilisateur ou le mot de passe est incorrect.",
"use_setting": "Utiliser le paramètre",
"am_pm": "AM/PM",
"time_options": "Options de temps",
@@ -694,7 +694,7 @@
"create_new_team": "Créer une nouvelle équipe",
"open_invitations": "Invitations ouvertes",
"new_team": "Nouvelle équipe",
"create_first_team_and_invite_others": "Créez votre première équipe et invitez d'autres utilisateurs à travailler avec vous.",
"create_first_team_and_invite_others": "Créez votre première équipe et invitez d'autres utilisateurs à travailler ensemble.",
"create_team_to_get_started": "Créez une équipe pour commencer",
"teams": "Équipes",
"team": "Équipe",
@@ -864,6 +864,7 @@
"installed_apps": "Apps installées",
"free_to_use_apps": "Gratuit",
"no_category_apps": "Aucune application {{category}}",
"all_apps": "Toutes les apps",
"no_category_apps_description_calendar": "Ajoutez une application de calendrier pour vérifier les conflits et éviter les doubles réservations.",
"no_category_apps_description_conferencing": "Essayez d'ajouter une application de visioconférence pour les appels vidéo avec vos clients.",
"no_category_apps_description_payment": "Ajoutez une application de paiement pour faciliter les transactions entre vous et vos clients.",
@@ -1083,6 +1084,8 @@
"email_attendee_action": "envoyer un e-mail aux participants",
"sms_attendee_action": "Envoyer un SMS au participant",
"sms_number_action": "envoyer un SMS à un numéro spécifique",
"whatsapp_number_action": "envoyer un message WhatsApp à un numéro spécifique",
"whatsapp_attendee_action": "envoyer un message WhatsApp au participant",
"workflows": "Workflows",
"new_workflow_btn": "Nouveau workflow",
"add_new_workflow": "Ajouter un nouveau workflow",
@@ -1144,8 +1147,10 @@
"specific_issue": "Vous avez un problème particulier ?",
"browse_our_docs": "parcourir notre documentation",
"choose_template": "Choisir un modèle",
"reminder": "Rappel",
"custom": "Personnalisé",
"reminder": "Rappel",
"rescheduled": "Replanifié",
"completed": "Terminé",
"reminder_email": "Rappel : {{eventType}} avec {{name}} le {{date}}",
"not_triggering_existing_bookings": "Ne se déclenchera pas pour les réservations déjà existantes car l'utilisateur sera invité à fournir un numéro de téléphone lors de la réservation de l'événement.",
"minute_one": "{{count}} minute",
@@ -1458,6 +1463,7 @@
"new_event_type_availability": "Disponibilités de {{eventTypeTitle}}",
"error_editing_availability": "Erreur lors de la modification des disponibilités",
"dont_have_permission": "Vous n'avez pas la permission d'accéder à cette ressource.",
"saml_config": "SAML",
"saml_configuration_placeholder": "Veuillez coller les métadonnées SAML de votre fournisseur d'identité ici.",
"saml_email_required": "Veuillez saisir une adresse e-mail pour que nous puissions trouver votre fournisseur d'identité SAML",
"saml_sp_title": "Informations sur le fournisseur de services",
@@ -1486,8 +1492,9 @@
"must_enter_team_name": "Vous devez saisir un nom d'équipe",
"team_url_required": "Vous devez saisir un lien d'équipe",
"url_taken": "Ce lien est déjà pris",
"problem_registering_domain": "Un problème est survenu lors de l'enregistrement du sous-domaine, veuillez réessayer ou contacter un administrateur",
"team_publish": "Publier l'équipe",
"number_sms_notifications": "Numéro de téléphone (notifications par SMS)",
"number_text_notifications": "Numéro de téléphone (notifications par SMS)",
"attendee_email_variable": "Adresse e-mail du participant",
"attendee_email_info": "Adresse e-mail du participant",
"kbar_search_placeholder": "Saisissez une commande ou une recherche...",
@@ -1614,12 +1621,9 @@
"teams_plan_required": "Le plan Équipes est requis",
"routing_forms_are_a_great_way": "Les formulaires de routage sont un excellent moyen d'acheminer vos prospects entrants vers la bonne personne. Passez au plan Équipes pour accéder à cette fonctionnalité.",
"choose_a_license": "Choisir une licence",
"choose_license_description": "Cal.com est fourni avec licence AGPLv3 accessible et gratuite (avec certaines limitations) qui peut être mis à niveau vers une licence Entreprise à tout moment. Vous pouvez mettre à niveau à tout moment plus tard.",
"choose_license_description": "Cal.com est fourni avec une licence AGPLv3 accessible et gratuite mais comportant des limitations. Nous sommes en train d'intégrer les clients du plan Entreprise pour la licence commerciale. Vous pouvez vous renseigner à ce sujet en contactant le service commercial ci-dessous.",
"license": "Licence",
"agplv3_license": "Licence AGPLv3",
"ee_enterprise_license": "Licence Entreprise « /ee »",
"enterprise_booking_fee": "À partir de {{enterprise_booking_fee}}/mois",
"enterprise_license_includes": "Tout pour un usage commercial",
"no_need_to_keep_your_code_open_source": "Pas besoin de garder votre code open source",
"repackage_rebrand_resell": "Restructurez, changez de marque et revendez facilement",
"a_vast_suite_of_enterprise_features": "Une vaste suite de fonctionnalités d'entreprise",
@@ -1780,7 +1784,7 @@
"managed_event_dialog_title_one": "Le lien /{{slug}} existe déjà pour {{count}} membre. Voulez-vous le remplacer ?",
"managed_event_dialog_title_other": "Le lien /{{slug}} existe déjà pour {{count}} membres. Voulez-vous le remplacer ?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> utilise déjà le lien <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> utilisent déjà le lien <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> utilisent déjà l'URL <strong>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Si vous choisissez de le remplacer, nous les en informerons. Revenez en arrière et supprimez-les si vous ne souhaitez pas l'écraser.",
"review_event_type": "Examiner le type d'événement",
"looking_for_more_analytics": "Vous cherchez plus d'analyses ?",
@@ -1908,8 +1912,8 @@
"team_names_empty": "Les noms d'équipe ne peuvent pas être vides",
"team_names_repeated": "Les noms d'équipe ne peuvent pas être répétés",
"user_belongs_organization": "L'utilisateur appartient à une organisation",
"no_teams_yet": "Cette organisation n'a pas encore d'équipe",
"no_teams_yet_description": "si vous êtes administrateur, assurez-vous de créer des équipes à afficher ici.",
"org_no_teams_yet": "Cette organisation n'a pas encore d'équipe",
"org_no_teams_yet_description": "Si vous êtes un administrateur, assurez-vous de créer des équipes à afficher ici.",
"set_up": "Configurer",
"set_up_your_profile": "Configurer votre profil",
"set_up_your_profile_description": "Faites savoir aux gens qui vous êtes au sein de {{orgName}} et quand ils interagissent avec votre lien public.",
@@ -1918,6 +1922,7 @@
"crm": "CRM",
"messaging": "Messagerie",
"sender_id_info": "Nom ou numéro affiché comme expéditeur d'un SMS (certains pays n'autorisent pas les ID d'expéditeur alphanumériques)",
"org_admins_can_create_new_teams": "Seul l'administrateur de votre organisation peut créer de nouvelles équipes",
"google_new_spam_policy": "La nouvelle politique anti-spam de Google peut vous empêcher de recevoir des notifications par e-mail ou sur votre calendrier concernant cette réservation.",
"resolve": "Résoudre",
"no_organization_slug": "Une erreur s'est produite lors de la création des équipes pour cette organisation. Le slug du lien est manquant.",
@@ -1932,5 +1937,6 @@
"insights_team_filter": "Équipe : {{teamName}}",
"insights_user_filter": "Utilisateur : {{userName}}",
"insights_subtitle": "Visualisez les statistiques de réservation à travers vos événements",
"custom_plan": "Plan personnalisé",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -440,7 +440,6 @@
"max_limit_allowed_hint": "חייב להיות באורך של עד {{limit}} תווים",
"invalid_password_hint": "הסיסמה חייבת להיות באורך של {{passwordLength}} תווים לפחות ולכלול לפחות ספרה אחת ושילוב של אותיות רישיות וקטנות",
"incorrect_password": "הסיסמה שגויה.",
"incorrect_username_password": "שם משתמש או סיסמה שגויים.",
"use_setting": "השתמש בהגדרה",
"am_pm": "לפנה\"צ/אחה\"צ",
"time_options": "אפשרויות זמן",
@@ -463,7 +462,7 @@
"friday": "יום שישי",
"saturday": "יום שבת",
"sunday": "יום ראשון",
"all_booked_today": "כל מה שהוזמן.",
"all_booked_today": "הכל הוזמן.",
"slots_load_fail": "לא ניתן לטעון את חלונות הזמן הזמינים.",
"additional_guests": "הוספת אורחים",
"your_name": "שמך",
@@ -651,7 +650,6 @@
"create_new_team": "יצירת צוות חדש",
"open_invitations": "הזמנות פתוחות",
"new_team": "צוות חדש",
"create_first_team_and_invite_others": "צור את הצוות הראשון שלך והזמן משתמשים אחרים לעבוד יחד איתך.",
"create_team_to_get_started": "צור צוות כדי להתחיל",
"teams": "צוותים",
"team": "צוות",
@@ -1085,8 +1083,8 @@
"specific_issue": "נתקלת בבעיה ספציפית?",
"browse_our_docs": "לדפדף במסמכים שלנו",
"choose_template": "בחירת תבנית",
"reminder": "תזכורת",
"custom": "התאמה אישית",
"reminder": "תזכורת",
"reminder_email": "תזכורת: {{eventType}} עם {{name}} בתאריך {{date}}",
"not_triggering_existing_bookings": "לא יופעל עבור הזמנות קיימות מאחר שהמשתמש יתבקש למסור מספר טלפון בעת הזמנת האירוע.",
"minute_one": "{{count}} דקה",
@@ -1540,9 +1538,6 @@
"choose_license_description": "Cal.com מגיע עם רישיון AGPLv3 נגיש וחינמי עם כמה מגבלות שניתן לשדרג לתוכנית Enterprise בכל עת לקבלת גישה אליהן. אתה יכול לשדרג בכל מועד עתידי.",
"license": "רישיון",
"agplv3_license": "רישיון AGPLv3",
"ee_enterprise_license": "\"ee/\" רישיון Enterprise",
"enterprise_booking_fee": "החל מ- {{enterprise_booking_fee}}/לחודש",
"enterprise_license_includes": "הכל לשימוש מסחרי",
"no_need_to_keep_your_code_open_source": "אין צורך להשאיר את הקוד שלך כקוד פתוח",
"repackage_rebrand_resell": "אריזה מחדש, מיתוג מחדש ומכירה בקלות",
"a_vast_suite_of_enterprise_features": "מגוון יכולות Enterprise עצום",
@@ -1691,7 +1686,6 @@
"managed_event_dialog_title_one": "כתובת ה-URL /‏{{slug}} כבר קיימת עבור חבר/ה {{count}}. האם ברצונך להחליף אותה?",
"managed_event_dialog_title_other": "כתובת ה-URL /‏{{slug}} כבר קיימת עבור {{count}} חברים. האם ברצונך להחליף אותה?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> כבר משתמש/ת בכתובת ה-URL ‏<strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> כבר משתמשים בכתובת ה-URL ‏<string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "אם תבחר/י להחליף את כתובת ה-URL, אנו נודיע על כך לחברים. אם אינך רוצה להחליף אותה, חזור/י והסר/י את החברים.",
"review_event_type": "בדיקת סוג האירוע",
"looking_for_more_analytics": "מחפש עוד מידע אנליטי?",
@@ -1745,7 +1739,7 @@
"payment_app_commission": "דרישת תשלום (‎{{paymentFeePercentage}}%‎ + ‏{{fee, currency}} עמלה על העסקה)",
"email_invite_team": "נשלחה הזמנה ל-{{email}}",
"email_invite_team_bulk": "{{userCount}} משתמשים הוזמנו",
"error_collecting_card": "שגיאה בעיבוד הכרטיס",
"error_collecting_card": "שגיאה בחיוב הכרטיס",
"image_size_limit_exceed": "גודל התמונה המרבי שניתן להעלות הוא 5MB",
"inline_embed": "הטמעה באתר",
"load_inline_content": "סוג האירוע נטען ישירות בתוך שאר תוכן האתר שלך.",
@@ -452,7 +452,6 @@
"max_limit_allowed_hint": "Deve avere una lunghezza massima di {{limit}} caratteri",
"invalid_password_hint": "La password deve avere una lunghezza minima di {{passwordLength}} caratteri e deve contenere almeno 1 numero e una combinazione di caratteri maiuscoli e minuscoli",
"incorrect_password": "La password non è corretta.",
"incorrect_username_password": "Nome utente o password errati.",
"use_setting": "Usa impostazione",
"am_pm": "am/pm",
"time_options": "Opzioni tempo",
@@ -475,7 +474,7 @@
"friday": "Venerdì",
"saturday": "Sabato",
"sunday": "Domenica",
"all_booked_today": "Tutto prenotato.",
"all_booked_today": "Nessuna disponibilità.",
"slots_load_fail": "Impossibile caricare gli intervalli di tempo disponibili.",
"additional_guests": "Aggiungi ospiti",
"your_name": "Il tuo nome",
@@ -663,7 +662,6 @@
"create_new_team": "Crea un nuovo team",
"open_invitations": "Inviti Aperti",
"new_team": "Nuovo Team",
"create_first_team_and_invite_others": "Crea il tuo primo team e invita altri utenti a lavorare insieme a te.",
"create_team_to_get_started": "Crea una squadra per iniziare",
"teams": "Team",
"team": "Team",
@@ -1099,8 +1097,8 @@
"specific_issue": "Hai un problema specifico?",
"browse_our_docs": "sfoglia i nostri documenti",
"choose_template": "Scegli un modello",
"reminder": "Promemoria",
"custom": "Personalizzato",
"reminder": "Promemoria",
"reminder_email": "Promemoria: {{eventType}} con {{name}} il {{date}}",
"not_triggering_existing_bookings": "Non verrà attivato per le prenotazioni già esistenti, in quanto agli utenti verrà chiesto di specificare il numero di telefono al momento della prenotazione dell'evento.",
"minute_one": "{{count}} minuto",
@@ -1560,9 +1558,6 @@
"choose_license_description": "Cal.com viene fornito con una licenza AGPLv3 accessibile e gratuita che ha alcune limitazioni e può essere convertita in una licenza Enterprise in qualsiasi momento. È possibile effettuare l'upgrade in un secondo tempo.",
"license": "Licenza",
"agplv3_license": "Licenza AGPLv3",
"ee_enterprise_license": "Licenza Enterprise “/ee”",
"enterprise_booking_fee": "A partire da {{enterprise_booking_fee}}/mese",
"enterprise_license_includes": "Tutto il necessario per l'utilizzo commerciale",
"no_need_to_keep_your_code_open_source": "Non è necessario che il codice rimanga open source",
"repackage_rebrand_resell": "Rivendi facilmente con un nuovo marchio",
"a_vast_suite_of_enterprise_features": "Un'estesa suite di funzionalità enterprise",
@@ -1714,7 +1709,6 @@
"managed_event_dialog_title_one": "L'URL /{{slug}} esiste già per {{count}} membro. Vuoi sostituirlo?",
"managed_event_dialog_title_other": "L'URL /{{slug}} esiste già per {{count}} membri. Vuoi sostituirlo?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> utilizza già l'URL <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> utilizzano già l'URL <strong>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Se decidi di sostituirlo, i membri ne saranno avvisati. Torna indietro e rimuovili se non desideri sovrascriverlo.",
"review_event_type": "Rivedi tipo di evento",
"looking_for_more_analytics": "Hai bisogno di più dati analitici?",
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "{{limit}} 文字以下で入力してください",
"invalid_password_hint": "パスワードには少なくとも 1 文字以上数字を含め、アルファベットの大文字と小文字を両方使用した上で {{passwordLength}} 文字以上の長さに設定してください",
"incorrect_password": "パスワードが正しくありません。",
"incorrect_username_password": "ユーザー名またはパスワードが正しくありません。",
"use_setting": "設定を使用",
"am_pm": "午前/午後",
"time_options": "時刻オプション",
@@ -639,7 +638,6 @@
"create_new_team": "新しいチームを作成",
"open_invitations": "招待状を開く",
"new_team": "新しいチーム",
"create_first_team_and_invite_others": "あなたの最初のチームを作成し、あなたと一緒に仕事をするために他のユーザーを招待してください。",
"create_team_to_get_started": "始めるためにチームを作成",
"teams": "チーム",
"team": "チーム",
@@ -1073,8 +1071,8 @@
"specific_issue": "特定の問題がおありですか?",
"browse_our_docs": "ドキュメントを閲覧",
"choose_template": "テンプレートを選択する",
"reminder": "リマインダー",
"custom": "カスタム",
"reminder": "リマインダー",
"reminder_email": "リマインダー:{{date}} の {{name}} との {{eventType}}",
"not_triggering_existing_bookings": "イベントの予約時に電話番号の入力を求められるため、すでにある予約はトリガーされません。",
"minute_one": "{{count}} 分",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com には無料で利用可能な AGPLv3 ライセンスが付属していますが、これにはいくつかの制限があり、いつでも企業向けライセンスへとアップグレードすることができます。アップグレードは今後のどのタイミングでも可能です。",
"license": "ライセンス",
"agplv3_license": "AGPLv3 ライセンス",
"ee_enterprise_license": "\"/ee\" Enterprise ライセンス",
"enterprise_booking_fee": "{{enterprise_booking_fee}}/月で開始する",
"enterprise_license_includes": "商用ユースケースで必要となるすべてのもの",
"no_need_to_keep_your_code_open_source": "コードをオープンソースのままにしておく必要はありません",
"repackage_rebrand_resell": "再パッケージ、リブランディング、再販売が簡単になります",
"a_vast_suite_of_enterprise_features": "膨大な量の企業向け機能パッケージ",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "{{count}} 人のメンバーがすでに URL /{{slug}} を使用しています。置き換えますか?",
"managed_event_dialog_title_other": "{{count}} 人のメンバーがすでに URL /{{slug}} を使用しています。置き換えますか?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> がすでに URL <strong>/{{slug}}</strong> を使用しています。",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> らがすでに URL <string>/{{slug}}</strong> を使用しています。",
"managed_event_dialog_clarification": "置き換えを選択すると、通知が送られます。上書きしたくない場合には、戻って削除してください。",
"review_event_type": "イベントの種類を確認",
"looking_for_more_analytics": "詳しい分析情報をお探しですか?",
@@ -1735,7 +1729,7 @@
"email_invite_team_bulk": "{{userCount}} 人のユーザーが招待されました",
"error_collecting_card": "カードの収集中にエラーが発生しました",
"image_size_limit_exceed": "アップロードする画像のサイズは 5 MB 以下にする必要があります",
"inline_embed": "インラインで埋め込む",
"inline_embed": "インライン埋め込み",
"load_inline_content": "イベントの種類を他のウェブサイト内コンテンツにインラインで直接読み込みます。",
"floating_pop_up_button": "フローティングポップアップボタン",
"floating_button_trigger_modal": "モーダルウィンドウをご利用のイベントの種類でトリガーするフローティングボタンをサイトに設置します。",
@@ -429,7 +429,6 @@
"max_limit_allowed_hint": "{{limit}}자 이하여야 합니다",
"invalid_password_hint": "비밀번호는 7자 이상이어야 하고 숫자가 하나 이상 포함되어야 하며 대문자와 소문자가 혼합되어야 합니다",
"incorrect_password": "비밀번호가 잘못되었습니다",
"incorrect_username_password": "사용자 이름 또는 비밀번호가 잘못되었습니다.",
"use_setting": "사용 설정",
"am_pm": "am/pm",
"time_options": "시간 옵션",
@@ -640,7 +639,6 @@
"create_new_team": "새 팀 만들기",
"open_invitations": "초대 열기",
"new_team": "새 팀",
"create_first_team_and_invite_others": "첫 번째 팀을 만들고 함께 작업할 다른 사용자를 초대합니다.",
"create_team_to_get_started": "시작하려면 팀을 만드세요.",
"teams": "팀",
"team": "팀",
@@ -1074,8 +1072,8 @@
"specific_issue": "특정한 문제가 있나요?",
"browse_our_docs": "우리 문서 탐색",
"choose_template": "템플릿 선택",
"reminder": "미리 알림",
"custom": "사용자 정의",
"reminder": "미리 알림",
"reminder_email": "미리 알림: {{eventType}} - 이름 {{name}} 날짜 {{date}}",
"not_triggering_existing_bookings": "이벤트 예약 시 사용자에게 전화번호를 묻기 때문에 이미 존재하는 예약에 대해서는 트리거되지 않습니다.",
"minute_one": "{{count}}분",
@@ -1529,9 +1527,6 @@
"choose_license_description": "Cal.com은 액세스 가능한 무료 AGPLv3 라이선스와 함께 제공되며 일부 제한된 기능은 Enterprise 라이선스로 업그레이드하여 언제든지 이용할 수 있습니다. 업그레이드는 나중에 언제든지 가능합니다.",
"license": "라이선스",
"agplv3_license": "AGPLv3 라이선스",
"ee_enterprise_license": "“/ee” Enterprise 라이선스",
"enterprise_booking_fee": "시작 가격 {{enterprise_booking_fee}}/월",
"enterprise_license_includes": "모든 상업적 기능 구비",
"no_need_to_keep_your_code_open_source": "코드를 오픈 소스로 유지할 필요가 없습니다",
"repackage_rebrand_resell": "쉽게 패키지를 재구성하여 새 브랜드로 재판매 가능",
"a_vast_suite_of_enterprise_features": "방대한 엔터프라이즈 기능 모음",
@@ -1680,7 +1675,6 @@
"managed_event_dialog_title_one": "URL /{{slug}}은(는) {{count}}명의 회원에 대해 이미 존재합니다. 바꾸시겠습니까?",
"managed_event_dialog_title_other": "URL /{{slug}}은(는) {{count}}명의 회원에 대해 이미 존재합니다. 바꾸시겠습니까?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> 님이 이미 <strong>/{{slug}}</strong> url을 사용하는 중입니다.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> 님이 이미 <string>/{{slug}}</strong> url을 사용하는 중입니다.",
"managed_event_dialog_clarification": "URL 바꾸기를 선택하면 회원에게 알려드립니다. 덮어쓰지 않으려면 돌아가서 제거하십시오.",
"review_event_type": "이벤트 타입 검토",
"looking_for_more_analytics": "더 많은 분석 정보를 원합니까?",
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Moet {{limit}}} of minder tekens lang zijn",
"invalid_password_hint": "Het wachtwoord moet minimaal {{passwordLength}} tekens lang zijn, minimaal 1 cijfer bevatten en bestaan uit een mix van hoofd- en kleine letters",
"incorrect_password": "Uw wachtwoord is incorrect.",
"incorrect_username_password": "Gebruikersnaam of wachtwoord is onjuist.",
"use_setting": "Instelling gebruiken",
"am_pm": "am/pm",
"time_options": "Tijd, datum en tijdzone instellen",
@@ -639,7 +638,6 @@
"create_new_team": "Nieuw team aanmaken",
"open_invitations": "Openstaande uitnodigingen",
"new_team": "Nieuw Team",
"create_first_team_and_invite_others": "Maak uw eerste team en nodig andere gebruikers uit om in groepsverband af te spreken.",
"create_team_to_get_started": "Maak een team aan om aan de slag te gaan",
"teams": "Teams",
"team": "Team",
@@ -1073,8 +1071,8 @@
"specific_issue": "Heeft u een specifiek probleem?",
"browse_our_docs": "door onze documenten bladen",
"choose_template": "Kies een sjabloon",
"reminder": "Herinnering",
"custom": "Aangepast",
"reminder": "Herinnering",
"reminder_email": "Herinnering: {{eventType}} met {{name}} op {{date}}",
"not_triggering_existing_bookings": "Wordt niet geactiveerd voor bestaande boekingen, omdat de gebruiker om zijn/haar telefoonnummer gevraagd wordt bij het boeken van de gebeurtenis.",
"minute_one": "{{count}} minuut",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com wordt geleverd met een toegankelijke en gratis AGPLv3-licentie met enkele beperkingen die op elk moment kan worden geüpgraded naar een bedrijfslicentie. U kunt later altijd upgraden.",
"license": "Licentie",
"agplv3_license": "AGPLv3-licentie",
"ee_enterprise_license": "“/ee” Enterprise-licentie",
"enterprise_booking_fee": "Vanaf {{enterprise_booking_fee}}/maand",
"enterprise_license_includes": "Alles voor een commerciële toepassing",
"no_need_to_keep_your_code_open_source": "Uw code hoeft niet open source te zijn",
"repackage_rebrand_resell": "Gemakkelijk opnieuw verpakken, rebranden en doorverkopen",
"a_vast_suite_of_enterprise_features": "Een uitgebreid pakket bedrijfsfuncties",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "De URL /{{slug}} bestaat al voor {{count}} lid. Wilt u deze vervangen?",
"managed_event_dialog_title_other": "De URL /{{slug}} bestaat al voor {{count}} leden. Wilt u deze vervangen?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> gebruikt de URL <strong>/{{slug}}</strong> al.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> gebruiken de URL <string>/{{slug}}</strong> al.",
"managed_event_dialog_clarification": "Als u ervoor kiest om de URL te vervangen, informeren wij de leden daarover. Ga terug en verwijder ze als u de URL niet wilt overschrijven.",
"review_event_type": "Gebeurtenistype bekijken",
"looking_for_more_analytics": "Op zoek naar meer analyses?",
@@ -598,7 +598,6 @@
"create_new_team": "Opprett et nytt team",
"open_invitations": "Åpne invitasjoner",
"new_team": "Nytt team",
"create_first_team_and_invite_others": "Lag ditt første team og inviter andre brukere til å jobbe sammen med deg.",
"create_team_to_get_started": "Opprett et team for å komme i gang",
"teams": "Team",
"team": "Team",
@@ -1010,8 +1009,8 @@
"specific_issue": "Har du et spesifikt problem?",
"browse_our_docs": "bla gjennom vår dokumentasjon",
"choose_template": "Velg en mal",
"reminder": "Påminnelse",
"custom": "Egendefinert",
"reminder": "Påminnelse",
"reminder_email": "Påminnelse: {{eventType}} med {{name}} kl. {{date}}",
"not_triggering_existing_bookings": "Vil ikke utløses for allerede eksisterende bookinger da brukeren vil bli bedt om telefonnummer ved booking av hendelsen.",
"minute_one": "{{count}} minutt",
+4 -10
View File
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Może składać się z maksymalnie {{limit}} znaków",
"invalid_password_hint": "Hasło musi składać się z co najmniej {{passwordLength}} znaków, zawierać co najmniej jedną cyfrę oraz kombinację wielkich i małych liter",
"incorrect_password": "Hasło jest nieprawidłowe.",
"incorrect_username_password": "Nieprawidłowa nazwa użytkownika lub hasło.",
"use_setting": "Użyj ustawień",
"am_pm": "rano/po południu",
"time_options": "Opcje czasu",
@@ -639,7 +638,6 @@
"create_new_team": "Utwórz nowy zespół",
"open_invitations": "Otwórz Zaproszenia",
"new_team": "Nowy zespół",
"create_first_team_and_invite_others": "Utwórz swój pierwszy zespół i zaproś innych użytkowników do współpracy z Tobą.",
"create_team_to_get_started": "Utwórz drużynę, aby rozpocząć",
"teams": "Drużyny",
"team": "Zespół",
@@ -735,7 +733,7 @@
"offset_toggle": "Przesuń czasy rozpoczęcia",
"offset_toggle_description": "Przesuń o określoną liczbę minut przedziały czasowe wyświetlane rezerwującym",
"offset_start": "Przesuń o",
"offset_start_description": "np. przedziały czasowe do rezerwacji będą dostępne o {{ adjustedTime }} zamiast o {{ originalTime }}",
"offset_start_description": "np. przedziały czasowe do rezerwacji będą dostępne o {{ adjustedTime }}, zamiast o {{ originalTime }}",
"slot_interval": "Przedziały czasowe",
"slot_interval_default": "Użyj długości zdarzenia (domyślnie)",
"delete_event_type": "Usunąć typ wydarzenia?",
@@ -1073,8 +1071,8 @@
"specific_issue": "Masz określony problem?",
"browse_our_docs": "przeglądaj nasze dokumenty",
"choose_template": "Wybierz szablon",
"reminder": "Przypomnienie",
"custom": "Niestandard.",
"reminder": "Przypomnienie",
"reminder_email": "Przypomnienie: {{eventType}} z {{name}} w dniu {{date}}",
"not_triggering_existing_bookings": "Nie uruchomi się w przypadku już istniejących rezerwacji, ponieważ użytkownik zostanie poproszony o numer telefonu podczas rezerwacji wydarzenia.",
"minute_one": "{{count}} min",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Wraz z Cal.com zyskujesz przystępną i bezpłatną licencję AGPLv3 z pewnymi ograniczeniami, z której można w każdej chwili przejść na licencję dla przedsiębiorstw. Na wyższą wersję możesz przejść w każdej chwili.",
"license": "Licencja",
"agplv3_license": "Licencja AGPLv3",
"ee_enterprise_license": "Licencja dla przedsiębiorstw „/ee”",
"enterprise_booking_fee": "Od {{enterprise_booking_fee}}/miesiąc",
"enterprise_license_includes": "Wszystko, co niezbędne do zastosowań komercyjnych",
"no_need_to_keep_your_code_open_source": "Twój kod nie musi pozostawać otwarty.",
"repackage_rebrand_resell": "Łatwa modyfikacja wyglądu, marki i możliwość odsprzedaży",
"a_vast_suite_of_enterprise_features": "Duży wybór funkcji dla przedsiębiorstw",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "Adres URL /{{slug}} już istnieje dla {{count}} członka. Czy chcesz go zastąpić?",
"managed_event_dialog_title_other": "Adres URL /{{slug}} już istnieje dla {{count}} członków. Czy chcesz go zastąpić?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> używa już adresu URL <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> używają już adresu URL <strong>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Jeśli zdecydujesz się go zastąpić, powiadomimy te osoby. Wróć i usuń je, jeśli nie chcesz zastępować adresu.",
"review_event_type": "Sprawdź typ wydarzenia",
"looking_for_more_analytics": "Szukasz dokładniejszych danych analitycznych?",
@@ -1733,7 +1727,7 @@
"payment_app_commission": "Wymagaj płatności ({{paymentFeePercentage}}% + {{fee, currency}} prowizji od transakcji)",
"email_invite_team": "Zaproszono {{email}}",
"email_invite_team_bulk": "Zaproszono {{userCount}} użytkowników",
"error_collecting_card": "Błąd podczas pobierania karty",
"error_collecting_card": "Błąd podczas pobierania należności z karty",
"image_size_limit_exceed": "Rozmiar przesyłanego obrazu nie powinien przekraczać limitu 5 MB",
"inline_embed": "Osadź w stronie",
"load_inline_content": "Wczytuje typ wydarzenia bezpośrednio w treści Twojej strony internetowej.",
@@ -1742,7 +1736,7 @@
"pop_up_element_click": "Okienko wyskakujące po kliknięciu elementu",
"open_dialog_with_element_click": "Otwiera okno dialogowe Cal, gdy użytkownik kliknie odpowiedni element.",
"need_help_embedding": "Potrzebujesz pomocy? Zapoznaj się z naszymi instrukcjami osadzania narzędzi Cal w stronach utworzonych w usługach Wix, Squarespace lub WordPress, sprawdź często zadawane pytania lub poznaj zaawansowane opcje osadzania.",
"book_my_cal": "Zarezerwuj w moim kalendarzu",
"book_my_cal": "Zarezerwuj za pośrednictwem Cal",
"invite_as": "Zaproś jako",
"form_updated_successfully": "Formularz został zaktualizowany.",
"disable_attendees_confirmation_emails": "Wyłącz domyślne wiadomości e-mail z potwierdzeniem dla uczestników",
@@ -11,11 +11,21 @@
"calcom_explained_new_user": "Termine de configurar sua conta do {{appName}}! Falta pouco para você solucionar todos os seus problemas de agendamento.",
"have_any_questions": "Precisa de ajuda? Estamos aqui para ajudar.",
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
"verify_email_subject": "{{appName}}: Verificar sua conta",
"check_your_email": "Verifique seu e-mail",
"verify_email_email_header": "Verifique seu endereço de e-mail",
"verify_email_email_button": "Verificar e-mail",
"verify_email_email_body": "Por favor, verifique seu endereço de e-mail clicando no botão abaixo.",
"verify_email_email_link_text": "Aqui está o link caso você não goste de usar botões:",
"email_sent": "E-mail enviado com sucesso",
"event_declined_subject": "Recusado: {{title}} em {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
"event_request_declined": "Sua solicitação de reunião foi recusada",
"event_request_declined_recurring": "Sua solicitação de evento recorrente foi recusada",
"event_request_cancelled": "Seu evento agendado foi cancelado",
"organizer": "Organizador",
"need_to_reschedule_or_cancel": "Precisa reagendar ou cancelar?",
"no_options_available": "Sem opções disponíveis",
"cancellation_reason": "Motivo do cancelamento",
"cancellation_reason_placeholder": "Por que você está cancelando?",
"rejection_reason": "Motivo da recusa",
@@ -76,6 +86,7 @@
"event_type_has_been_rescheduled_on_time_date": "Seu {{title}} foi remarcado para {{date}}.",
"event_has_been_rescheduled": "Atualização - O seu evento foi reagendado",
"request_reschedule_title_organizer": "Você solicitou que {{attendee}} reagendasse",
"request_reschedule_subtitle_organizer": "Você cancelou a reserva e {{attendee}} escolherá um novo horário.",
"rescheduled_event_type_subject": "Solicitação de reagendamento enviado: {{eventType}} com {{name}} às {{date}}",
"requested_to_reschedule_subject_attendee": "Ação de reagendamento necessária: reserve outro horário para {{eventType}} com {{name}}",
"hi_user_name": "Olá {{name}}",
@@ -180,6 +191,7 @@
"blog": "Blog",
"blog_description": "Leia as nossas últimas notícias e artigos",
"join_our_community": "Junte-se à nossa comunidade",
"join_our_discord": "Junte-se ao nosso Discord",
"404_claim_entity_user": "Obtenha o seu nome de usuário e agende eventos",
"popular_pages": "Páginas populares",
"register_now": "Inscreva-se agora",
@@ -222,6 +234,7 @@
"done": "Concluído",
"all_done": "Tudo pronto!",
"all": "Todos os apps",
"yours": "Sua conta",
"available_apps": "Aplicativos disponíveis",
"check_email_reset_password": "Verifique o seu email. Enviamos um link para redefinir a sua senha.",
"finish": "Finalizar",
@@ -282,6 +295,9 @@
"success": "Sucesso",
"failed": "Falhou",
"password_has_been_reset_login": "A sua senha foi redefinida. Agora pode fazer login com a sua senha recém-criada.",
"bookerlayout_month_view": "Mês",
"bookerlayout_week_view": "Semanalmente",
"bookerlayout_column_view": "Coluna",
"unexpected_error_try_again": "Ocorreu um erro inesperado. Tente novamente.",
"sunday_time_error": "Horário inválido no domingo",
"monday_time_error": "Horário inválido na segunda-feira",
@@ -294,6 +310,7 @@
"error_end_time_next_day": "O horário de término não pode ser maior do que 24 horas",
"back_to_bookings": "Voltar às Reservas",
"free_to_pick_another_event_type": "Sinta-se à vontade para escolher outro evento a qualquer hora.",
"cancelled": "Cancelado",
"cancellation_successful": "Cancelado com Sucesso",
"really_cancel_booking": "Deseja realmente cancelar a sua reserva?",
"cannot_cancel_booking": "Você não pode cancelar esta reserva",
@@ -315,6 +332,7 @@
"upcoming_bookings": "Assim que alguém reservar uma hora, ela aparecerá aqui.",
"recurring_bookings": "Assim que alguém reservar uma reunião recorrente com você, essa pessoa aparecerá aqui.",
"past_bookings": "As suas reservas anteriores aparecerão aqui.",
"cancelled_bookings": "Suas reservas canceladas aparecerão aqui.",
"unconfirmed_bookings": "Suas reservas não confirmadas aparecerão aqui.",
"unconfirmed_bookings_tooltip": "Reservas não confirmadas",
"on": "em",
@@ -428,7 +446,6 @@
"max_limit_allowed_hint": "O tamanho deve ser de {{limit}} ou menos caracteres",
"invalid_password_hint": "A senha precisa ter pelo menos sete caracteres com pelo menos um número e ser uma combinação de letras maiúsculas e minúsculas",
"incorrect_password": "Senha incorreta.",
"incorrect_username_password": "A senha ou o nome de usuário estão incorretos.",
"use_setting": "Usar configurações",
"am_pm": "manhã/tarde",
"time_options": "Opções de horário",
@@ -456,6 +473,7 @@
"additional_guests": "+ Participantes Adicionais",
"your_name": "Seu nome",
"your_full_name": "Seu nome completo",
"no_name": "Sem nome",
"email_address": "Endereço de e-mail",
"enter_valid_email": "Insira um e-mail válido",
"location": "Local",
@@ -499,10 +517,13 @@
"your_team_updated_successfully": "Seu time foi atualizado com sucesso.",
"about": "Sobre",
"team_description": "Fale um pouco sobre seu time. Isso aparecerá na página pública do seu time.",
"org_description": "Uma breve descrição sobre sua organização. Isso aparecerá na página principal da sua organização.",
"members": "Membros",
"organization_members": "Membros da organização",
"member": "Membro",
"number_member_one": "{{count}} membro",
"number_member_other": "{{count}} membros",
"number_selected": "{{count}} selecionados",
"owner": "Proprietário",
"admin": "Admin",
"administrator_user": "Usuário administrador",
@@ -639,10 +660,11 @@
"create_new_team": "Criar um novo time",
"open_invitations": "Convites enviados",
"new_team": "Novo time",
"create_first_team_and_invite_others": "Crie o seu primeiro time e convide outros usuários para trabalhar em conjunto.",
"create_first_team_and_invite_others": "Crie seu primeiro time e convide outros usuários para trabalharem com você.",
"create_team_to_get_started": "Criar um time para começar",
"teams": "Times",
"team": "Equipe",
"organization": "Organização",
"team_billing": "Cobrança da equipe",
"team_billing_description": "Gerenciar cobrança para sua equipe",
"upgrade_to_flexible_pro_title": "Alteramos a cobrança para equipes",
@@ -717,6 +739,7 @@
"cal_provide_huddle01_meeting_url": "O {{appName}} fornecerá um URL de reunião do Huddle01 Web3 video.",
"cal_provide_teams_meeting_url": "O {{appName}} enviará um URL da reunião para o MS Teams. OBSERVAÇÃO: É PRECISO TER UMA CONTA ESCOLAR OU CORPORATIVA",
"require_payment": "Requerer Pagamento",
"you_need_to_add_a_name": "Você precisa adicionar um nome",
"commission_per_transaction": "comissão por transação",
"event_type_updated_successfully_description": "O seu tipo de evento foi atualizado com sucesso.",
"hide_event_type": "Ocultar Tipo de Evento",
@@ -808,6 +831,7 @@
"free_to_use_apps": "Grátis",
"no_category_apps": "Sem aplicativos de {{category}}",
"no_category_apps_description_calendar": "Adicione um aplicativo de calendário para verificar se há conflitos para evitar agendamentos duplos",
"no_category_apps_description_conferencing": "Tente adicionar um aplicativo para chamadas de vídeo com seus clientes",
"no_category_apps_description_payment": "Adicione um aplicativo de pagamento para facilitar a transação entre você e seus clientes",
"no_category_apps_description_analytics": "Adicione um aplicativo de análise às suas páginas de reservas",
"no_category_apps_description_automation": "Adicione um aplicativo de automação para usar",
@@ -1073,8 +1097,8 @@
"specific_issue": "Tem um problema específico?",
"browse_our_docs": "pesquisar nos nossos documentos",
"choose_template": "Escolher um modelo",
"reminder": "Lembrete",
"custom": "Personalizado",
"reminder": "Lembrete",
"reminder_email": "Lembrete: {{eventType}} com {{name}} em {{date}}",
"not_triggering_existing_bookings": "Não será acionado para reservas já existentes, pois o usuário deverá fornecer o número de telefone ao fazer um pedido no evento.",
"minute_one": "{{count}} minuto",
@@ -1528,9 +1552,6 @@
"choose_license_description": "O Cal.com vem com uma licença AGPLv3 acessível e gratuita, mas tem algumas limitações, que podem ser resolvidas atualizando para a licença Enterprise a qualquer momento. Você poderá fazer isso mais tarde, quando quiser.",
"license": "Licença",
"agplv3_license": "Licença AGPLv3",
"ee_enterprise_license": "“/ee” Licença Enterprise",
"enterprise_booking_fee": "A partir de {{enterprise_booking_fee}}/mês",
"enterprise_license_includes": "Tudo para um caso de uso comercial",
"no_need_to_keep_your_code_open_source": "Não precisa manter seu código em código aberto",
"repackage_rebrand_resell": "Reempacote, troque de marca e revenda com facilidade",
"a_vast_suite_of_enterprise_features": "Uma ampla variedade de recursos empresariais",
@@ -1679,7 +1700,6 @@
"managed_event_dialog_title_one": "O url/{{slug}} já existe para {{count}} membro. Deseja substituir?",
"managed_event_dialog_title_other": "O url/{{slug}} já existe para {{count}} membros. Deseja substituir?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> já está usando o url <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> já estão usando o url <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Se você escolher substituir, serão enviadas notificações. Volte e remova caso não queira sobrescrever.",
"review_event_type": "Revisar tipo de evento",
"looking_for_more_analytics": "Procurando mais análises?",
+78 -10
View File
@@ -11,6 +11,8 @@
"calcom_explained_new_user": "Conclua a configuração da sua conta {{appName}}! Está apenas a alguns passos de resolver todos os seus problemas com agendamentos.",
"have_any_questions": "Tem perguntas? Estamos disponíveis para ajudar.",
"reset_password_subject": "{{appName}}: Instruções de redefinição da senha",
"verify_email_subject": "{{appName}}: Verificar sua conta",
"verify_email_page_body": "Nós enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir que você receberá as comunicações de {{appName}}.",
"event_declined_subject": "Declinado: {{title}} em {{date}}",
"event_request_declined": "O seu pedido de evento foi declinado",
"event_request_declined_recurring": "O seu pedido de evento recorrente foi recusado",
@@ -302,6 +304,12 @@
"cancelling_event_recurring": "O evento é uma instância de um evento recorrente.",
"cancelling_all_recurring": "Estas são todas as restantes instâncias do evento recorrente.",
"error_with_status_code_occured": "Ocorreu um erro com o código de estado {{status}}",
"error_event_type_url_duplicate": "Um tipo de evento com essa URL já existe.",
"error_event_type_unauthorized_create": "Você não tem autorização para criar esse evento",
"error_event_type_unauthorized_update": "Você não tem autorização para editar esse evento",
"error_workflow_unauthorized_create": "Você não tem autorização para criar esse evento",
"error_schedule_unauthorized_create": "Você não tem autorização para criar essa agenda",
"booking_already_cancelled": "Esta reserva já foi cancelada",
"booking_already_accepted_rejected": "Esta reserva já foi aceite ou rejeitada",
"go_back_home": "Voltar à Página Inicial",
"or_go_back_home": "Ou volte à página inicial",
@@ -311,10 +319,12 @@
"no_status_bookings_yet_description": "Não tem reservas {{status}}. {{description}}",
"event_between_users": "{{eventName}} entre {{host}} e {{attendeeName}}",
"bookings": "Reservas",
"booking_not_found": "Reserva não encontrada",
"bookings_description": "Veja os eventos futuros e passados reservados através das ligações de tipos de eventos.",
"upcoming_bookings": "Assim que alguém reservar uma hora, ela aparecerá aqui.",
"recurring_bookings": "Assim que alguém reservar uma reunião recorrente, será mostrada aqui.",
"past_bookings": "As suas reservas anteriores aparecerão aqui.",
"cancelled_bookings": "Suas reservas canceladas aparecerão aqui.",
"unconfirmed_bookings": "As suas marcações não confirmadas irão aparecer aqui.",
"unconfirmed_bookings_tooltip": "Reservas não confirmadas",
"on": "em",
@@ -354,9 +364,12 @@
"browse_api_documentation": "Consulte a nossa documentação da API",
"leverage_our_api": "Tire partido da nossa API para controlo e personalização total.",
"create_webhook": "Criar Webhook",
"booking_cancelled": "Reserva Cancelada",
"booking_rescheduled": "Reserva Reagendada",
"recording_ready": "Ligação de transferência da gravação pronta",
"booking_created": "Reserva Criada",
"booking_rejected": "Reserva Rejeitada",
"booking_requested": "Reserva Rejeitada",
"meeting_ended": "A reunião terminou",
"form_submitted": "Formulário enviado",
"event_triggers": "Causadores de eventos",
@@ -428,7 +441,6 @@
"max_limit_allowed_hint": "Pode ter um máximo de {{limit}} caracteres",
"invalid_password_hint": "A palavra-passe deve ter no mínimo {{passwordLength}} caracteres, com pelo menos um número e uma mistura de letras maiúsculas e minúsculas",
"incorrect_password": "Palavra-Passe incorreta",
"incorrect_username_password": "O nome de utilizador ou a palavra-passe estão incorretos.",
"use_setting": "Usar Configuração",
"am_pm": "am/pm",
"time_options": "Opções de Hora",
@@ -456,6 +468,7 @@
"additional_guests": "+ Convidados Adicionais",
"your_name": "O seu nome",
"your_full_name": "O seu nome completo",
"no_name": "Sem nome",
"email_address": "Endereço de email",
"enter_valid_email": "Insira um e-mail válido",
"location": "Localização",
@@ -639,10 +652,10 @@
"create_new_team": "Criar uma Equipa",
"open_invitations": "Convites Abertos",
"new_team": "Nova Equipa",
"create_first_team_and_invite_others": "Crie a sua primeira equipa e convide outros utilizadores para trabalhar em conjunto.",
"create_team_to_get_started": "Criar uma equipa para começar",
"teams": "Equipas",
"team": "Equipa",
"organization": "Organização",
"team_billing": "Facturação da equipa",
"team_billing_description": "Faça a gestão da faturação da sua equipa",
"upgrade_to_flexible_pro_title": "Alterámos os termos de facturação",
@@ -807,18 +820,23 @@
"installed_apps": "Apps instaladas",
"free_to_use_apps": "Grátis",
"no_category_apps": "Sem aplicações de {{category}}",
"all_apps": "Todas as aplicações",
"no_category_apps_description_calendar": "Adicione uma aplicação de calendário para verificar se existem conflitos e assim evitar sobreposição de marcações",
"no_category_apps_description_payment": "Adicione uma aplicação de pagamentos para facilitar a transação entre si e os seus clientes",
"no_category_apps_description_analytics": "Adicionar uma aplicação de análise às suas páginas de reservas",
"no_category_apps_description_automation": "Adicionar uma aplicação de automatização a utilizar",
"no_category_apps_description_other": "Adicione qualquer outro tipo de aplicações para fazer todos os tipos de coisas",
"no_category_apps_description_web3": "Adicione uma aplicação web3 às suas páginas de reservas",
"no_category_apps_description_crm": "Adicione um aplicativo CRM para manter o controle de com quem você encontrou",
"installed_app_calendar_description": "Defina o(s) calendário(s) para verificar se existem conflitos e assim evitar marcações sobrepostas.",
"installed_app_payment_description": "Configure os serviços de processamento de pagamentos que serão utilizados nas cobranças aos seus clientes.",
"installed_app_analytics_description": "Configurar as aplicações de análise a utilizar nas suas páginas de reservas",
"installed_app_other_description": "Todas as aplicações instaladas de outras categorias.",
"installed_app_conferencing_description": "Configure que aplicativos de conferência utilizar",
"installed_app_automation_description": "Configurar as aplicações de automatização a utilizar",
"installed_app_web3_description": "Configure as aplicações web3 a utilizar nas suas páginas de reservas",
"installed_app_messaging_description": "Configure que aplicativos de mensagens utilizar para configurar notificações e lembretes personalizados",
"installed_app_crm_description": "Configure que aplicativos de CRM utilizar para manter o controle de com quem você encontrou",
"analytics": "Estatísticas",
"empty_installed_apps_headline": "Nenhuma aplicação instalada",
"empty_installed_apps_description": "As aplicações permitem-lhe melhorar o seu fluxo de trabalho e os seus agendamentos, significativamente.",
@@ -913,6 +931,7 @@
"confirm_delete_api_key": "Revogar esta chave de API",
"revoke_api_key": "Revogar chave de API",
"api_key_copied": "Chave de API copiada!",
"api_key_expires_on": "A chave de API expirará em",
"delete_api_key_confirm_title": "Remover de forma permanente esta chave de API da sua conta?",
"copy": "Copiar",
"expire_date": "Data de expiração",
@@ -1002,16 +1021,21 @@
"event_location": "Localização do evento",
"reschedule_optional": "Razão do reagendamento (opcional)",
"reschedule_placeholder": "Informe os restantes para que saibam porque precisa de reagendar",
"event_cancelled": "Esse evento foi cancelado",
"emailed_information_about_cancelled_event": "Enviámos um email com o aviso para si e para os restantes participantes.",
"this_input_will_shown_booking_this_event": "Este campo será mostrado na reserva deste evento",
"meeting_url_in_conformation_email": "O URL da reunião está no email de confirmação",
"url_start_with_https": "O URL tem de começar por http:// ou https://",
"number_provided": "Será fornecido um número de telefone",
"before_event_trigger": "antes do evento iniciar",
"event_cancelled_trigger": "quando o evento for cancelado",
"new_event_trigger": "quando um novo evento for reservado",
"email_host_action": "enviar email ao anfitrião",
"email_attendee_action": "enviar e-mail aos participantes",
"sms_attendee_action": "Enviar SMS ao participante",
"sms_number_action": "enviar SMS para um número específico",
"whatsapp_number_action": "enviar Whatsapp para um número específico",
"whatsapp_attendee_action": "enviar Whatsapp ao participante",
"workflows": "Fluxos de trabalho",
"new_workflow_btn": "Novo fluxo de trabalho",
"add_new_workflow": "Adicionar novo fluxo de trabalho",
@@ -1073,8 +1097,10 @@
"specific_issue": "Tem um problema específico?",
"browse_our_docs": "consulte a nossa documentação",
"choose_template": "Escolha um modelo",
"reminder": "Lembrete",
"custom": "Personalizado",
"reminder": "Lembrete",
"rescheduled": "Reagendado",
"completed": "Concluído",
"reminder_email": "Lembrete: {{eventType}} com {{name}} em {{date}}",
"not_triggering_existing_bookings": "Não será accionado para reservas já existentes, uma vez que será pedido o número de telefone ao utilizador ao reservar o evento.",
"minute_one": "{{count}} minuto",
@@ -1213,6 +1239,7 @@
"conferencing_description": "Adicione as suas aplicações de videoconferência para reuniões",
"add_conferencing_app": "Adicionar aplicação de conferência",
"password_description": "Faça a gestão das definições para as palavras-passe da sua conta",
"set_up_two_factor_authentication": "Configure sua autenticação em duas etapas",
"we_just_need_basic_info": "Precisamos apenas de algumas informações básicas para configurar o seu perfil.",
"skip": "Ignorar",
"do_this_later": "Fazer isto mais tarde",
@@ -1226,6 +1253,8 @@
"connect_analytics_apps": "Associar aplicações de estatística",
"connect_other_apps": "Associe outras aplicações",
"connect_web3_apps": "Associe aplicações web3",
"connect_messaging_apps": "Conecte aplicações de mensagens",
"connect_crm_apps": "Conecte aplicações de CRM",
"current_step_of_total": "Passo {{currentStep}} de {{maxSteps}}",
"add_variable": "Adicionar variável",
"custom_phone_number": "Número do telefone personalizado",
@@ -1238,12 +1267,14 @@
"location_info": "O local do evento",
"additional_notes_info": "As notas adicionais da reserva",
"attendee_name_info": "O nome do responsável pela reserva",
"organizer_name_info": "Nome do(a) organizador(a)",
"to": "Para",
"workflow_turned_on_successfully": "Fluxo de trabalho {{workflowName}} {{offOn}} com sucesso",
"download_responses": "Descarregar respostas",
"download_responses_description": "Descarregue todas as respostas ao seu formulário em formato CSV.",
"download": "Descarregar",
"download_recording": "Transferir gravação",
"recording_from_your_recent_call": "A gravação da sua chamada recente em {{appName}} está pronta para download",
"create_your_first_form": "Crie o seu primeiro formulário",
"create_your_first_form_description": "Com os Formulários de encaminhamento, pode fazer questões qualitativas e encaminhar para a pessoa ou o tipo de evento correto.",
"create_your_first_webhook": "Crie o seu primeiro Webhook",
@@ -1252,7 +1283,9 @@
"event_one": "evento",
"event_other": "eventos",
"profile_team_description": "Faça a gestão das definições do seu perfil de equipa",
"profile_org_description": "Gerencie as configurações do perfil da sua organização",
"members_team_description": "Utilizadores que estão no grupo",
"organization_description": "Gerencie os membros e administradores da sua organização",
"team_url": "Endereço da equipa",
"team_members": "Membros da equipa",
"more": "Mais",
@@ -1378,6 +1411,7 @@
"new_event_type_availability": "Disponibilidade de {{eventTypeTitle}}",
"error_editing_availability": "Erro ao editar a disponibilidade",
"dont_have_permission": "Não tem permissão para aceder a esta funcionalidade.",
"saml_config": "SAML",
"saml_configuration_placeholder": "Cole aqui os metadados de SAML do seu fornecedor de identidade",
"saml_email_required": "Por favor insira um email para que seja possível encontrar o seu fornecedor de identidade de SAML",
"saml_sp_title": "Detalhes do Fornecedor do serviço",
@@ -1398,6 +1432,7 @@
"your_team_disbanded_successfully": "A sua equipa foi dissolvida com sucesso",
"error_creating_team": "Erro ao criar a equipa",
"you": "Você",
"resend_email": "Reenviar e-mail",
"member_already_invited": "O membro já foi convidado",
"enter_email_or_username": "Especifique um e-mail ou nome de utilizador",
"team_name_taken": "Este nome já está a ser utilizado",
@@ -1405,6 +1440,7 @@
"team_url_required": "Deve especificar um endereço para a equipa",
"url_taken": "Este endereço já está a ser utilizado",
"team_publish": "Publicar equipa",
"number_text_notifications": "Número de telefone (notificações de texto)",
"attendee_email_variable": "E-mail do participante",
"attendee_email_info": "O e-mail do responsável pela reserva",
"kbar_search_placeholder": "Digite um comando ou pesquise...",
@@ -1427,6 +1463,8 @@
"find_the_best_person": "Encontre a melhor pessoa disponível e faça um ciclo pela sua equipa.",
"fixed_round_robin": "Distribuição equilibrada fixa",
"add_one_fixed_attendee": "Adicione um participante fixo e distribua de forma equilibrada a partir de um número de participantes.",
"calcom_is_better_with_team": "{{appName}} é melhor com times",
"the_calcom_team": "A equipe {{companyName}}",
"add_your_team_members": "Adicione os membros da sua equipa aos seus tipos de eventos. Utilize o agendamento coletivo para incluir todos os membros ou encontre a pessoa mais adequada com o agendamento baseado numa distribuição equilibrada.",
"booking_limit_reached": "O limite de reservas para este tipo de evento foi atingido",
"duration_limit_reached": "O limite de duração para este tipo de evento foi atingido",
@@ -1445,6 +1483,7 @@
"navigate_installed_apps": "Ir para as aplicações instaladas",
"disabled_calendar": "Se tiver outro calendário instalado, as novas reservas serão adicionadas ao mesmo. Se não for o caso, deve associar um novo calendário para garantir que não perde novas reservas.",
"enable_apps": "Ativar aplicações",
"enable_apps_description": "Ative aplicativos que usuários podem integrar com {{appName}}",
"purchase_license": "Adquira uma Licença",
"already_have_key": "Eu já tenho uma chave:",
"already_have_key_suggestion": "Por favor, copie a sua variável de ambiente CALCOM_LICENSE_KEY existente aqui.",
@@ -1458,6 +1497,9 @@
"no_available_apps": "Não existem aplicações disponíveis",
"no_available_apps_description": "Garanta que existem aplicações na sua instalação em 'packages/app-store'",
"no_apps": "Não existem aplicações ativas nesta instância do Cal",
"no_apps_configured": "Nenhuma aplicação foi configurada ainda",
"enable_in_settings": "Você pode habilitar os aplicatovs nas configurações",
"please_contact_admin": "Por favor, contate seu administrador",
"apps_settings": "Definições de aplicações",
"fill_this_field": "Por favor, preencha este campo",
"options": "Opções",
@@ -1528,9 +1570,6 @@
"choose_license_description": "A Cal.com vem com uma licença AGPLv3 acessível e gratuita, na qual algumas limitações podem ser ultrapassadas a qualquer momento com uma licença Enterprise. Pode atualizar para a versão Enterprise mais tarde e quando quiser.",
"license": "Licença",
"agplv3_license": "Licença AGPLv3",
"ee_enterprise_license": "Licença Enterprise “/ee”",
"enterprise_booking_fee": "A partir de {{enterprise_booking_fee}}/mês",
"enterprise_license_includes": "Tudo para uma utilização comercial",
"no_need_to_keep_your_code_open_source": "Não há necessidade de manter o seu código aberto",
"repackage_rebrand_resell": "Republique, personalize a marca e revenda com facilidade",
"a_vast_suite_of_enterprise_features": "Um vasto conjunto de funcionalidades empresariais",
@@ -1595,6 +1634,7 @@
"booking_questions_title": "Questões sobre a reserva",
"booking_questions_description": "Personalize as questões colocadas na página da reserva",
"add_a_booking_question": "Adicionar uma questão",
"identifier": "Identificador",
"duplicate_email": "O e-mail está duplicado",
"booking_with_payment_cancelled": "Já não é possível pagar para este evento",
"booking_with_payment_cancelled_already_paid": "Está a ser processado um reembolso do pagamento desta reserva.",
@@ -1641,6 +1681,7 @@
"switch_monthly": "Mudar para a vista mensal",
"switch_weekly": "Mudar para a vista semanal",
"switch_multiday": "Mudar para a vista diária",
"switch_columnview": "Mudar para visualização em coluna",
"num_locations": "{{num}} opções de localização",
"select_on_next_step": "Selecione no próximo passo",
"this_meeting_has_not_started_yet": "Esta reunião ainda não começou",
@@ -1679,7 +1720,6 @@
"managed_event_dialog_title_one": "A URL /{{slug}} já existe para {{count}} membro. Pretende substituir a mesma?",
"managed_event_dialog_title_other": "A URL /{{slug}} já existe para {{count}} membros. Pretende substituir a mesma?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> já está a utilizar a URL <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> já estão a utilizar a URL <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Se optar pela substituição, iremos notificá-los. Retroceda e remova-os se não quiser substituir.",
"review_event_type": "Rever Tipo de Evento",
"looking_for_more_analytics": "À procura de mais análises?",
@@ -1695,12 +1735,14 @@
"least_booked_members": "Membros com menos reservas",
"events_created": "Eventos criados",
"events_completed": "Eventos concluídos",
"events_cancelled": "Eventos Cancelados",
"events_rescheduled": "Eventos reagendados",
"from_last_period": "do último período",
"from_to_date_period": "De: {{startDate}} A: {{endDate}}",
"redirect_url_warning": "Ao adicionar um redirecionamento irá desativar a página de sucesso. Certifique-se que indica que \"A reserva foi confirmada\" na sua página de sucesso personalizada.",
"event_trends": "Tendências de evento",
"clear_filters": "Limpar filtros",
"clear": "Limpar",
"hold": "Reter",
"on_booking_option": "Receber o pagamento na reserva",
"hold_option": "Cobrar taxa de não-comparência",
@@ -1727,18 +1769,21 @@
"complete_your_booking": "Concluir a sua reserva",
"complete_your_booking_subject": "Concluir a sua reserva: {{title}} a {{date}}",
"confirm_your_details": "Confirme os seus dados",
"copy_invite_link": "Copiar link do convite",
"edit_invite_link": "Editar configurações do link",
"invite_link_copied": "Link do convite copiado",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Está em vias de cobrar {{amount, currency}} ao participante. Tem a certeza que pretende continuar?",
"charge_attendee": "Cobrar {{amount, currency}} ao participante",
"payment_app_commission": "Exigir pagamento ({{paymentFeePercentage}}% + {{fee, currency}} de comissão por transação)",
"email_invite_team": "{{email}} foi convidado/a",
"email_invite_team_bulk": "Foram convidados {{userCount}} utilizadores",
"error_collecting_card": "Erro ao recolher o cartão",
"error_collecting_card": "Erro ao processar o cartão",
"image_size_limit_exceed": "A imagem enviada não deve exceder o limite de tamanho de 5MB",
"inline_embed": "Incorporação em linha",
"load_inline_content": "Carrega o seu tipo de evento diretamente e em linha, em conjunto com os conteúdos do seu outro site.",
"floating_pop_up_button": "Botão pop-up flutuante",
"floating_button_trigger_modal": "Coloca um botão flutuante no seu site que ativa uma modal com o seu tipo de evento.",
"floating_button_trigger_modal": "Coloca um botão flutuante no seu site que ativa uma janela modal com o seu tipo de evento.",
"pop_up_element_click": "Pop-up via clique no elemento",
"open_dialog_with_element_click": "Abrir a sua janela do Cal quando alguém clicar num elemento.",
"need_help_embedding": "Precisa de ajuda? Consulte os nossos guias para incorporar o Cal no Wix, Squarespace, ou WordPress. Consulte ainda as nossas perguntas frequentes ou explore opções avançadas de incorporação.",
@@ -1755,5 +1800,28 @@
"google_workspace_admin_tooltip": "Tem de ser um Administrador do Workspace para utilizar esta funcionalidade",
"first_event_type_webhook_description": "Crie o seu primeiro webhook para este tipo de evento",
"create_for": "Criar para",
"insights_all_org_filter": "Todos as aplicações"
"organization_logo": "Logo da Organização",
"organization_about_description": "Breve descrição sobre sua organização. Isso vai aparecer na página de perfil pública da sua organização.",
"ill_do_this_later": "Farei isso depois",
"verify_your_email": "Verifique seu e-mail",
"enter_digit_code": "Entre o código de 6 dígitos enviado para {{email}}",
"verify_email_organization": "Verifique seu e-mail para criar uma organização",
"code_provided_invalid": "O código fornecido inválido, tente novamente",
"email_already_used": "E-mail já está sendo usado",
"duplicated_slugs_warning": "Os seguintes times não puderam ser criados devido a slugs duplicados: {{slugs}}",
"team_names_empty": "Nomes de equipes não podem estar vazios",
"team_names_repeated": "Nomes de equipes não podem se repetir",
"user_belongs_organization": "Usuário pertence a uma organização",
"org_no_teams_yet": "Essaorganização ainda não tem times",
"my_profile": "Meu perfil",
"my_settings": "Minhas configurações",
"crm": "CRM",
"messaging": "Mensagens",
"org_url": "URL da Organização",
"copy_link_org": "Copie o link para organização",
"404_the_org": "A organização",
"404_the_team": "O time",
"insights_all_org_filter": "Todos as aplicações",
"insights_team_filter": "Time: {{teamName}}",
"insights_user_filter": "Usuário {{userName}}"
}
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Trebuie să aibă {{limit}} sau mai puține caractere",
"invalid_password_hint": "Parola trebuie să aibă cel puțin {{passwordLength}} caractere, să conțină cel puțin o cifră și o combinație de litere mari și litere mici",
"incorrect_password": "Parola este incorectă.",
"incorrect_username_password": "Numele de utilizator sau parola este incorectă.",
"use_setting": "Folosește setarea",
"am_pm": "am/pm",
"time_options": "Opţiuni de timp",
@@ -639,7 +638,6 @@
"create_new_team": "Creează o echipă nouă",
"open_invitations": "Deschide invitațiile",
"new_team": "Echipă nouă",
"create_first_team_and_invite_others": "Creează-ți prima echipă și invită-i pe ceilalți utilizatori să lucreze împreună cu tine.",
"create_team_to_get_started": "Creează o echipă pentru a începe",
"teams": "Echipe",
"team": "Echipă",
@@ -1073,8 +1071,8 @@
"specific_issue": "Aveți o problemă specifică?",
"browse_our_docs": "răsfoiți documentele noastre",
"choose_template": "Alegeți un șablon",
"reminder": "Memento",
"custom": "Personalizat",
"reminder": "Memento",
"reminder_email": "Memento: {{eventType}} cu {{name}} la {{date}}",
"not_triggering_existing_bookings": "Nu se va declanșa pentru rezervările existente, deoarece utilizatorului i se va solicita numărul de telefon atunci când face rezervare pentru eveniment.",
"minute_one": "{{count}} minut",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com include o licență AGPLv3 accesibilă și gratuită, cu unele limitări, însă care poate fi actualizată la o licență Enterprise în orice moment. Puteți face upgrade oricând mai târziu.",
"license": "Licență",
"agplv3_license": "Licență AGPLv3",
"ee_enterprise_license": "Licență Enterprise „/ee”",
"enterprise_booking_fee": "Începând de la {{enterprise_booking_fee}}/lună",
"enterprise_license_includes": "Totul pentru uz comercial",
"no_need_to_keep_your_code_open_source": "Nu este nevoie să vă păstrați codul open-source",
"repackage_rebrand_resell": "Restructurați și revindeți cu ușurință, sub o nouă marcă",
"a_vast_suite_of_enterprise_features": "O gamă largă de caracteristici specifice întreprinderilor",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "URL-ul /{{slug}} există deja pentru {{count}} membru. Doriți să îl înlocuiți?",
"managed_event_dialog_title_other": "URL-ul /{{slug}} există deja pentru {{count}} (de) membri. Doriți să îl înlocuiți?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> folosește deja URL-ul <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> folosesc deja URL-ul <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Îi vom anunța dacă alegeți să îl înlocuiți. Reveniți și ștergeți-i dacă nu doriți să îl suprascrieți.",
"review_event_type": "Revizuiți tipul de eveniment",
"looking_for_more_analytics": "Căutați mai multe analize?",
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Не более {{limit}} символов",
"invalid_password_hint": "Пароль должен быть не короче {{passwordLength}} символов и содержать заглавные и строчные буквы, а также не менее одной цифры",
"incorrect_password": "Неверный пароль",
"incorrect_username_password": "Неверное имя пользователя или пароль.",
"use_setting": "Использовать настройки",
"am_pm": "am/pm",
"time_options": "Настройки времени",
@@ -639,7 +638,6 @@
"create_new_team": "Создание новой команды",
"open_invitations": "Открыть список приглашений",
"new_team": "Новая команда",
"create_first_team_and_invite_others": "Создайте свою первую команду и пригласите других пользователей работать вместе с вами.",
"create_team_to_get_started": "Создайте команду, чтобы начать работу",
"teams": "Команды",
"team": "Команда",
@@ -1073,8 +1071,8 @@
"specific_issue": "Возникла конкретная проблема?",
"browse_our_docs": "просмотрите нашу документацию",
"choose_template": "Выбрать шаблон",
"reminder": "Напоминание",
"custom": "Пользовательский",
"reminder": "Напоминание",
"reminder_email": "Напоминание: {{eventType}} с {{name}} {{date}}",
"not_triggering_existing_bookings": "Не работает для уже созданных бронирований, так как при бронировании события пользователь должен будет ввести номер телефона.",
"minute_one": "{{count}} минута",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com предоставляется под доступной и бесплатной лицензией AGPLv3, имеющей некоторые ограничения. Вы можете перейти на лицензию Enterprise сейчас или позже.",
"license": "Лицензия",
"agplv3_license": "Лицензия AGPLv3",
"ee_enterprise_license": "Enterprise-лицензия «/ee»",
"enterprise_booking_fee": "От {{enterprise_booking_fee}}/месяц",
"enterprise_license_includes": "Всё для коммерческого использования",
"no_need_to_keep_your_code_open_source": "Нет необходимости оставлять исходный код открытым",
"repackage_rebrand_resell": "Простая переупаковка, ребрендинг и перепродажа",
"a_vast_suite_of_enterprise_features": "Множество функций для предприятий",
@@ -1676,10 +1671,9 @@
"connect_now": "Подключить",
"managed_event_dialog_confirm_button_one": "Заменить и уведомить {{count}} участника",
"managed_event_dialog_confirm_button_other": "Заменить и уведомить участников ({{count}})",
"managed_event_dialog_title_one": "URL-адрес /{{slug}} для {{count}} участника уже существует. Заменить?",
"managed_event_dialog_title_other": "URL-адрес /{{slug}} для {{count}} участников уже существует. Заменить?",
"managed_event_dialog_title_one": "URL-адрес /{{slug}} уже используется {{count}} участником. Заменить?",
"managed_event_dialog_title_other": "URL-адрес /{{slug}} уже используется {{count}} участниками. Заменить?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> уже используют URL-адрес <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> уже используют URL-адрес <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "В случае замены мы уведомим пользователей. Если вы не хотите выполнять перезапись, вернитесь назад и удалите типы событий.",
"review_event_type": "Пересмотреть тип события",
"looking_for_more_analytics": "Нужно больше аналитики?",
@@ -451,7 +451,6 @@
"max_limit_allowed_hint": "Mora da ima {{limit}} znakova ili manje",
"invalid_password_hint": "Lozinka mora da bude dugačka najmanje {{passwordLength}} znakova, da sadrži najmanje jedan broj i da ima mešavinu velikih i malih slova",
"incorrect_password": "Lozinka je netačna.",
"incorrect_username_password": "Korisničko ime ili lozinka su netačni.",
"use_setting": "Koristi podešavanje",
"am_pm": "am/pm",
"time_options": "Vremenske opcije",
@@ -662,7 +661,6 @@
"create_new_team": "Napravite novi tim",
"open_invitations": "Otvoreni pozivi za pridruživanje",
"new_team": "Novi Tim",
"create_first_team_and_invite_others": "Napravite svoj prvi tim i pozovite druge korisnike da radite zajedno.",
"create_team_to_get_started": "Napravite tim da biste počeli",
"teams": "Timovi",
"team": "Tim",
@@ -758,7 +756,7 @@
"offset_toggle": "Pomeri vremena početka",
"offset_toggle_description": "Pomeranje termina prikazanih osobama koje zakazuju za određeni broj minuta",
"offset_start": "Pomeri za",
"offset_start_description": "npr. ovo će prikazati termine vašim polaznicima u {{ adjustedTime }} umesto {{ originalTime }}",
"offset_start_description": "npr. ovo će prikazati termine vašim korisnicima u {{ adjustedTime }} umesto {{ originalTime }}",
"slot_interval": "Intervali vremenskih slotova",
"slot_interval_default": "Koristi dužinu događaja (uobičajeno podešavanje)",
"delete_event_type": "Izbriši tip događaja?",
@@ -1097,8 +1095,8 @@
"specific_issue": "Imate konkretan problem?",
"browse_our_docs": "pregledajte naša dokumenta",
"choose_template": "Izaberite predložak",
"reminder": "Podsetnik",
"custom": "Prilagođeno",
"reminder": "Podsetnik",
"reminder_email": "Podsetnik: {{eventType}} sa {{name}} datuma {{date}}",
"not_triggering_existing_bookings": "Neće pokrenuti za već postojeća zakazivanja jer će korisniku biti tražen broj telefona prilikom rezervacije događaja.",
"minute_one": "{{count}} minut",
@@ -1557,9 +1555,6 @@
"choose_license_description": "Cal.com dolazi sa pristupačnom i besplatnom AGPLv3 licencom čija ograničenja mogu da budu nadograđena na licencu za preduzeća u svakom trenutku. Možete u bilo kom trenutku kasnije da nadogradite.",
"license": "Licenca",
"agplv3_license": "AGPLv3 licenca",
"ee_enterprise_license": "“/ee” Licenca za preduzeća",
"enterprise_booking_fee": "Već od {{enterprise_booking_fee}}/mesečno",
"enterprise_license_includes": "Sve za komercijalnu upotrebu",
"no_need_to_keep_your_code_open_source": "Nije potrebno da vaš kôd bude open-source",
"repackage_rebrand_resell": "Prepakujte, rebrendirajte i lako ponovo prodajte",
"a_vast_suite_of_enterprise_features": "Ogroman izbor funkcija za preduzeća",
@@ -1671,7 +1666,7 @@
"timeslot_missing_cta": "Izaberite termin",
"switch_monthly": "Pređite na mesečni pregled",
"switch_weekly": "Pređite na nedeljni pregled",
"switch_multiday": "Pređite na dnevni pregled",
"switch_multiday": "Pređi na dnevni pregled",
"num_locations": "{{num}} opcije lokacije",
"select_on_next_step": "Izaberite na sledećem koraku",
"this_meeting_has_not_started_yet": "Ovaj sastanak još nije počeo",
@@ -1710,7 +1705,6 @@
"managed_event_dialog_title_one": "URL /{{slug}} već postoji za {{count}} člana. Da li želite da ga zamenite?",
"managed_event_dialog_title_other": "URL /{{slug}} već postoji za {{count}} članova. Da li želite da ga zamenite?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> već koristi <strong>/{{slug}}</strong> URL.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> već koriste <string>/{{slug}}</strong> URL.",
"managed_event_dialog_clarification": "Ako izaberete da ga zamenite, obavestićemo ih. Vratite se i uklonite ih ako ne želite da zamenite postojeće.",
"review_event_type": "Pregledaj tip događaja",
"looking_for_more_analytics": "Tražite još analitike?",
+6 -12
View File
@@ -355,7 +355,7 @@
"leverage_our_api": "Utnyttja vår API för full kontroll och anpassningsbarhet.",
"create_webhook": "Skapa webhook",
"booking_rescheduled": "Bokning ombokad",
"recording_ready": "Länk för nedladdning av inspelning klar",
"recording_ready": "Länk för att ladda ned inspelning är klar",
"booking_created": "Bokning skapad",
"meeting_ended": "Mötet avslutat",
"form_submitted": "Formulär skickat",
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Måste innehålla {{limit}} eller färre tecken",
"invalid_password_hint": "Lösenordet måste innehålla minst {{passwordLength}} tecken och har minst en siffra samt en blandning av versaler och gemener",
"incorrect_password": "Lösenordet är felaktigt.",
"incorrect_username_password": "Felaktigt användarnamn eller lösenord.",
"use_setting": "Använd inställning",
"am_pm": "am/pm",
"time_options": "Tidsinställningar",
@@ -451,7 +450,7 @@
"friday": "Fredag",
"saturday": "Lördag",
"sunday": "Söndag",
"all_booked_today": "Alla bokade.",
"all_booked_today": "Fullbokat.",
"slots_load_fail": "Det gick inte att ladda tillgängliga tidsluckor.",
"additional_guests": "Lägg till gäster",
"your_name": "Ditt namn",
@@ -639,7 +638,6 @@
"create_new_team": "Skapa ett nytt team",
"open_invitations": "Öppna inbjudningar",
"new_team": "Nytt team",
"create_first_team_and_invite_others": "Skapa ditt första team och bjud in andra användare att arbeta tillsammans med dig.",
"create_team_to_get_started": "Skapa ett team för att komma igång",
"teams": "Teams",
"team": "Team",
@@ -735,7 +733,7 @@
"offset_toggle": "Flytta fram starttider",
"offset_toggle_description": "Flytta fram tidsluckor som visas för bokare med ett angivet antal minuter",
"offset_start": "Flytta fram med",
"offset_start_description": "detta visar t.ex. tidsluckor för dina bokare vid {{ adjustedTime }} istället för {{ originalTime }}",
"offset_start_description": "detta visar t.ex. tidsluckor för dina bokare kl. {{ adjustedTime }} istället för kl. {{ originalTime }}",
"slot_interval": "Tid mellan bokningar",
"slot_interval_default": "Använd händelsens längd (standard)",
"delete_event_type": "Radera händelsetyp?",
@@ -1073,8 +1071,8 @@
"specific_issue": "Har du ett specifikt problem?",
"browse_our_docs": "bläddra i våra dokument",
"choose_template": "Välj en mall",
"reminder": "Påminnelse",
"custom": "Anpassad",
"reminder": "Påminnelse",
"reminder_email": "Påminnelse: {{eventType}} med {{name}} den {{date}}",
"not_triggering_existing_bookings": "Kommer inte utlösa för redan befintliga bokningar eftersom användaren kommer att bli ombedd att uppge telefonnummer när händelsen bokas.",
"minute_one": "{{count}} minut",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com har en tillgänglig och gratis AGPLv3-licens med vissa begränsningar som när som helst kan uppgraderas till en Enterprise-licens. Du kan uppgradera när som helst senare.",
"license": "Licens",
"agplv3_license": "AGPLv3-licens",
"ee_enterprise_license": "\"/ee\" Enterprise-licens",
"enterprise_booking_fee": "Från {{enterprise_booking_fee}}/månad",
"enterprise_license_includes": "Allt för ett kommersiellt användningsfall",
"no_need_to_keep_your_code_open_source": "Behöver inte behålla din kod som öppen källkod",
"repackage_rebrand_resell": "Lätt att ompaketera, omprofilera och sälja vidare",
"a_vast_suite_of_enterprise_features": "En omfattande uppsättning Enterprise-funktioner",
@@ -1676,10 +1671,9 @@
"connect_now": "Anslut nu",
"managed_event_dialog_confirm_button_one": "Ersätt och meddela {{count}} medlem",
"managed_event_dialog_confirm_button_other": "Ersätt och meddela {{count}} medlemmar",
"managed_event_dialog_title_one": "Webbadressen /{{slug}} finns redan för {{count}} medlem. Vill du ersätta den?",
"managed_event_dialog_title_other": "Webbadressen /{{slug}} finns redan för {{count}} medlemmar. Vill du ersätta den?",
"managed_event_dialog_title_one": "Webbadressen/{{slug}} finns redan för {{count}} medlem. Vill du ersätta den?",
"managed_event_dialog_title_other": "Webbadressen/{{slug}} finns redan för {{count}} medlemmar. Vill du ersätta den?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> använder redan webbadressen <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> använder redan webbadressen <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Vi meddelar dem om du byter ut den. Gå tillbaka och ta bort dem om du inte vill skriva över den.",
"review_event_type": "Granska händelsetyp",
"looking_for_more_analytics": "Vill du ha mer analys?",
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "En fazla {{limit}} karakter uzunluğunda olmalıdır",
"invalid_password_hint": "Şifre en az {{passwordLength}} karakter uzunluğunda olmalı, en az bir rakam içermeli ve büyük ve küçük harflerin karışımından oluşmalıdır",
"incorrect_password": "Şifre hatalı.",
"incorrect_username_password": "Kullanıcı adı veya şifre yanlış.",
"use_setting": "Ayarı kullan",
"am_pm": "öö/ös",
"time_options": "Zaman seçenekleri",
@@ -639,7 +638,6 @@
"create_new_team": "Yeni ekip oluştur",
"open_invitations": "Açık Davetler",
"new_team": "Yeni Ekip",
"create_first_team_and_invite_others": "İlk ekibinizi oluşturun ve diğer kullanıcıları sizinle birlikte çalışmaya davet edin.",
"create_team_to_get_started": "Başlamak için bir ekip oluşturun",
"teams": "Ekipler",
"team": "Ekip",
@@ -1073,8 +1071,8 @@
"specific_issue": "Belirli bir sorununuz mu var?",
"browse_our_docs": "belgelerimize göz atın",
"choose_template": "Bir şablon seçin",
"reminder": "Hatırlatıcı",
"custom": "Özel",
"reminder": "Hatırlatıcı",
"reminder_email": "Hatırlatma: {{date}} tarihinde {{name}} ile {{eventType}}",
"not_triggering_existing_bookings": "Etkinlik rezervasyonu yapılırken kullanıcıdan bir telefon numarası belirtmesi isteneceğinden mevcut rezervasyonlar için etkinleştirilmeyecektir.",
"minute_one": "{{count}} dakika",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Erişilebilir ve ücretsiz bir AGPLv3 lisansı ile birlikte gelen Cal.com uygulaması bazı sınırlamalara sahip olmakla birlikte uygulamayı istediğiniz zaman Enterprise lisansına yükseltebilirsiniz. Daha sonra istediğiniz zaman yükseltebilirsiniz.",
"license": "Lisans",
"agplv3_license": "AGPLv3 Lisansı",
"ee_enterprise_license": "“/ee” Enterprise Lisansı",
"enterprise_booking_fee": "Ayda {{enterprise_booking_fee}} başlangıç fiyatıyla",
"enterprise_license_includes": "Ticari bir kullanım durumu için her şey",
"no_need_to_keep_your_code_open_source": "Kodunuzu açık kaynak olarak tutmanıza gerek yok",
"repackage_rebrand_resell": "Kolayca yeniden paketleyin, yeniden markalalayın ve yeniden satın",
"a_vast_suite_of_enterprise_features": "Kapsamlı kurumsal özellikler paketi",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "URL /{{slug}}, {{count}} üye için zaten mevcut. Bunu değiştirmek istiyor musunuz?",
"managed_event_dialog_title_other": "URL /{{slug}}, {{count}} üye için zaten mevcut. Bunu değiştirmek istiyor musunuz?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> zaten <strong>/{{slug}}</strong> URL'sini kullanıyor.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> zaten <strong>/{{slug}}</strong> URL'sini kullanıyor.",
"managed_event_dialog_clarification": "Değiştirmeyi seçmeniz durumunda onları bilgilendiririz. Üzerine yazmak istemiyorsanız geri dönerek onları kaldırın.",
"review_event_type": "Etkinlik Türünü inceleyin",
"looking_for_more_analytics": "Daha fazla analiz mi arıyorsunuz?",
@@ -428,7 +428,6 @@
"max_limit_allowed_hint": "Максимальна кількість символів: {{limit}}",
"invalid_password_hint": "Пароль має містити не менше ніж {{passwordLength}} символів: принаймні одну цифру та комбінацію великих і малих літер",
"incorrect_password": "Пароль неправильний.",
"incorrect_username_password": "Неправильне ім’я користувача або пароль.",
"use_setting": "Використовувати налаштування",
"am_pm": "дп/пп",
"time_options": "Параметри часу",
@@ -639,7 +638,6 @@
"create_new_team": "Створити нову команду",
"open_invitations": "Відкриті запрошення",
"new_team": "Нова команда",
"create_first_team_and_invite_others": "Створіть свою першу команду та запросіть інших, щоб працювати разом.",
"create_team_to_get_started": "Створіть команду, щоб почати",
"teams": "Команди",
"team": "Команда",
@@ -1073,8 +1071,8 @@
"specific_issue": "Виникла конкретна проблема?",
"browse_our_docs": "переглянути нашу документацію",
"choose_template": "Виберіть шаблон",
"reminder": "Нагадування",
"custom": "Користувацький",
"reminder": "Нагадування",
"reminder_email": "Нагадування: {{eventType}} з користувачем {{name}}, {{date}}",
"not_triggering_existing_bookings": "Не спрацьовуватиме для бронювань, що вже існують, тому що в користувача під час бронювання запитуватиметься номер телефону.",
"minute_one": "{{count}} хв",
@@ -1528,9 +1526,6 @@
"choose_license_description": "Cal.com містить доступну й безкоштовну ліцензію AGPLv3 з певними обмеженнями, яку можна будь-коли оновити до ліцензії Enterprise.",
"license": "Ліцензія",
"agplv3_license": "Ліцензія AGPLv3",
"ee_enterprise_license": "“/ee” Ліцензія для плану Enterprise",
"enterprise_booking_fee": "Від {{enterprise_booking_fee}}/міс.",
"enterprise_license_includes": "Усе для комерційного використання",
"no_need_to_keep_your_code_open_source": "Немає потреби залишати вихідний код відкритим",
"repackage_rebrand_resell": "Легко здійснювати перепакування, ребрендинг і перепродаж",
"a_vast_suite_of_enterprise_features": "Великий набір корпоративних функцій",
@@ -1679,7 +1674,6 @@
"managed_event_dialog_title_one": "URL-адреса /{{slug}} вже існує для {{count}} учасника. Замінити її?",
"managed_event_dialog_title_other": "URL-адреса /{{slug}} вже існує для кількох учасників ({{count}}). Замінити її?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> вже використовує URL-адресу <strong>/{{slug}}</strong>.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> вже використовують URL-адресу <string>/{{slug}}</strong>.",
"managed_event_dialog_clarification": "Якщо ви захочете замінити її, ми повідомимо учасників. Поверніться і вилучіть їх, замість того щоб перезаписувати.",
"review_event_type": "Переглянути тип заходу",
"looking_for_more_analytics": "Потрібно більше аналітичних даних?",

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