Compare commits

...
108 Commits
Author SHA1 Message Date
Omar López d7953aa53a v3.1.0
release.yml / release (push) Canceled after 0s
2023-07-15 09:01:03 -07:00
Joe Au-Yeung 494012484e fix: Add feedback when user installs app [CAL-2144] (#10169)
* Prevent multiple clicks on app store

* Disable multiple clicks on app page

* Disable multiple clicks on app page

* Clean up
2023-07-15 05:43:10 +02:00
Crowdin Bot 8c6669e204 New Crowdin translations by Github Action 2023-07-15 01:22:39 +00:00
Leo Giovanetti d5f3623377 chore: Relying on feature flag (#10162) 2023-07-14 22:20:03 -03:00
Crowdin Bot ef483a6520 New Crowdin translations by Github Action 2023-07-14 23:11:42 +00:00
Crowdin Bot 339f00f6aa New Crowdin translations by Github Action 2023-07-14 23:09:32 +00:00
e98bebb9b2 feat: Zapier For Teams (#9851)
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-07-14 16:06:57 -07:00
Crowdin Bot dcd7e80e6a New Crowdin translations by Github Action 2023-07-14 23:04:48 +00:00
zomars fcc13da23d Update rateLimit.ts 2023-07-14 16:02:10 -07:00
zomars 67a210d439 Sync packages 2023-07-14 16:00:59 -07:00
zomars e813f8bb17 Update rateLimit.ts 2023-07-14 15:59:04 -07:00
Carina WollendorferandCarinaWolli 5ff8d2db6e fix: slot start times with interrupted availabilities (#10164)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-07-14 15:57:05 -07:00
Crowdin Bot 7953551189 New Crowdin translations by Github Action 2023-07-14 17:04:38 +00:00
jemiluv8 879bc3f169 fix: minor bug in schedule sms and whatsapp reminders cron job (#10135) 2023-07-14 19:02:06 +02:00
Purushottam Khedreandsean-brydon 4aaf5da36d fix: no feedback on buttons on /getting-started page. (#10152)
* add isLoading type to ButtonProps type

* add loading feedback

* Update apps/web/components/getting-started/components/AppConnectionItem.tsx

Not neeeded as buttonProps.isLoading is false by default :)

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-07-14 09:03:11 +00:00
alannnc fb16d8e900 Added distinct clause when getting user list in team (#10144) 2023-07-14 00:04:30 +02:00
Afzal SayedandAlex van Andel 58d6bb840a feat: Auto submit 2FA if all inputs have a value (#9729)
* auto submit 2FA if all inputs have a value

* organize imports

* Fix duplicate text issue, this label is part of TwoFactor

* Fixed test that was failing due to enable-2fa not clickable anymore due to auto-submit

* Remove extra newline for clarity

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-07-13 20:54:57 +00:00
Carina WollendorferandCarinaWolli 1419c534c8 fix: webhooks for events with seats (#10143)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-07-13 22:40:16 +02:00
Pradumn Kumar 4de1de3418 fix: added slugify the eventType slugs on the api aswell (#10007) 2023-07-13 17:33:26 +00:00
Rama Krishna Reddyandrkreddy99 c23a2102f4 remove max width from bookings page (#10126)
Co-authored-by: rkreddy99 <rreddy@e2clouds.com>
2023-07-13 19:17:02 +02:00
Keith Williams df686370ab chore: Update GitHub env caching to use current PR details (#10096) 2023-07-13 19:08:50 +02:00
Alex van Andel 0bc7d4ceda feat: Override any day of the week (#10138)
* feat: Override any day of the week

* Having multiple workingHours would duplicate the default
2023-07-13 15:33:27 +00:00
Hariom Balhara 9444f925e5 fix: Important Booking Page fixes (#10130) 2023-07-13 19:38:41 +05:30
Hariom Balhara 2918a34e1a fix: More instances of Avatar and booker links fixed (#10083) 2023-07-13 19:22:21 +05:30
213caa722e feat: Added order by most popular in Onboarding screen (#9891)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-07-13 19:17:46 +05:30
Crowdin Bot 6c7d7be9cc New Crowdin translations by Github Action 2023-07-13 13:32:30 +00:00
Nafees Nazikandsean-brydon f1ef50e606 fix: upload csv button not clickable (#9981)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-07-13 15:29:29 +02:00
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
314 changed files with 5137 additions and 5666 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
+6 -4
View File
@@ -2,14 +2,16 @@ name: "Set environment variables"
description: "Configures environment variables for a workflow"
runs:
using: "composite"
steps:
- name: Create env file
- name: Read env file
uses: buildjet/cache@v3
id: env-cache
with:
path: gh.env
key: env-cache-${{ hashFiles('gh.env') }}
path: gh.env-${{ github.sha }}
key: env-cache
restore-keys: env-cache-
- name: Set Environment Variables
uses: tw3lveparsecs/github-actions-setvars@latest
with:
envFilePath: gh.env
envFilePath: gh.env-${{ github.sha }}
@@ -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"
+5 -3
View File
@@ -4,6 +4,7 @@ on:
workflow_call:
env:
ENV_FILE_NAME: gh.env-${{ github.sha }}
INPUT_ENV_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
INPUT_ENV_NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
INPUT_ENV_NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
@@ -44,10 +45,11 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ozaytsev86/create-env-file@v1
with:
file-name: ${{ github.workspace }}/gh.env
file-name: ${{ github.workspace }}/${{ env.ENV_FILE_NAME }}
- uses: buildjet/cache@v3
id: env-cache
with:
path: gh.env
key: env-cache-${{ hashFiles('gh.env') }}
path: ${{ env.ENV_FILE_NAME }}
key: env-cache-${{ hashFiles(env.ENV_FILE_NAME) }}
+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. ❤️🎉
+6 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import slugify from "@calcom/lib/slugify";
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@@ -58,7 +59,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
const schemaEventTypeCreateParams = z
.object({
title: z.string(),
slug: z.string(),
slug: z.string().transform((s) => slugify(s)),
description: z.string().optional().nullable(),
length: z.number().int(),
metadata: z.any().optional(),
@@ -76,7 +77,10 @@ export const schemaEventTypeCreateBodyParams = schemaEventTypeBaseBodyParams
const schemaEventTypeEditParams = z
.object({
title: z.string().optional(),
slug: z.string().optional(),
slug: z
.string()
.transform((s) => slugify(s))
.optional(),
length: z.number().int().optional(),
seatsPerTimeSlot: z.number().optional(),
seatsShowAttendees: z.boolean().optional(),
+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
}
+12 -1
View File
@@ -29,6 +29,8 @@ import {
Avatar,
} from "@calcom/ui";
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Plus, Shield } from "@calcom/ui/components/icon";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
/* These app slugs all require Google Cal to be installed */
@@ -441,7 +443,16 @@ const InstallAppButtonChild = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuContent
onInteractOutside={(event) => {
if (mutation.isLoading) event.preventDefault();
}}
>
{mutation.isLoading && (
<div className="z-1 fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
<Spinner />
</div>
)}
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
{userAdminTeams.map((team) => {
@@ -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;
@@ -10,6 +10,7 @@ import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog
import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -49,6 +50,8 @@ type BookingItemProps = BookingItem & {
function BookingListItem(booking: BookingItemProps) {
// Get user so we can determine 12/24 hour format preferences
const query = useMeQuery();
const bookerUrl = useBookerUrl();
const user = query.data;
const {
t,
@@ -170,7 +173,7 @@ function BookingListItem(booking: BookingItemProps) {
id: "reschedule",
icon: Clock,
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}${
href: `${bookerUrl}/reschedule/${booking.uid}${
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
}`,
},
@@ -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" : "";
@@ -28,6 +28,7 @@ const AppConnectionItem = (props: IAppConnectionItem) => {
color="secondary"
disabled={installed}
type="button"
loading={buttonProps?.isLoading}
onClick={(event) => {
// Save cookie key to return url step
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
@@ -17,7 +17,11 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
const { nextStep } = props;
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
const { t } = useLocale();
const queryIntegrations = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
const queryIntegrations = trpc.viewer.integrations.useQuery({
variant: "calendar",
onlyInstalled: false,
sortByMostPopular: true,
});
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
(item) => item.calendars && item.calendars?.length > 0
@@ -16,6 +16,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { data: queryConnectedVideoApps, isLoading } = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
sortByMostPopular: true,
});
const { t } = useLocale();
@@ -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: {
@@ -1,8 +1,9 @@
import type { BaseSyntheticEvent } from "react";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, Form } from "@calcom/ui";
@@ -127,6 +128,17 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
}
}
const handleEnableRef = useCallbackRef(handleEnable);
const totpCode = form.watch("totpCode");
// auto submit 2FA if all inputs have a value
useEffect(() => {
if (totpCode?.trim().length === 6) {
form.handleSubmit(handleEnableRef.current)();
}
}, [form, handleEnableRef, totpCode]);
return (
<Dialog open={true}>
<DialogContent>
@@ -1,8 +1,9 @@
import type { BaseSyntheticEvent } from "react";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
@@ -129,6 +130,17 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
}
}
const handleEnableRef = useCallbackRef(handleEnable);
const totpCode = form.watch("totpCode");
// auto submit 2FA if all inputs have a value
useEffect(() => {
if (totpCode?.trim().length === 6) {
form.handleSubmit(handleEnableRef.current)();
}
}, [form, handleEnableRef, totpCode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
+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>
+4 -9
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,24 +11,20 @@ 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();
const router = useRouter();
const isBioEmpty = !member.bio || !member.bio.replace("<p><br></p>", "").length;
// slug is a route parameter, we don't want to forward it to the next route
const { slug: _slug, ...queryParamsToForward } = router.query;
// We don't want to forward orgSlug and user which are route params to the next route
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = router.query;
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.1.0",
"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))) {
+142 -320
View File
@@ -1,97 +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, org }: 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}
org={org}
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
org={org}
/>
</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);
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid, bookingUid } = context.query;
const user = await prisma.user.findFirst({
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const users = await prisma.user.findMany({
where: {
/** TODO: We should standarize this */
username: username.toLowerCase().replace(/( |%20)/g, "+"),
username: {
in: usernames,
},
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
@@ -99,183 +62,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
: 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 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,
},
},
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,
},
});
@@ -284,87 +71,122 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
notFound: true,
};
}
const org = isValidOrgDomain ? currentOrgDomain : null;
// 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,
org,
});
// 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,
org,
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}`);
}
const org = isValidOrgDomain ? currentOrgDomain : null;
// 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,
org,
});
if (!eventData) {
return {
notFound: true,
};
}
return {
props: {
booking,
away: user?.away,
user: username,
slug,
org,
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";
@@ -56,6 +56,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const handlerMap = (await import("@calcom/app-store/apps.server.generated")).apiHandlers;
const handlerKey = deriveAppDictKeyFromType(appName, handlerMap);
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` });
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
let redirectUrl = "/apps/installed";
if (typeof handler === "undefined")
-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);
+13 -1
View File
@@ -18,7 +18,7 @@ const querySchema = z
async function getIdentityData(req: NextApiRequest) {
const { username, teamname } = querySchema.parse(req.query);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const org = isValidOrgDomain ? currentOrgDomain : null;
if (username) {
const user = await prisma.user.findFirst({
where: {
@@ -35,6 +35,7 @@ async function getIdentityData(req: NextApiRequest) {
name: username,
email: user?.email,
avatar: user?.avatar,
org,
};
}
if (teamname) {
@@ -50,6 +51,7 @@ async function getIdentityData(req: NextApiRequest) {
select: { logo: true },
});
return {
org,
name: teamname,
email: null,
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
@@ -62,6 +64,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const img = identity?.avatar;
// If image isn't set or links to this route itself, use default avatar
if (!img) {
if (identity?.org) {
res.setHeader("x-cal-org", identity.org);
}
res.writeHead(302, {
Location: defaultAvatarSrc({
md5: crypto
@@ -70,16 +75,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
.digest("hex"),
}),
});
return res.end();
}
if (!img.includes("data:image")) {
if (identity.org) {
res.setHeader("x-cal-org", identity.org);
}
res.writeHead(302, { Location: img });
return res.end();
}
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
if (identity.org) {
res.setHeader("x-cal-org", identity.org);
}
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
+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"}
+94 -127
View File
@@ -1,42 +1,53 @@
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, org }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
org={org}
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent={isTeamEvent}
org={org}
/>
</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 ?? "");
const org = isValidOrgDomain ? currentOrgDomain : null;
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 +56,88 @@ 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, org });
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,
org,
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({
@@ -55,8 +55,8 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
type Props = UserTypePageProps | TeamTypePageProps;
export default function Page(props: Props) {
if ((props as TeamTypePageProps)?.teamId) return TeamTypePage(props as TeamTypePageProps);
return UserTypePage(props as UserTypePageProps);
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
return <UserTypePage {...(props as UserTypePageProps)} />;
}
Page.PageWrapper = PageWrapper;
@@ -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) => {
@@ -33,8 +33,8 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
type Props = UserPageProps | TeamPageProps;
export default function Page(props: Props) {
if ((props as TeamPageProps)?.team) return TeamPage(props as TeamPageProps);
return UserPage(props as UserPageProps);
if ((props as TeamPageProps)?.team) return <TeamPage {...(props as TeamPageProps)} />;
return <UserPage {...(props as UserPageProps)} />;
}
Page.PageWrapper = PageWrapper;
+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,
},
};
@@ -6,6 +6,8 @@ import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export { getServerSideProps } from "@calcom/features/ee/organizations/pages/organization";
const AboutOrganizationPage = () => {
const { t } = useLocale();
const router = useRouter();
@@ -7,6 +7,8 @@ import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export { getServerSideProps } from "@calcom/features/ee/organizations/pages/organization";
const AddNewTeamsPage = () => {
const { t } = useLocale();
const router = useRouter();
@@ -7,6 +7,8 @@ import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export { getServerSideProps } from "@calcom/features/ee/organizations/pages/organization";
const OnboardTeamMembersPage = () => {
const { t } = useLocale();
const router = useRouter();
@@ -6,6 +6,8 @@ import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export { getServerSideProps } from "@calcom/features/ee/organizations/pages/organization";
const SetPasswordPage = () => {
const { t } = useLocale();
const router = useRouter();
@@ -1,16 +1,21 @@
import type { GetServerSidePropsContext } from "next";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { CreateANewOrganizationForm } from "@calcom/features/ee/organizations/components";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
const CreateNewOrganizationPage = () => {
const CreateNewOrganizationPage = ({ querySlug }: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
return (
<LicenseRequired>
<Meta title={t("set_up_your_organization")} description={t("organizations_description")} />
<CreateANewOrganizationForm />
<CreateANewOrganizationForm slug={querySlug} />
</LicenseRequired>
);
};
@@ -22,6 +27,25 @@ const LayoutWrapper = (page: React.ReactElement) => {
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
// Check if organizations are enabled
if (flags["organizations"] !== true) {
return {
notFound: true,
};
}
const querySlug = context.query.slug as string;
return {
props: {
querySlug: querySlug ?? null,
},
};
};
CreateNewOrganizationPage.getLayout = LayoutWrapper;
CreateNewOrganizationPage.PageWrapper = PageWrapper;
+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>
</>
);
}
+21 -22
View File
@@ -7,7 +7,7 @@ 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 { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
@@ -28,9 +28,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();
@@ -103,7 +103,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 +140,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 || "",
}))}
/>
@@ -153,9 +153,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<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("no_teams_yet")}
{" " + t("org_no_teams_yet")}
</h2>
<p className="mx-auto max-w-md">{t("no_teams_yet_description")}</p>
<p className="mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
</div>
</div>
</div>
@@ -187,7 +187,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 +197,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>
@@ -251,6 +246,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const ssr = await ssrInit(context);
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const { isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const flags = await getFeatureFlagMap(prisma);
const team = await getTeamWithMembers(undefined, slug);
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
@@ -259,7 +255,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (
(isValidOrgDomain && team?.parent && !!metadata?.isOrganization) ||
(!isValidOrgDomain && team?.parent) ||
(!isValidOrgDomain && !!metadata?.isOrganization)
(!isValidOrgDomain && !!metadata?.isOrganization) ||
flags["organizations"] !== true
) {
return { notFound: true } as const;
}
@@ -289,7 +286,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 +299,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,
+77 -175
View File
@@ -1,214 +1,116 @@
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 { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
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, org }: 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
org={org}
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
org={org}
/>
</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 { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const team = await prisma.team.findFirst({
where: {
slug: slugParam,
slug: teamSlug,
parent: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
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;
};
} as const;
}
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;
const org = isValidOrgDomain ? currentOrgDomain : null;
// 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,
org,
});
if (!eventData) {
return {
notFound: true,
} as const;
}
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,
org,
booking,
trpcState: ssg.dehydrate(),
isBrandingHidden: team.hideBranding,
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) {
+1 -1
View File
@@ -102,7 +102,7 @@ test.describe("2FA Tests", async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await fillOtp({ page, secret: secret! });
await page.click('[data-testid="enable-2fa"]');
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
return user;
+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
+74 -6
View File
@@ -11,11 +11,23 @@
"calcom_explained_new_user": "قم بإنهاء إعداد حسابك في {{appName}}! متبقي بضع خطوات فقط لحل جميع مشاكل الجدولة لديك.",
"have_any_questions": "هل لديك أسئلة؟ نحن هنا للمساعدة.",
"reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور",
"verify_email_subject": "{{appName}}: تأكيد حسابك",
"check_your_email": "تحقق من بريدك الإلكتروني",
"verify_email_page_body": "أرسلنا رسالة إلكترونية إلى {{email}}. من المهم تأكيد عنوان بريدك الإلكتروني لضمان أفضل قابلية تسليم للبريد الإلكتروني والرزنامة من {{appName}}.",
"verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والرزنامة",
"verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني",
"verify_email_email_button": "تأكيد البريد الإلكتروني",
"verify_email_email_body": "الرجاء تأكيد عنوان بريدك الإلكتروني بالنقر على الزر أدناه.",
"verify_email_email_link_text": "إليك الرابط في حال لم تكن ترغب بالنقر على أي أزرار:",
"email_sent": "تم إرسال الرسالة الإلكترونية بنجاح",
"event_declined_subject": "تم الرفض: {{title}} في {{date}}",
"event_cancelled_subject": "تم إلغاء: {{title}} في {{date}}",
"event_request_declined": "تم رفض طلب الحدث الخاص بك",
"event_request_declined_recurring": "تم رفض طلب الحدث المتكرر الخاص بك",
"event_request_cancelled": "تم إلغاء حدثك المُجَدْوَل",
"organizer": "المنظِّم",
"need_to_reschedule_or_cancel": "هل تحتاج إلى إعادة جدولة أو إلغاء؟",
"no_options_available": "لا توجد خيارات متاحة",
"cancellation_reason": "سبب الإلغاء (اختياري)",
"cancellation_reason_placeholder": "لماذا تلغي؟",
"rejection_reason": "سبب الرفض",
@@ -75,7 +87,9 @@
"your_meeting_has_been_booked": "لقد تم حجز الاجتماع الخاص بك",
"event_type_has_been_rescheduled_on_time_date": "تم إعادة جدولة {{title}} إلى {{date}}.",
"event_has_been_rescheduled": "تم التحديث - تم إعادة جدولة الحدث الخاص بك",
"request_reschedule_subtitle": "ألغى {{organizer}} الحجز وطلب منك اختيار حجز في وقت آخر.",
"request_reschedule_title_organizer": "لقد طلبت من {{attendee}} إعادة جدولة موعد",
"request_reschedule_subtitle_organizer": "لقد ألغيت الحجز، ويجب أن يختار {{attendee}} وقتًا جديداً للحجز معك.",
"rescheduled_event_type_subject": "تم إرسال طلب لإعادة الجدولة: {{eventType}} مع {{name}} في {{date}}",
"requested_to_reschedule_subject_attendee": "الإجراء المطلوب إعادة الجدولة: يُرجى حجز وقت جديد لمدة {{eventType}} مع {{name}}",
"hi_user_name": "مرحبا {{name}}",
@@ -104,6 +118,7 @@
"team_info": "معلومات الفريق",
"request_another_invitation_email": "إذا كنت تفضل عدم استخدام {{toEmail}} كبريدك الإلكتروني {{appName}} أو لديك بالفعل حساب {{appName}} ، يرجى طلب دعوة أخرى إلى ذلك البريد الإلكتروني.",
"you_have_been_invited": "تم دعوتك للانضمام إلى الفريق {{teamName}}",
"user_invited_you": "يدعوك {{user}} للانضمام إلى فريق {{entity}} {{team}} على {{appName}}",
"hidden_team_member_title": "أنت في الوضع الخفي في هذا الفريق",
"hidden_team_member_message": "لم يتم الدفع مقابل مقعدك. يمكنك الترقية إلى Pro أو إعلام مالك الفريق أنه يستطيع الدفع مقابل مقعدك.",
"hidden_team_owner_message": "تحتاج إلى حساب Pro لاستخدام خدمة الفرق، وأنت حاليًا في الوضع الخفي حتى تقوم بالترقية.",
@@ -114,6 +129,8 @@
"team_upgrade_banner_description": "شكراً لك على تجربة خطة الفريق الجديدة التي نقدمها. لقد لاحظنا أن فريقك \"{{teamName}}\" بحاجة إلى الترقية.",
"upgrade_banner_action": "قم بالترقية هنا",
"team_upgraded_successfully": "تمت ترقية فريقك بنجاح!",
"org_upgrade_banner_description": "شكراً لك على تجربة الخطة الجديدة لمؤسستنا التي نقدمها. لقد لاحظنا أن مؤسستك \"{{teamName}}\" بحاجة إلى الترقية.",
"org_upgraded_successfully": "تمت ترقية مؤسستك بنجاح!",
"use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور",
"hey_there": "مرحبًا،",
"forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - {{appName}}",
@@ -180,6 +197,7 @@
"blog": "المدونة",
"blog_description": "اقرأ أحدث أخبارنا ومقالاتنا",
"join_our_community": "انضم إلى مجتمعنا",
"join_our_discord": "انضم إلينا على Discord",
"404_claim_entity_user": "احصل على اسم المستخدم وقم بجدولة الأحداث",
"popular_pages": "الصفحات الشائعة",
"register_now": "التسجيل الآن",
@@ -187,6 +205,7 @@
"page_doesnt_exist": "هذه الصفحة غير موجودة.",
"check_spelling_mistakes_or_go_back": "تحقق من الأخطاء الإملائية أو ارجع إلى الصفحة السابقة.",
"404_page_not_found": "404: تعذر العثور على هذه الصفحة.",
"booker_event_not_found": "لم نتمكن من العثور على الحدث الذي تحاول حجزه.",
"getting_started": "البدء",
"15min_meeting": "اجتماع لمدة 15 دقيقة",
"30min_meeting": "اجتماع لمدة 30 دقيقة",
@@ -222,11 +241,14 @@
"done": "تم",
"all_done": "اكتمل كل شيء!",
"all": "الكل",
"yours": "حسابك",
"available_apps": "التطبيقات المتوفرة",
"check_email_reset_password": "تحقق من البريد الإلكتروني. لقد أرسلنا رابطًا لإعادة تعيين كلمة المرور.",
"finish": "إنهاء",
"organization_general_description": "إدارة الإعدادات للغة فريقك ومنطقته الزمنية",
"few_sentences_about_yourself": "اكتب بضع جمل عن نفسك. سيظهر هذا على صفحة عنوان URL الشخصية لديك.",
"nearly_there": "أوشكت على الانتهاء!",
"nearly_there_instructions": "وفي نهاية المطاف: يساعدك كتابة وصف موجز عنك ووضع صورة في الحصول على عمليات حجز وأن يعرف الأشخاص مع من سيحجزون حقًا.",
"set_availability_instructions": "حدد الفترات الزمنية التي تكون متاحًا فيها بشكل متكرر. يمكنك لاحقًا تحديد المزيد منها وربطها مع تقاويم مختلفة.",
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
"continue_without_calendar": "المتابعة من دون تقويم",
@@ -282,6 +304,18 @@
"success": "تم بنجاح",
"failed": "فشل",
"password_has_been_reset_login": "تمت إعادة تعيين كلمة المرور الخاصة بك. يمكنك الآن تسجيل الدخول باستخدام كلمة المرور التي تم إنشاؤها حديثًا.",
"layout": "تخطيط",
"bookerlayout_default_title": "العرض الافتراضي",
"bookerlayout_description": "يمكنك اختيار خيارات متعددة، ويمكن لمن يقومون بالحجز لديك تبديل طرق العرض.",
"bookerlayout_user_settings_title": "تخطيط الحجز",
"bookerlayout_user_settings_description": "يمكنك تحديد خيارات متعددة ويمكن لمن يقومون بالحجز لديك تبديل طرق العرض. يمكن إبطال هذا بحسب وضع كل فعالية.",
"bookerlayout_month_view": "شهر",
"bookerlayout_week_view": "أسبوعياً",
"bookerlayout_column_view": "عمود",
"bookerlayout_error_min_one_enabled": "يجب تمكين تخطيط واحد على الأقل.",
"bookerlayout_error_default_not_enabled": "التخطيط الذي حددته كطريقة عرض افتراضية ليس جزءاً من التخطيطات التي تم تمكينها.",
"bookerlayout_error_unknown_layout": "التخطيط الذي اخترته ليس تخطيطاً صالحاً.",
"bookerlayout_override_global_settings": "يمكنك إدارة هذا لجميع أنواع فعالياتك في <2>إعدادات/المظهر</2> أو <6>إبطال لهذا الحدث فقط</6>.",
"unexpected_error_try_again": "حدث خطأ غير متوقع. حاول مجددًا.",
"sunday_time_error": "وقت غير صالح يوم الأحد",
"monday_time_error": "وقت غير صالح يوم الاثنين",
@@ -294,6 +328,7 @@
"error_end_time_next_day": "لا يمكن أن يكون وقت الانتهاء أطول من 24 ساعة",
"back_to_bookings": "العودة إلى عمليات الحجز",
"free_to_pick_another_event_type": "لا تتردد في اختيار حدث آخر في أي وقت.",
"cancelled": "تم الإلغاء",
"cancellation_successful": "تم الإلغاء بنجاح",
"really_cancel_booking": "هل تريد حقًا إلغاء الحجز؟",
"cannot_cancel_booking": "لا يمكنك إلغاء هذا الحجز",
@@ -302,6 +337,12 @@
"cancelling_event_recurring": "إن هذا الحدث أحد الأمثلة على حدث متكرر.",
"cancelling_all_recurring": "هذه هي كل الأمثلة المتبقية في الحدث المتكرر.",
"error_with_status_code_occured": "حدث خطأ برمز الحالة {{status}}.",
"error_event_type_url_duplicate": "يوجد بالفعل نوع فعالية مع هذا الرابط.",
"error_event_type_unauthorized_create": "أنت غير قادر على إنشاء هذه الفعالية",
"error_event_type_unauthorized_update": "أنت غير قادر على تعديل هذه الفعالية",
"error_workflow_unauthorized_create": "أنت غير قادر على إنشاء سير العمل هذا",
"error_schedule_unauthorized_create": "أنت غير قادر على إنشاء هذا الجدول الزمني",
"booking_already_cancelled": "تم إلغاء هذا الحجز بالفعل",
"booking_already_accepted_rejected": "تم بالفعل قبول هذا الحجز أو رفضه",
"go_back_home": "العودة إلى الشاشة الرئيسية",
"or_go_back_home": "أو العودة إلى الشاشة الرئيسية",
@@ -311,10 +352,12 @@
"no_status_bookings_yet_description": "ليست لديك عمليات حجز في حالة {{status}}.‏ {{description}}",
"event_between_users": "{{eventName}} بين {{host}} و{{attendeeName}}",
"bookings": "عمليات الحجز",
"booking_not_found": "الحجز غير موجود",
"bookings_description": "اطلع على الأحداث القادمة والسابقة المحجوزة لديك من خلال روابط أنواع الحدث.",
"upcoming_bookings": "بمجرد أن يحجز شخص ما موعدًا معك، سيظهر هنا.",
"recurring_bookings": "بمجرد أن يحجز شخص ما اجتماعًا متكررًا معك، سيظهر هنا.",
"past_bookings": "ستظهر هنا عمليات حجزك السابقة.",
"cancelled_bookings": "ستظهر هنا عمليات حجزك التي تم إلغاؤها.",
"unconfirmed_bookings": "ستظهر هنا كل حجوزات غير المؤكدة بعد.",
"unconfirmed_bookings_tooltip": "الحجوزات غير المؤكدة",
"on": "في",
@@ -348,15 +391,19 @@
"user_dynamic_booking_disabled": "قام بعض المستخدمين في المجموعة حاليًا بتعطيل حجز المجموعات الديناميكي",
"allow_dynamic_booking_tooltip": "روابط الحجز الجماعية التي يمكن إنشاؤها ديناميكيًا عن طريق إضافة أسماء مستخدمين متعددة باستخدام \"+\". مثال: \"{{appName}}/bailey+peer\"",
"allow_dynamic_booking": "السماح للحضور بالحجز لك عن طريق حجز المجموعات الديناميكي",
"dynamic_booking": "روابط المجموعة الديناميكية",
"email": "البريد الإلكتروني",
"email_placeholder": "name@example.com",
"full_name": "الاسم بالكامل",
"browse_api_documentation": "استعراض مستندات واجهة برمجة التطبيقات (API) لدينا",
"leverage_our_api": "استفد من واجهة برمجة التطبيقات (API) لدينا من أجل قدرة كاملة على التحكم والتخصيص.",
"create_webhook": "إنشاء الويب هوك",
"booking_cancelled": "تم إلغاء الحجز",
"booking_rescheduled": "تمت إعادة جدولة الحجز",
"recording_ready": "رابط تنزيل التسجيل جاهز",
"booking_created": "تم إنشاء الحجز",
"booking_rejected": "تم رفض الحجز",
"booking_requested": "تم طلب الحجز",
"meeting_ended": "انتهى الاجتماع",
"form_submitted": "تم إرسال النموذج",
"event_triggers": "مشغلات الحدث",
@@ -366,6 +413,8 @@
"team_webhooks": "فريق ويبهوكس",
"create_new_webhook_to_account": "إنشاء ويب هوك جديد لحسابك",
"new_webhook": "ويب هوك جديد",
"receive_cal_meeting_data": "تلقَّ بيانات اجتماعات {{appName}} على عنوان URL محدد، وفي الوقت الفعلي، عندما تتم جدولة حدث ما أو يتم إلغاؤه.",
"receive_cal_event_meeting_data": "تلقي بيانات اجتماعات {{appName}} على عنوان URL محدد، وفي الوقت الحقيقي، عندما تتم جدولة حدث ما أو إلغاؤه.",
"responsive_fullscreen_iframe": "إطار iframe سريع الاستجابة بكامل الشاشة",
"loading": "يجري التحميل...",
"deleting": "جارٍ الحذف...",
@@ -428,7 +477,6 @@
"max_limit_allowed_hint": "يجب أن يكون عدد الأحرف {{limit}} أو أقل",
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من {{passwordLength}} أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
"incorrect_password": "كلمة المرور غير صحيحة.",
"incorrect_username_password": "اسم المستخدم أو كلمة المرور غير صحيحة.",
"use_setting": "استخدام الإعداد",
"am_pm": "صباحًا/مساءً",
"time_options": "خيارات الوقت",
@@ -456,6 +504,7 @@
"additional_guests": "إضافة ضيوف",
"your_name": "اسمك",
"your_full_name": "اسمك الكامل",
"no_name": "لا يوجد اسم",
"email_address": "عنوان البريد الإلكتروني",
"enter_valid_email": "يرجى إدخال بريد إلكتروني صالح",
"location": "الموقع",
@@ -499,10 +548,13 @@
"your_team_updated_successfully": "تم تحديث فريقك بنجاح.",
"about": "حول",
"team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.",
"org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.",
"members": "الأعضاء",
"organization_members": "أعضاء المنظمة",
"member": "العضو",
"number_member_one": "{{count}} عضو",
"number_member_other": "{{count}} من الأعضاء",
"number_selected": "{{count}} تم اختيارها",
"owner": "المالك",
"admin": "المشرف",
"administrator_user": "المستخدم المسؤول",
@@ -533,6 +585,8 @@
"confirm_all": "تأكيد الكل",
"disband_team": "تفكيك الفريق",
"disband_team_confirmation_message": "هل تريد بالتأكيد تفكيك هذا الفريق؟ أي شخص شاركت رابط هذا الفريق معه لن يستطيع بعد الآن الحجز باستخدامه.",
"disband_org": "حل المنظمة",
"disband_org_confirmation_message": "هل أنت متأكد من أنك تريد حل هذه المنظمة؟ سيتم حذف جميع الفرق والأعضاء.",
"remove_member_confirmation_message": "هل تريد بالتأكيد إزالة هذا العضو من الفريق؟",
"confirm_disband_team": "نعم، أريد تفكيك الفريق",
"confirm_remove_member": "نعم، أريد إزالة العضو",
@@ -607,6 +661,7 @@
"event_type_updated_successfully": "تم تحديث نوع الحدث {{eventTypeTitle}} بنجاح",
"event_type_deleted_successfully": "تم حذف نوع الحدث بنجاح",
"hours": "الساعات",
"people": "الأشخاص",
"your_email": "بريدك الإلكتروني",
"change_avatar": "تغيير الصورة الرمزية",
"language": "اللغة",
@@ -643,6 +698,7 @@
"create_team_to_get_started": "قم بإنشاء فريق للبدء",
"teams": "الفرق",
"team": "فريق",
"organization": "المنظمة",
"team_billing": "الفوترة الخاصة بالفريق",
"team_billing_description": "إدارة الفواتير لفريقك",
"upgrade_to_flexible_pro_title": "لقد قمنا بتغيير الفوترة الخاصة بالفرق",
@@ -717,6 +773,7 @@
"cal_provide_huddle01_meeting_url": "سيوفر {{appName}} رابطًا للاجتماع عبر الفيديو على Huddle01 web3.",
"cal_provide_teams_meeting_url": "سيوفر {{appName}} عنوان URL لاجتماعات MS Teams. ملاحظة: يجب أن يكون لديك حساب عمل أو حساب مدرسة",
"require_payment": "يلزم الدفع",
"you_need_to_add_a_name": "تحتاج إلى إضافة اسم",
"commission_per_transaction": "عمولة كل معاملة",
"event_type_updated_successfully_description": "تم تحديث نوع الحدث لديك بنجاح.",
"hide_event_type": "إخفاء نوع الحدث",
@@ -807,18 +864,25 @@
"installed_apps": "التطبيقات المثبتة",
"free_to_use_apps": "متاح",
"no_category_apps": "لا توجد تطبيقات {{category}}",
"all_apps": "كل التطبيقات",
"no_category_apps_description_calendar": "إضافة تطبيق تقويم للتحقق من أي تضارب لمنع أي حجوزات مزدوجة",
"no_category_apps_description_conferencing": "جرب إضافة تطبيق مؤتمرات لإنشاء مكالمات فيديو مع عملائك",
"no_category_apps_description_payment": "أضف تطبيق دفع لتسهيل التعاملات المالية بينك وبين عملائك",
"no_category_apps_description_analytics": "إضافة تطبيق تحليلات لصفحات الحجز الخاصة بك",
"no_category_apps_description_automation": "إضافة تطبيق أتمتة لاستخدامه",
"no_category_apps_description_other": "أضف أي نوع آخر من التطبيقات للقيام بأي شيء",
"no_category_apps_description_web3": "إضافة تطبيق web3 لصفحات الحجز لديك",
"no_category_apps_description_messaging": "إضافة تطبيق مراسلة لإعداد التنبيهات والمذكرات المخصصة",
"no_category_apps_description_crm": "إضافة تطبيق CRM لتتبع من قابلته",
"installed_app_calendar_description": "قم بتعيين التقويمات للتحقق من وجود تعارضات لمنع الحجوزات المزدوجة.",
"installed_app_payment_description": "قم بإعداد أي خدمات معالجة دفع تود استخدامها عند التحصيل من العملاء.",
"installed_app_analytics_description": "تهيئة أي تطبيقات تستخدم لصفحات الحجز الخاصة بك",
"installed_app_other_description": "جميع تطبيقاتك المثبتة من الفئات الأخرى.",
"installed_app_conferencing_description": "تهيئة أي تطبيقات المؤتمرات لاستخدامها",
"installed_app_automation_description": "تهيئة تطبيقات الأتمتة التي سيتم استخدامها",
"installed_app_web3_description": "تهيئة تطبيقات web3 التي يمكن استخدامها لصفحات الحجز لديك",
"installed_app_messaging_description": "تكوين أي تطبيقات الرسائل لاستخدامها لإعداد الإشعارات والمذكرات المخصصة",
"installed_app_crm_description": "تهيئة تطبيقات CRM التي يمكن استخدامها لتتبع من قابلته",
"analytics": "التحليلات",
"empty_installed_apps_headline": "لم يتم تثبيت أي تطبيق",
"empty_installed_apps_description": "تعطيك التطبيقات القدرة على تعزيز سير عملك وتحسين الجدولة في حياتك بشدة.",
@@ -1073,8 +1137,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 +1592,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": "مجموعة كبيرة من ميزات المؤسسة",
@@ -1605,6 +1666,7 @@
"show_on_booking_page": "إظهار في صفحة الحجز",
"get_started_zapier_templates": "البدء في استخدام قوالب Zapier",
"team_is_unpublished": "لم يُنشر {{team}}",
"team_is_unpublished_description": "رابط هذا {{entity}} غير متاح حاليًا. يرجى الاتصال بمالك {{entity}} أو طلب نشره منه.",
"team_member": "عضو الفريق",
"a_routing_form": "نموذج توجيه",
"form_description_placeholder": "وصف النموذج",
@@ -1634,13 +1696,16 @@
"spot_popular_event_types": "اكتشف أنواع الأحداث الشائعة",
"spot_popular_event_types_description": "تعرف أي أنواع من الأحداث لديك تتلقى أكبر عدد من النقرات والحجوزات",
"no_responses_yet": "لا توجد ردود بعد",
"no_routes_defined": "لا توجد مسارات محددة",
"this_will_be_the_placeholder": "سيكون هذا هو العنصر النائب",
"error_booking_event": "حدث خطأ عند حجز الفعالية، الرجاء تحديث الصفحة والمحاولة ثانيةً",
"timeslot_missing_title": "لم يتم تحديد فترة زمنية",
"timeslot_missing_description": "يرجى تحديد فترة زمنية لحجز الحدث.",
"timeslot_missing_cta": "حدد فترة زمنية",
"switch_monthly": "التبديل إلى العرض الشهري",
"switch_weekly": "التبديل إلى العرض الأسبوعي",
"switch_multiday": "التبديل إلى العرض اليومي",
"switch_columnview": "التبديل إلى العرض العمود",
"num_locations": "{{num}} من خيارات الموقع",
"select_on_next_step": "حدد في الخطوة التالية",
"this_meeting_has_not_started_yet": "لم يبدأ هذا الاجتماع بعد",
@@ -1679,7 +1744,7 @@
"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_information_other": "<strong>{{names}}</strong>يستخدمون الرابط <strong>/{{slug}}</strong> بالفعل.",
"managed_event_dialog_clarification": "إذا اخترت استبداله، فسنخطرهم بذلك. ارجع وقم بإزالتهم إذا كنت لا تريد استبداله.",
"review_event_type": "مراجعة نوع الحدث",
"looking_for_more_analytics": "هل تبحث عن المزيد من التحليلات؟",
@@ -1695,12 +1760,14 @@
"least_booked_members": "الأعضاء الأقل حجزًا",
"events_created": "تم إنشاء الأحداث",
"events_completed": "تم اكتمال الأحداث",
"events_cancelled": "تم إلغاء الفعاليات",
"events_rescheduled": "تمت إعادة جدولة الأحداث",
"from_last_period": "من آخر فترة",
"from_to_date_period": "من: {{startDate}} إلى: {{endDate}}",
"redirect_url_warning": "سيؤدي إضافة إعادة توجيه إلى تعطيل صفحة النجاح. تأكد من ذكر \"تأكيد الحجز\" في صفحة النجاح المخصصة.",
"event_trends": "الرائج في الأحداث",
"clear_filters": "مسح عوامل التصفية",
"clear": "مسح",
"hold": "تعليق",
"on_booking_option": "تحصيل الدفع عن الحجز",
"hold_option": "فرض رسوم عدم الحضور",
@@ -1727,6 +1794,7 @@
"complete_your_booking": "أكمل الحجز",
"complete_your_booking_subject": "أكمل الحجز: {{title}} في {{date}}",
"confirm_your_details": "تأكيد التفاصيل الخاصة بك",
"copy_invite_link": "نسخ رابط الدعوة",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "أنت على وشك تحصيل مبلغ {{amount, currency}} من أحد الحضور. هل أنت متأكد من أنك تريد المتابعة؟",
"charge_attendee": "تحصيل مبلغ {{amount, currency}} من أحد الحضور",
@@ -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",
+16 -11
View File
@@ -17,6 +17,7 @@
"verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability",
"verify_email_email_header": "Verify your email address",
"verify_email_email_button": "Verify email",
"copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.",
"verify_email_email_body": "Please verify your email address by clicking the button below.",
"verify_email_email_link_text": "Here's the link in case you don't like clicking buttons:",
"email_sent": "Email sent successfully",
@@ -477,7 +478,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 +696,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 +1087,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 +1150,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 +1495,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 +1625,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 +1788,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 +1916,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 +1926,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 +1941,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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

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