Compare commits

...
69 Commits
Author SHA1 Message Date
Peer Richelsenandnafees nazik efb9bf7116 replacing intercom with helpscout (#7865)
release.yml / release (push) Canceled after 0s
Co-authored-by: nafees nazik <nafeesnazik21@gmail.com>
2023-03-22 14:29:14 +00:00
49ee5d300b Fix rescheduling updating calendars & duplicate bookings (#7851)
* Fix rescheduling

* Fix tests

* Clean up

* Update packages/core/EventManager.ts

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>

* Commit fix

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-22 15:23:09 +01:00
Peer Richelsen 1512551903 Update package.json 2023-03-22 15:04:02 +01:00
Shane MaglangitandBailey Pumfleet 1b459eb212 style: Increase input width for duration limits (#7779)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-03-22 11:59:03 +00:00
Nafees Nazik e4eb21bf8d fix: metadata get lost when rescheduling (#7873) 2023-03-22 11:17:19 +01:00
79f7a16f62 fix: pass timeFormat to booking page (#7642)
* fix: pass timeFormat to booking page

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

* fix: add zod schema, fix type eroor

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

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-03-22 15:45:16 +05:30
Hariom Balhara 7fc884912e Make sure that label is also set (#7874) 2023-03-22 04:40:49 +00:00
Hariom Balhara 20b3efbcb6 Handle empty label from Custom inputs (#7871)
* Handle empty label from Custom inputs

* Fix comment
2023-03-21 18:44:18 +00:00
zomars 427b19238d Update yarn.lock 2023-03-21 11:24:08 -07:00
Peer Richelsen 696122a701 updated daily.co env description (#7869) 2023-03-21 18:18:06 +00:00
Shafkathullah IhsanandOmar López 7d9c21cf9a adds fix-availability-slot-addition (#7420)
Co-authored-by: Omar López <zomars@me.com>
2023-03-21 17:56:16 +00:00
Peer Richelsen eb93524f49 Updated Daily.co Partnership links (#7868) 2023-03-21 18:51:30 +01:00
Peer Richelsen 07fd3ad29f i18n for cal video sidebar (#7862) 2023-03-21 23:06:04 +08:00
Hariom Balhara 389633394b Fix/conflicts of custom input Name, with system field (#7856)
* Dont conflict with system fields

* Allow considering a field that has system fields name to be fully editable
2023-03-21 11:47:17 +01:00
Efraín RochínandBailey Pumfleet d27817ad51 Add a minute in endTime working hours when 11:59 is selected (#7852)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-03-21 10:43:36 +00:00
7af23305eb fix: use zod in getting started and settings (#7698)
* fix: use zod in getting started and settings

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

* chore: change name of variable

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

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-03-21 10:43:07 +00:00
Peer Richelsen db947591ad added team recording hint to cal video (#7855) 2023-03-21 10:39:49 +01:00
Joe Au-Yeung 2ef4a05750 Add team members to iCal, Apple Cal, Outlook, and Lark calendar events (#7846)
* Outlook add team members

* iCal & Apple add team members

* Exchange add team members

* iCal & App cal update include attendees

* Exchange reschedule

* GCal add team members on reschedule

* Lark cal loop through team members
2023-03-21 04:15:59 +01:00
Richard Poelderl f1ac45ee19 Add motif env vars (#7847)
* turn inline logo & shell headers into h3

* turbo (website): add motif.land env vars

* remove $ prefix

* git checkout origin/main packages/embeds/embed-core/src/embed-iframe.ts

* git checkout origin/main packages/prisma/seed-app-store.config.json
2023-03-21 04:15:31 +01:00
zomars 03c9fc7952 Update yarn.lock 2023-03-20 13:03:42 -07:00
Alex van Andel de2a56ebcb v2.7.2 2023-03-20 20:37:31 +01:00
Carina WollendorferandCarinaWolli 0bae688da1 Fixes additional inputs as variables in custom workflow templates (#7840)
* fix additonal inputs as dynamic text variables

* pass responses in same format

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-03-20 20:08:14 +01:00
sean-brydon 7967a65ade Edit location dialog select (#7833)
* Upgrade react-select

* figma 1-1

* Style X

* Fixes timezone select

* Add location select styles

* remove menu is open test
2023-03-20 19:46:22 +01:00
Alex van Andel 1cd96d4c25 No delete of booking on seated events, fix id->uid (#7839)
* No delete of booking on seated events, fix id->uid

* Use uid or id
2023-03-20 18:30:00 +00:00
Peer RichelsenandCarina Wollendorfer 19bc329377 fixed location input, date overwrite dialog (#7778)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-03-20 18:55:40 +01:00
Omar López 0c29f2a135 Adds team members to gCal (#7841) 2023-03-20 17:52:10 +00:00
sean-brydon a145de56ee Adds multiselect checkbox fixes (#7835) 2023-03-20 17:43:32 +01:00
d2352da54b fix: remove cal.com from title when branding is disabled (#7827)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-20 11:41:03 +00:00
Efraín Rochín 5d7685f2c0 revalidate event type page on update (#7685) 2023-03-20 11:21:14 +00:00
ee8b2e67f0 feat: add rate limiting to reset password endpoint (#7700)
* feat: add rate limiting to reset password endpoint

* Update apps/web/pages/api/auth/forgot-password.ts

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
2023-03-20 11:20:29 +00:00
Miguel Nieto a0c9123ee7 fix: Add appName to generate_api_key_description (#7819) 2023-03-20 11:18:46 +00:00
sean-brydon 131611e114 Upgrade react-select (#7824)
* Upgrade react-select

* figma 1-1

* Style X

* Fixes timezone select
2023-03-20 11:18:23 +00:00
Omar López b3407e19b5 Fixes vercel url usage on the client (#7823) 2023-03-20 11:16:46 +00:00
Nadav Schwartz 0d4b0847d4 Update dialog min-width (#7806) 2023-03-20 10:34:08 +01:00
5d8575abf4 [CAL-1096] Embed selection modal - UI update (#7327)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-20 15:52:54 +08:00
Nafees Nazik 631bf4d9fe fix: hide giphy in cancellation detail page (#7828) 2023-03-20 08:48:22 +01:00
darshan 8fd1d76227 fix add calendar button width and alignment (#7814) 2023-03-19 15:41:56 +01:00
22b189cb19 fix: render markdown in og images (#7693)
* chore: remove unused packages

* fix: add dangerously set inner html

* fix: use markdown

* fix: add remove html regex

* fix: revert dangerouslySetInnerHTML

* fix: title

* added yarn.lock

* yarn

* fix: yarn lock

* Update yarn.lock

* Update yarn lock

* Revert yarn lock changes

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
2023-03-19 07:06:16 +00:00
Alex van Andel 27c29c65e2 Bugfix/format without time zone (#7820)
* Format Office365 as non-UTC (defer to timeZone)

* Ensure the start_time is provided in UTC
2023-03-18 22:47:15 +01:00
zomars f0c7d7ff29 Uses internal @calcom/embed-snippet 2023-03-17 16:57:58 -07:00
Richard Poelderl 679965782e chore: update docs links (#7782)
* replace docs.cal.com references with cal.com/docs
2023-03-17 16:42:27 -07:00
Omar López 650ecc7a85 Upgrades turbo to 1.8.3 (#7809) 2023-03-17 21:14:12 +00:00
Joe Au-Yeung 8d27dcdea3 Use dayjs.tz (#7808) 2023-03-17 20:05:22 +00:00
Carina WollendorferandCarinaWolli 6128be8860 Fixes that sometimes last shown slot was unavailable (#7804)
* fix unavailable slots + add relevant test

* code clean up

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-03-17 13:02:56 -07:00
Joe Au-Yeung 13577a5dbe If no expiry date then refresh the token (#7805) 2023-03-17 17:20:41 +00:00
Efraín Rochín 9ba13a3f3c Amend logic to calc slots time already in DST (#7792)
* amend logic to calculate DST time on buildSlots

* send start and end date in UTC

* port the logic to calc DST from buildSlots to inWithinAvailableHours
2023-03-17 12:53:05 +01:00
Sai Deepesh 9ac562cfba feat: add toast dynamically on booking page (#7672) 2023-03-17 16:02:02 +05:30
sean-brydon e1411b3fb2 Fixes select getting darkmode text color even on lightmode (#7799) 2023-03-17 15:34:26 +05:30
Nafees Nazik 0f89415ec3 fix: videoCallUrl is not saved in metadata after rescheduling (#7715) 2023-03-17 13:54:48 +05:30
Nafees Nazik c6ff3dca4a feat: take locale relavant while formatting date in /bookings (#7691)
* feat: use locale while formatting

* fix: tooltip content
2023-03-17 09:12:51 +01:00
Nafees Nazik e001c0b88f test: add multiple guests e2e test (#7774) 2023-03-17 12:33:54 +05:30
zomars 5cbd334a42 Simplifies admin password role checks 2023-03-16 14:02:38 -07:00
Hariom Balhara 470ef737b5 Make embed tests work with single playwright config (#7781)
* Fix Embed test

* Remove only
2023-03-16 11:16:09 -07:00
Joe Au-Yeung f3e8bed1cf Fix dynamic bookings overwriting users (#7699)
* Move sorting dynamic user list

* Remove console logs
2023-03-16 11:12:30 -07:00
zomars 750362c0cf Update yarn lock 2023-03-16 10:44:28 -07:00
zomars 3811dd564d Adds back playwright for OG images generator 2023-03-16 10:42:47 -07:00
Alex van Andel 99fe67b879 v2.7.1
release.yml / release (push) Canceled after 0s
2023-03-16 15:24:19 +00:00
Alex van AndelandKeith Williams 2685e8154f Hotfix/account provider should be lowercase (#7763)
* Fix passThrough console error (passHref is not valid)

* Fix 'use-identity-login' error when logging in with idP

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-03-16 15:12:45 +00:00
4308ea8d52 Fix Google Calendar sharing guest information logic (#7323)
* Fix logic

* Update packages/app-store/googlecalendar/lib/CalendarService.ts

Co-authored-by: alannnc <alannnc@gmail.com>

* Fixed linting

* Add seats logic to reschedule

---------

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-03-16 14:50:35 +00:00
Alex van AndelandPeer Richelsen 358aa8be2b Prevent refresh_token nulling if the returned refresh token is empty (#7764)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-16 14:04:30 +00:00
Alex van Andel b20c0df4e8 Remove 'userIsOwner' from isCancelled (#7773) 2023-03-16 13:29:16 +00:00
Nafees NazikandPeer Richelsen 09af14b3f4 fix: badge component line-height according to the DS (#7766)
* fix: badge line-height

* fix: revert padding top and bottom

* fix: remove large prop

* fix: remove large prop

* feat: make md default

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-16 11:51:40 +00:00
77df4117d0 refactor: event types kbar action (#7423)
* refactor: event types action

* chore : wrap actions inside useMemo

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-16 09:49:12 +00:00
Nafees NazikandHariom Balhara 84f7924215 Hotfix: guest are not added to the invite and emails (#7767)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-03-16 14:34:17 +05:30
c8554e6878 [CAL-203] App Store: Add <iframe> as an option in the slider (#7324)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-16 05:30:11 +00:00
Hariom BalharaandAlex van Andel 5993e0842c Fix: EventType crash when Moving up and down beyond boundaries (#7765)
* Make sure that moving up and down doesnt go beyond boundary

* Update packages/features/form-builder/FormBuilder.tsx

Co-authored-by: Alex van Andel <me@alexvanandel.com>

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-16 05:25:37 +00:00
Hariom Balhara 7179b09d7b Allow rescheduleReason to be marked required (#7729) 2023-03-16 10:40:20 +05:30
Nafees Nazik 325dc9805f fix: reschedule reason title in email (#7746)
* fix: reschedule reason title

* fix: remove aditional check
2023-03-16 03:21:59 +00:00
Alex van Andel 0f6a678b74 Disable PRISMA_GENERATE_DATAPROXY 2023-03-16 01:07:02 +00:00
137 changed files with 1666 additions and 1484 deletions

No files matched your search

+6 -1
View File
@@ -20,12 +20,17 @@
# - APP STORE ********************************************************************************************** # - APP STORE **********************************************************************************************
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️ # ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
# - DAILY.CO VIDEO # - DAILY.CO VIDEO
# Enables Cal Video. to get your key
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
# 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
# @see https://github.com/calcom/cal.com#obtaining-daily-api-credentials
DAILY_API_KEY= DAILY_API_KEY=
DAILY_SCALE_PLAN='' DAILY_SCALE_PLAN=''
# - GOOGLE CALENDAR/MEET/LOGIN # - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google # Needed to enable Google Calendar integration and Login with Google
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials # @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS= GOOGLE_API_CREDENTIALS=
# To enable Login with Google you need to: # To enable Login with Google you need to:
+6 -3
View File
@@ -22,10 +22,13 @@ CALCOM_LICENSE_KEY=
# - DATABASE ************************************************************************************************ # - DATABASE ************************************************************************************************
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
# Opt-in to a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you will be able to scale your DB independently of your application. # Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy # @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
PRISMA_GENERATE_DATAPROXY=false # PRISMA_GENERATE_DATAPROXY=true
PRISMA_GENERATE_DATAPROXY=
# *********************************************************************************************************** # ***********************************************************************************************************
# - SHARED ************************************************************************************************** # - SHARED **************************************************************************************************
+2 -1
View File
@@ -88,6 +88,7 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
- [React](https://reactjs.org/?ref=cal.com) - [React](https://reactjs.org/?ref=cal.com)
- [Tailwind](https://tailwindcss.com/?ref=cal.com) - [Tailwind](https://tailwindcss.com/?ref=cal.com)
- [Prisma](https://prisma.io/?ref=cal.com) - [Prisma](https://prisma.io/?ref=cal.com)
- [Daily.co](https://go.cal.com/daily)
## Stay Up-to-Date ## Stay Up-to-Date
@@ -432,7 +433,7 @@ following
### Obtaining Daily API Credentials ### Obtaining Daily API Credentials
1. Open [Daily](https://www.daily.co/) and sign into your account. 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab. 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
3. Copy your API key. 3. Copy your API key.
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file. 4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
@@ -94,16 +94,6 @@ const DestinationCalendarSelector = ({
styles={{ styles={{
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }), placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }), singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
option: (defaultStyles, state) => ({
...defaultStyles,
backgroundColor: state.isSelected
? state.isFocused
? "var(--brand-color)"
: "var(--brand-color)"
: state.isFocused
? "var(--brand-color-dark-mode)"
: "var(--brand-text-color)",
}),
control: (defaultStyles) => { control: (defaultStyles) => {
return { return {
...defaultStyles, ...defaultStyles,
+78 -75
View File
@@ -327,10 +327,10 @@ const embeds: {
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z" d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white" fill="white"
/> />
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" /> <rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" /> <rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="2" fill="#E1E1E1" /> <rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="6" fill="#F3F4F6" />
<rect x="48" y="86.5" width="48" height="4" rx="1" fill="#E1E1E1" /> <rect x="48" y="86.5" width="48" height="4" rx="6" fill="#F3F4F6" />
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
@@ -378,9 +378,9 @@ const embeds: {
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" /> <rect x="24.5" y="51" width="139" height="163" rx="6" stroke="#292929" />
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" /> <rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" /> <rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
</svg> </svg>
), ),
}, },
@@ -400,12 +400,12 @@ const embeds: {
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z" d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white" fill="white"
/> />
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" /> <rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" /> <rect x="24" y="50.5" width="120" height="76" rx="6" fill="#F3F4F6" />
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" /> <rect x="24" y="138.5" width="120" height="76" rx="6" fill="#F3F4F6" />
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" /> <rect x="156" y="50.5" width="128" height="164" rx="6" fill="#F3F4F6" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" /> <rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" /> <rect x="226" y="223.5" width="66" height="26" rx="6" fill="#292929" />
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" /> <rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
</svg> </svg>
), ),
@@ -426,62 +426,65 @@ const embeds: {
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z" d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white" fill="white"
/> />
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" /> <rect x="24" y="0.50293" width="260" height="24" rx="6" fill="#F3F4F6" />
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" /> <rect x="24" y="35" width="259" height="192" rx="5.5" fill="#F9FAFB" />
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" /> <g filter="url(#filter0_i_3223_14162)">
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" /> <rect opacity="0.8" x="40" y="99" width="24" height="24" rx="2" fill="#E5E7EB" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" /> <rect x="40" y="127" width="48" height="8" rx="1" fill="#E5E7EB" />
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" fill="#F8F8F8" /> <rect x="40" y="139" width="82" height="8" rx="1" fill="#E5E7EB" />
<rect opacity="0.8" x="108" y="85" width="80" height="8" rx="2" fill="#E1E1E1" /> <rect x="40" y="151" width="34" height="4" rx="1" fill="#E5E7EB" />
<rect x="108" y="97" width="48" height="4" rx="1" fill="#E1E1E1" /> <rect x="40" y="159" width="34" height="4" rx="1" fill="#E5E7EB" />
<rect x="109" y="110" width="6" height="6" rx="1" fill="#C6C6C6" /> </g>
<rect x="121" y="110" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="152" y="48" width="2" height="169" rx="2" fill="#E5E7EB" />
<rect x="133" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="110" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect opacity="0.8" x="176" y="84" width="80" height="8" rx="2" fill="#E5E7EB" />
<rect x="157" y="110" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="176" y="96" width="48" height="4" rx="1" fill="#E5E7EB" />
<rect x="169" y="110" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="177" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="181" y="110" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="189" y="109" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="193" y="110" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="201" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="145" y="124" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="213" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="157" y="124" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="225" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="169" y="124" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="237" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="181" y="124" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="249" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="193" y="124" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="261" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="109" y="136" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="213" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="121" y="136" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="225" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
<path <rect x="237" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
d="M121 135H127V133H121V135ZM128 136V142H130V136H128ZM127 143H121V145H127V143ZM120 142V136H118V142H120ZM121 143C120.448 143 120 142.552 120 142H118C118 143.657 119.343 145 121 145V143ZM128 142C128 142.552 127.552 143 127 143V145C128.657 145 130 143.657 130 142H128ZM127 135C127.552 135 128 135.448 128 136H130C130 134.343 128.657 133 127 133V135ZM121 133C119.343 133 118 134.343 118 136H120C120 135.448 120.448 135 121 135V133Z" <rect x="249" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
fill="#3E3E3E" <rect x="261" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
/> <rect x="177" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="133" y="136" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="189" y="135" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="145" y="136" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="187.3" y="133.4" width="9" height="9" rx="1.5" stroke="#0D121D" />
<rect x="157" y="136" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="201" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="169" y="136" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="213" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="181" y="136" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="225" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="193" y="136" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="237" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="109" y="148" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="249" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="121" y="148" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="261" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="133" y="148" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="177" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="145" y="148" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="189" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="157" y="148" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="201" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="169" y="148" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="213" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="181" y="148" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="225" y="147" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="193" y="148" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="237" y="147" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="109" y="160" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="249" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="121" y="160" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="261" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="133" y="160" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="177" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="145" y="160" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="189" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="157" y="160" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="201" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="169" y="160" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="213" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="181" y="160" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="225" y="159" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="193" y="160" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="237" y="159" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="109" y="172" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="249" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="121" y="172" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="261" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="133" y="172" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="177" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="145" y="172" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="189" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" /> <rect x="201" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" /> <rect x="213" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" /> <rect x="225" y="171" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="237" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="24" y="35" width="259" height="192" rx="5.5" stroke="#101010" />
<rect x="24" y="241.503" width="260" height="24" rx="6" fill="#F3F4F6" />
</svg> </svg>
), ),
}, },
@@ -629,9 +632,9 @@ const ChooseEmbedTypesDialogContent = () => {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
return ( return (
<DialogContent type="creation" size="lg"> <DialogContent className="rounded-lg p-10" type="creation" size="lg">
<div className="mb-4"> <div className="mb-2">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title"> <h3 className="mb-2 text-2xl font-bold leading-none text-gray-900" id="modal-title">
{t("how_you_want_add_cal_site", { appName: APP_NAME })} {t("how_you_want_add_cal_site", { appName: APP_NAME })}
</h3> </h3>
<div> <div>
@@ -641,7 +644,7 @@ const ChooseEmbedTypesDialogContent = () => {
<div className="flex items-start"> <div className="flex items-start">
{embeds.map((embed, index) => ( {embeds.map((embed, index) => (
<button <button
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-gray-100 ltr:mr-2 rtl:ml-2" className="hover:border-primary-900 w-1/3 rounded-md border border-gray-200 bg-gray-50 p-6 text-left hover:rounded-md hover:bg-gray-100 ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0"
key={index} key={index}
data-testid={embed.type} data-testid={embed.type}
onClick={() => { onClick={() => {
@@ -649,11 +652,11 @@ const ChooseEmbedTypesDialogContent = () => {
embedType: embed.type, embedType: embed.type,
}); });
}}> }}>
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white"> <div className="order-none box-border flex-none rounded-md border border-solid bg-white">
{embed.illustration} {embed.illustration}
</div> </div>
<div className="mt-2 font-medium text-gray-900">{embed.title}</div> <div className="mt-4 font-semibold leading-none text-gray-900">{embed.title}</div>
<p className="text-sm text-gray-500">{embed.subtitle}</p> <p className="mt-2 text-sm text-gray-500">{embed.subtitle}</p>
</button> </button>
))} ))}
</div> </div>
+23 -14
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import type { IframeHTMLAttributes } from "react";
import React, { useState } from "react"; import React, { useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
@@ -45,12 +46,12 @@ const Component = ({
tos, tos,
privacy, privacy,
isProOnly, isProOnly,
images, descriptionItems,
isTemplate, isTemplate,
dependencies, dependencies,
}: Parameters<typeof App>[0]) => { }: Parameters<typeof App>[0]) => {
const { t } = useLocale(); const { t } = useLocale();
const hasImages = images && images.length > 0; const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
const router = useRouter(); const router = useRouter();
const mutation = useAddAppMutation(null, { const mutation = useAddAppMutation(null, {
@@ -94,17 +95,25 @@ const Component = ({
return ( return (
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0"> <div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
{hasImages && ( {hasDescriptionItems && (
<div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md"> <div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
{images ? ( {descriptionItems ? (
images.map((img) => ( descriptionItems.map((descriptionItem, index) =>
<img typeof descriptionItem === "object" ? (
key={img} <div
src={img} key={`iframe-${index}`}
alt={`Screenshot of app ${name}`} className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full" <iframe allowFullScreen {...descriptionItem.iframe} />
/> </div>
)) ) : (
<img
key={descriptionItem}
src={descriptionItem}
alt={`Screenshot of app ${name}`}
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
/>
)
)
) : ( ) : (
<SkeletonText /> <SkeletonText />
)} )}
@@ -113,7 +122,7 @@ const Component = ({
<div <div
className={classNames( className={classNames(
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0", "sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
hasImages && "lg:ml-8" hasDescriptionItems && "lg:ml-8"
)}> )}>
<div className="mb-8 flex pt-4"> <div className="mb-8 flex pt-4">
<header> <header>
@@ -354,7 +363,7 @@ export default function App(props: {
privacy?: string; privacy?: string;
licenseRequired: AppType["licenseRequired"]; licenseRequired: AppType["licenseRequired"];
isProOnly: AppType["isProOnly"]; isProOnly: AppType["isProOnly"];
images?: string[]; descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
isTemplate?: boolean; isTemplate?: boolean;
disableInstall?: boolean; disableInstall?: boolean;
dependencies?: string[]; dependencies?: string[];
@@ -96,16 +96,6 @@ const DestinationCalendarSelector = ({
styles={{ styles={{
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }), placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }), singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
option: (defaultStyles, state) => ({
...defaultStyles,
backgroundColor: state.isSelected
? state.isFocused
? "var(--brand-color)"
: "var(--brand-color)"
: state.isFocused
? "var(--brand-color-dark-mode)"
: "var(--brand-text-color)",
}),
control: (defaultStyles) => { control: (defaultStyles) => {
return { return {
...defaultStyles, ...defaultStyles,
@@ -94,7 +94,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
slots.map((slot) => { slots.map((slot) => {
type BookingURL = { type BookingURL = {
pathname: string; pathname: string;
query: Record<string, string | number | string[] | undefined>; query: Record<string, string | number | string[] | undefined | TimeFormat>;
}; };
const bookingUrl: BookingURL = { const bookingUrl: BookingURL = {
pathname: router.pathname.endsWith("/embed") ? "../book" : "book", pathname: router.pathname.endsWith("/embed") ? "../book" : "book",
@@ -103,6 +103,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
date: dayjs.utc(slot.time).tz(timeZone()).format(), date: dayjs.utc(slot.time).tz(timeZone()).format(),
type: eventTypeId, type: eventTypeId,
slug: eventTypeSlug, slug: eventTypeSlug,
timeFormat,
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */ /** Treat as recurring only when a count exist and it's not a rescheduling workflow */
count: recurringCount && !rescheduleUid ? recurringCount : undefined, count: recurringCount && !rescheduleUid ? recurringCount : undefined,
...(ethSignature ? { ethSignature } : {}), ...(ethSignature ? { ethSignature } : {}),
@@ -135,7 +135,6 @@ const BookingDescription: FC<Props> = (props) => {
<li key={idx}> <li key={idx}>
<Badge <Badge
variant="gray" variant="gray"
size="lg"
className={classNames( className={classNames(
duration === dur.toString() duration === dur.toString()
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast" ? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
@@ -46,7 +46,10 @@ function BookingListItem(booking: BookingItemProps) {
// Get user so we can determine 12/24 hour format preferences // Get user so we can determine 12/24 hour format preferences
const query = useMeQuery(); const query = useMeQuery();
const user = query.data; const user = query.data;
const { t } = useLocale(); const {
t,
i18n: { language },
} = useLocale();
const utils = trpc.useContext(); const utils = trpc.useContext();
const router = useRouter(); const router = useRouter();
const [rejectionReason, setRejectionReason] = useState<string>(""); const [rejectionReason, setRejectionReason] = useState<string>("");
@@ -195,7 +198,9 @@ function BookingListItem(booking: BookingItemProps) {
); );
}; };
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); const startTime = dayjs(booking.startTime)
.locale(language)
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
@@ -431,7 +436,10 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
// Get user so we can determine 12/24 hour format preferences // Get user so we can determine 12/24 hour format preferences
const query = useMeQuery(); const query = useMeQuery();
const user = query.data; const user = query.data;
const { t } = useLocale(); const {
t,
i18n: { language },
} = useLocale();
const now = new Date(); const now = new Date();
const recurringCount = recurringDates.filter((date) => { const recurringCount = recurringDates.filter((date) => {
return ( return (
@@ -461,7 +469,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
<p key={key} className={classNames(pastOrCancelled && "line-through")}> <p key={key} className={classNames(pastOrCancelled && "line-through")}>
{formatTime(aDate, user?.timeFormat, user?.timeZone)} {formatTime(aDate, user?.timeFormat, user?.timeZone)}
{" - "} {" - "}
{dayjs(aDate).format("D MMMM YYYY")} {dayjs(aDate).locale(language).format("D MMMM YYYY")}
</p> </p>
); );
})}> })}>
@@ -86,7 +86,7 @@ export default function CancelBooking(props: Props) {
const res = await fetch("/api/cancel", { const res = await fetch("/api/cancel", {
body: JSON.stringify({ body: JSON.stringify({
id: booking?.id, uid: booking?.uid,
cancellationReason: cancellationReason, cancellationReason: cancellationReason,
allRemainingBookings, allRemainingBookings,
// @NOTE: very important this shouldn't cancel with number ID use uid instead // @NOTE: very important this shouldn't cancel with number ID use uid instead
@@ -154,6 +154,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
nofollow: eventType.hidden, nofollow: eventType.hidden,
noindex: eventType.hidden, noindex: eventType.hidden,
}} }}
isBrandingHidden={restProps.isBrandingHidden}
/> />
<BookingPageTagManager eventType={eventType} /> <BookingPageTagManager eventType={eventType} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} /> <CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
@@ -4,7 +4,7 @@ import { useSession } from "next-auth/react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { useEffect, useMemo, useReducer, useState } from "react";
import { useForm, useFormContext } from "react-hook-form"; import { useForm, useFormContext } from "react-hook-form";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { z } from "zod"; import { z } from "zod";
@@ -15,7 +15,6 @@ import { getEventLocationType, locationKeyToString } from "@calcom/app-store/loc
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import { getEventTypeAppData } from "@calcom/app-store/utils"; import { getEventTypeAppData } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location"; import type { LocationObject } from "@calcom/core/location";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { import {
useEmbedNonStylesConfig, useEmbedNonStylesConfig,
@@ -36,19 +35,19 @@ import classNames from "@calcom/lib/classNames";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme"; import useTheme from "@calcom/lib/hooks/useTheme";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar"; import { TimeFormat } from "@calcom/lib/timeFormat";
import { Button, Form, Tooltip } from "@calcom/ui"; import { Button, Form, Tooltip } from "@calcom/ui";
import { FiAlertTriangle, FiCalendar, FiRefreshCw, FiUser } from "@calcom/ui/components/icon"; import { FiAlertTriangle, FiCalendar, FiRefreshCw, FiUser } from "@calcom/ui/components/icon";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock"; import { timeZone } from "@lib/clock";
import useRouterQuery from "@lib/hooks/useRouterQuery"; import useRouterQuery from "@lib/hooks/useRouterQuery";
import createBooking from "@lib/mutations/bookings/create-booking"; import createBooking from "@lib/mutations/bookings/create-booking";
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking"; import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
import { parseDate, parseRecurringDates } from "@lib/parseDate"; import { parseRecurringDates, parseDate } from "@lib/parseDate";
import type { Gate, GateState } from "@components/Gates"; import type { Gate, GateState } from "@components/Gates";
import Gates from "@components/Gates"; import Gates from "@components/Gates";
@@ -58,6 +57,8 @@ import type { BookPageProps } from "../../../pages/[user]/book";
import type { HashLinkPageProps } from "../../../pages/d/[link]/book"; import type { HashLinkPageProps } from "../../../pages/d/[link]/book";
import type { TeamBookingPageProps } from "../../../pages/team/[slug]/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 */ /** These are like 40kb that not every user needs */
const BookingDescriptionPayment = dynamic( const BookingDescriptionPayment = dynamic(
() => import("@components/booking/BookingDescriptionPayment") () => import("@components/booking/BookingDescriptionPayment")
@@ -78,6 +79,7 @@ const BookingFields = ({
const { t } = useLocale(); const { t } = useLocale();
const { watch, setValue } = useFormContext(); const { watch, setValue } = useFormContext();
const locationResponse = watch("responses.location"); const locationResponse = watch("responses.location");
const currentView = rescheduleUid ? "reschedule" : "";
return ( 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 // 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
@@ -87,12 +89,16 @@ const BookingFields = ({
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful // Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
let readOnly = let readOnly =
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid; (field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
let noLabel = false; let noLabel = false;
let hidden = !!field.hidden; let hidden = !!field.hidden;
const fieldViews = field.views;
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
return null;
}
if (field.name === SystemField.Enum.rescheduleReason) { if (field.name === SystemField.Enum.rescheduleReason) {
if (!rescheduleUid) {
return null;
}
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule // rescheduleReason is a reschedule specific field and thus should be editable during reschedule
readOnly = false; readOnly = false;
} }
@@ -180,6 +186,22 @@ const BookingFields = ({
); );
}; };
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 = ({ const BookingPage = ({
eventType, eventType,
booking, booking,
@@ -218,20 +240,6 @@ const BookingPage = ({
duration = Number(queryDuration); duration = Number(queryDuration);
} }
// This is a workaround for forcing the same time format for both server side rendering and client side rendering
// At initial render, we use the default time format which is 12H
const [withDefaultTimeFormat, setWithDefaultTimeFormat] = useState(true);
const parseDateFunc = useCallback(
(date: string | null | Dayjs) => {
return parseDate(date, i18n, withDefaultTimeFormat);
},
[withDefaultTimeFormat]
);
// After intial render on client side, we let parseDateFunc to use the time format from the localStorage
useEffect(() => {
setWithDefaultTimeFormat(false);
}, []);
useEffect(() => { useEffect(() => {
if (top !== window) { if (top !== window) {
//page_view will be collected automatically by _middleware.ts //page_view will be collected automatically by _middleware.ts
@@ -288,11 +296,17 @@ const BookingPage = ({
}, },
}); });
const rescheduleUid = router.query.rescheduleUid as string; const {
data: { timeFormat, rescheduleUid, date },
} = useTypedQuery(routerQuerySchema);
useTheme(profile.theme); useTheme(profile.theme);
const date = asStringOrNull(router.query.date);
const querySchema = getBookingResponsesPartialSchema({ const querySchema = getBookingResponsesPartialSchema({
bookingFields: getBookingFieldsWithSystemFields(eventType), eventType: {
bookingFields: getBookingFieldsWithSystemFields(eventType),
},
view: rescheduleUid ? "reschedule" : "booking",
}); });
const parsedQuery = querySchema.parse({ const parsedQuery = querySchema.parse({
@@ -372,7 +386,8 @@ const BookingPage = ({
const bookingFormSchema = z const bookingFormSchema = z
.object({ .object({
responses: getBookingResponsesSchema({ responses: getBookingResponsesSchema({
bookingFields: getBookingFieldsWithSystemFields(eventType), eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
view: rescheduleUid ? "reschedule" : "booking",
}), }),
}) })
.passthrough(); .passthrough();
@@ -390,26 +405,17 @@ const BookingPage = ({
// Calculate the booking date(s) // Calculate the booking date(s)
let recurringStrings: string[] = [], let recurringStrings: string[] = [],
recurringDates: Date[] = []; recurringDates: Date[] = [];
const parseRecurringDatesFunc = useCallback(
(date: string | null | Dayjs, recurringEvent: RecurringEvent, recurringCount: number) => {
return parseRecurringDates(
{
startDate: date,
timeZone: timeZone(),
recurringEvent: recurringEvent,
recurringCount: recurringCount,
withDefaultTimeFormat: withDefaultTimeFormat,
},
i18n
);
},
[withDefaultTimeFormat, date, eventType.recurringEvent, recurringEventCount]
);
if (eventType.recurringEvent?.freq && recurringEventCount !== null) { if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
[recurringStrings, recurringDates] = parseRecurringDatesFunc( [recurringStrings, recurringDates] = parseRecurringDates(
date, {
eventType.recurringEvent, startDate: date,
parseInt(recurringEventCount.toString()) timeZone: timeZone(),
recurringEvent: eventType.recurringEvent,
recurringCount: parseInt(recurringEventCount.toString()),
selectedTimeFormat: timeFormat,
},
i18n
); );
} }
@@ -437,8 +443,8 @@ const BookingPage = ({
const recurringEventId = uuidv4(); const recurringEventId = uuidv4();
const recurringBookings = recurringDates.map((recurringDate) => ({ const recurringBookings = recurringDates.map((recurringDate) => ({
...bookingValues, ...bookingValues,
start: dayjs(recurringDate).format(), start: dayjs(recurringDate).utc().format(),
end: dayjs(recurringDate).add(duration, "minute").format(), end: dayjs(recurringDate).utc().add(duration, "minute").format(),
eventTypeId: eventType.id, eventTypeId: eventType.id,
eventTypeSlug: eventType.slug, eventTypeSlug: eventType.slug,
recurringEventId, recurringEventId,
@@ -457,8 +463,8 @@ const BookingPage = ({
} else { } else {
mutation.mutate({ mutation.mutate({
...bookingValues, ...bookingValues,
start: dayjs(date).tz(timeZone()).format(), start: dayjs(date).utc().format(),
end: dayjs(date).tz(timeZone()).add(duration, "minute").format(), end: dayjs(date).utc().add(duration, "minute").format(),
eventTypeId: eventType.id, eventTypeId: eventType.id,
eventTypeSlug: eventType.slug, eventTypeSlug: eventType.slug,
timeZone: timeZone(), timeZone: timeZone(),
@@ -539,7 +545,7 @@ const BookingPage = ({
<div className="text-sm font-medium"> <div className="text-sm font-medium">
{isClientTimezoneAvailable && {isClientTimezoneAvailable &&
(rescheduleUid || !eventType.recurringEvent?.freq) && (rescheduleUid || !eventType.recurringEvent?.freq) &&
`${parseDateFunc(date)}`} `${parseDate(date, i18n, timeFormat)}`}
{isClientTimezoneAvailable && {isClientTimezoneAvailable &&
!rescheduleUid && !rescheduleUid &&
eventType.recurringEvent?.freq && eventType.recurringEvent?.freq &&
@@ -569,7 +575,7 @@ const BookingPage = ({
<FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" /> <FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
{isClientTimezoneAvailable && {isClientTimezoneAvailable &&
typeof booking.startTime === "string" && typeof booking.startTime === "string" &&
parseDateFunc(dayjs(booking.startTime))} parseDate(dayjs(booking.startTime), i18n, timeFormat)}
</p> </p>
</div> </div>
)} )}
@@ -638,6 +644,7 @@ const BookingPage = ({
</div> </div>
</div> </div>
</main> </main>
<Toaster position="bottom-right" />
</Gates> </Gates>
); );
}; };
@@ -17,6 +17,7 @@ import {
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Input } from "@calcom/ui";
import { Button, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui"; import { Button, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui";
import { FiMapPin } from "@calcom/ui/components/icon"; import { FiMapPin } from "@calcom/ui/components/icon";
@@ -52,7 +53,7 @@ const LocationInput = (props: {
const { control } = useFormContext() as typeof locationFormMethods; const { control } = useFormContext() as typeof locationFormMethods;
if (eventLocationType?.organizerInputType === "text") { if (eventLocationType?.organizerInputType === "text") {
return ( return (
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} /> <Input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
); );
} else if (eventLocationType?.organizerInputType === "phone") { } else if (eventLocationType?.organizerInputType === "phone") {
return ( return (
@@ -171,7 +172,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
id="locationInput" id="locationInput"
placeholder={t(eventLocationType.organizerInputPlaceholder || "")} placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
required required
className="block w-full rounded-sm border-gray-300 text-sm"
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
defaultValue={ defaultValue={
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
@@ -424,7 +424,7 @@ const IntervalLimitItem = ({
<TextField <TextField
required required
type="number" type="number"
containerClassName={`${textFieldSuffix ? "w-36" : "w-16"} -mb-1`} containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
placeholder={`${value}`} placeholder={`${value}`}
min={step} min={step}
step={step} step={step}
@@ -198,8 +198,16 @@ export const EventSetupTab = (
return ( return (
<li <li
onClick={() => {
locationFormMethods.setValue("locationType", location.type);
locationFormMethods.unregister("locationLink");
locationFormMethods.unregister("locationAddress");
locationFormMethods.unregister("locationPhoneNumber");
setEditingLocationType(location.type);
openLocationModal(location.type);
}}
key={`${location.type}${index}`} key={`${location.type}${index}`}
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2"> className="mb-2 rounded-md border border-gray-300 py-1.5 px-2 hover:cursor-pointer">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<img <img
@@ -1,8 +1,11 @@
import { ArrowRightIcon } from "@heroicons/react/outline"; import { ArrowRightIcon } from "@heroicons/react/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
@@ -22,23 +25,30 @@ const UserSettings = (props: IUserSettingsProps) => {
const { t } = useLocale(); const { t } = useLocale();
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess()); const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const userSettingsSchema = z.object({
name: z
.string()
.min(1)
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
}),
});
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm({ } = useForm<z.infer<typeof userSettingsSchema>>({
defaultValues: { defaultValues: {
name: user?.name || "", name: user?.name || "",
}, },
reValidateMode: "onChange", reValidateMode: "onChange",
resolver: zodResolver(userSettingsSchema),
}); });
useEffect(() => { useEffect(() => {
telemetry.event(telemetryEventTypes.onboardingStarted); telemetry.event(telemetryEventTypes.onboardingStarted);
}, [telemetry]); }, [telemetry]);
const defaultOptions = { required: true, maxLength: 255 };
const utils = trpc.useContext(); const utils = trpc.useContext();
const onSuccess = async () => { const onSuccess = async () => {
await utils.viewer.me.invalidate(); await utils.viewer.me.invalidate();
@@ -67,7 +77,9 @@ const UserSettings = (props: IUserSettingsProps) => {
{t("full_name")} {t("full_name")}
</label> </label>
<input <input
{...register("name", defaultOptions)} {...register("name", {
required: true,
})}
id="name" id="name"
name="name" name="name"
type="text" type="text"
@@ -77,7 +89,7 @@ const UserSettings = (props: IUserSettingsProps) => {
/> />
{errors.name && ( {errors.name && (
<p data-testid="required" className="py-2 text-xs text-red-500"> <p data-testid="required" className="py-2 text-xs text-red-500">
{t("required")} {errors.name.message}
</p> </p>
)} )}
</div> </div>
+4 -15
View File
@@ -3,8 +3,7 @@ import { components } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations"; import type { EventLocationType } from "@calcom/app-store/locations";
import { classNames } from "@calcom/lib"; import { classNames } from "@calcom/lib";
import { Select } from "@calcom/ui";
import Select from "@components/ui/form/Select";
export type LocationOption = { export type LocationOption = {
label: string; label: string;
@@ -17,21 +16,11 @@ export type SingleValueLocationOption = SingleValue<LocationOption>;
export type GroupOptionType = GroupBase<LocationOption>; export type GroupOptionType = GroupBase<LocationOption>;
const OptionWithIcon = ({ const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
icon,
isSelected,
label,
}: {
icon?: string;
isSelected?: boolean;
label: string;
}) => {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5" />} {icon && <img src={icon} alt="cover" className="h-3.5 w-3.5" />}
<span className={classNames("text-sm font-medium", isSelected ? "text-white" : "text-gray-900")}> <span className={classNames("text-sm font-medium")}>{label}</span>
{label}
</span>
</div> </div>
); );
}; };
@@ -44,7 +33,7 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
components={{ components={{
Option: (props) => ( Option: (props) => (
<components.Option {...props}> <components.Option {...props}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} isSelected={props.isSelected} /> <OptionWithIcon icon={props.data.icon} label={props.data.label} />
</components.Option> </components.Option>
), ),
SingleValue: (props) => ( SingleValue: (props) => (
+5 -1
View File
@@ -112,7 +112,11 @@ export const getBookingWithResponses = <
return { return {
...booking, ...booking,
responses: getBookingResponsesPartialSchema({ responses: getBookingResponsesPartialSchema({
bookingFields: eventType.bookingFields, eventType: {
bookingFields: eventType.bookingFields,
},
// An existing booking can have data from any number of views, so the schema should consider ALL_VIEWS
view: "ALL_VIEWS",
}).parse(booking.responses || getResponsesFromOldBooking(booking)), }).parse(booking.responses || getResponsesFromOldBooking(booking)),
}; };
}; };
+9 -10
View File
@@ -3,23 +3,22 @@ import { RRule } from "rrule";
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { detectBrowserTimeFormat, TimeFormat } from "@calcom/lib/timeFormat"; import type { TimeFormat } from "@calcom/lib/timeFormat";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import type { RecurringEvent } from "@calcom/types/Calendar"; import type { RecurringEvent } from "@calcom/types/Calendar";
import { parseZone } from "./parseZone"; import { parseZone } from "./parseZone";
const processDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => { const processDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
const parsedZone = parseZone(date); const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date"; if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format( const formattedTime = parsedZone?.format(selectedTimeFormat || detectBrowserTimeFormat);
withDefaultTimeFormat ? TimeFormat.TWELVE_HOUR : detectBrowserTimeFormat
);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" }); return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
}; };
export const parseDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => { export const parseDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
if (!date) return ["No date"]; if (!date) return ["No date"];
return processDate(date, i18n, withDefaultTimeFormat); return processDate(date, i18n, selectedTimeFormat);
}; };
export const parseRecurringDates = ( export const parseRecurringDates = (
@@ -28,13 +27,13 @@ export const parseRecurringDates = (
timeZone, timeZone,
recurringEvent, recurringEvent,
recurringCount, recurringCount,
withDefaultTimeFormat, selectedTimeFormat,
}: { }: {
startDate: string | null | Dayjs; startDate: string | null | Dayjs;
timeZone?: string; timeZone?: string;
recurringEvent: RecurringEvent | null; recurringEvent: RecurringEvent | null;
recurringCount: number; recurringCount: number;
withDefaultTimeFormat: boolean; selectedTimeFormat?: TimeFormat;
}, },
i18n: I18n i18n: I18n
): [string[], Date[]] => { ): [string[], Date[]] => {
@@ -54,7 +53,7 @@ export const parseRecurringDates = (
}); });
const dateStrings = times.map((t) => { const dateStrings = times.map((t) => {
// finally; show in local timeZone again // finally; show in local timeZone again
return processDate(t.tz(timeZone), i18n, withDefaultTimeFormat); return processDate(t.tz(timeZone), i18n, selectedTimeFormat);
}); });
return [dateStrings, times.map((t) => t.toDate())]; return [dateStrings, times.map((t) => t.toDate())];
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "2.7.0", "version": "2.7.3",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -111,8 +111,8 @@
"react-multi-email": "^0.5.3", "react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.2.7", "react-phone-number-input": "^3.2.7",
"react-schemaorg": "^2.0.0", "react-schemaorg": "^2.0.0",
"react-select": "^5.4.0", "react-select": "^5.7.0",
"react-timezone-select": "^1.3.2", "react-timezone-select": "^1.4.0",
"react-use-intercom": "1.5.1", "react-use-intercom": "1.5.1",
"react-virtualized-auto-sizer": "^1.0.6", "react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.7", "react-window": "^1.8.7",
+18 -1
View File
@@ -1,14 +1,31 @@
import type { ResetPasswordRequest } from "@prisma/client"; import type { ResetPasswordRequest } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { sendPasswordResetEmail } from "@calcom/emails"; import { sendPasswordResetEmail } from "@calcom/emails";
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email"; import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
import rateLimit from "@calcom/lib/rateLimit";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
const limiter = rateLimit({
intervalInMs: 60 * 1000, // 1 minute
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common"); const t = await getTranslation(req.body.language ?? "en", "common");
const email = z
.string()
.email()
.transform((val) => val.toLowerCase())
.parse(req.body?.email);
const { isRateLimited } = limiter.check(10, email); // 10 requests per minute
if (isRateLimited) {
return res.status(429).json({ message: "Too Many Requests." });
}
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).end(); return res.status(405).end();
@@ -17,7 +34,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try { try {
const maybeUser = await prisma.user.findUnique({ const maybeUser = await prisma.user.findUnique({
where: { where: {
email: req.body?.email?.toLowerCase(), email,
}, },
select: { select: {
name: true, name: true,
+7 -5
View File
@@ -1,11 +1,10 @@
import { ImageResponse } from "@vercel/og"; import { ImageResponse } from "@vercel/og";
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
import { remark } from "remark";
import type { SatoriOptions } from "satori"; import type { SatoriOptions } from "satori";
import strip from "strip-markdown";
import { z } from "zod"; import { z } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages"; import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { md } from "@calcom/lib/markdownIt";
const calFont = fetch(new URL("../../../../public/fonts/cal.ttf", import.meta.url)).then((res) => const calFont = fetch(new URL("../../../../public/fonts/cal.ttf", import.meta.url)).then((res) =>
res.arrayBuffer() res.arrayBuffer()
@@ -75,10 +74,13 @@ export default async function handler(req: NextApiRequest) {
meetingImage: searchParams.get("meetingImage"), meetingImage: searchParams.get("meetingImage"),
imageType, imageType,
}); });
const title_ = md.render(title).replace(/(<([^>]+)>)/gi, "");
const img = new ImageResponse( const img = new ImageResponse(
( (
<Meeting <Meeting
title={title} title={title_}
profile={{ name: meetingProfileName, image: meetingImage }} profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))} users={names.map((name, index) => ({ name, username: usernames[index] }))}
/> />
@@ -108,8 +110,8 @@ export default async function handler(req: NextApiRequest) {
description: searchParams.get("description"), description: searchParams.get("description"),
imageType, imageType,
}); });
const description_ = await (await remark().use(strip).process(description)).toString();
const img = new ImageResponse(<Generic title={title} description={description_} />, ogConfig) as { const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig) as {
body: Buffer; body: Buffer;
}; };
+23 -4
View File
@@ -3,6 +3,7 @@ import matter from "gray-matter";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import type { GetStaticPaths, GetStaticPropsContext } from "next"; import type { GetStaticPaths, GetStaticPropsContext } from "next";
import path from "path"; import path from "path";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -13,6 +14,23 @@ import App from "@components/apps/App";
const md = new MarkdownIt("default", { html: true, breaks: true }); const md = new MarkdownIt("default", { html: true, breaks: true });
const sourceSchema = z.object({
content: z.string(),
data: z.object({
description: z.string().optional(),
items: z
.array(
z.union([
z.string(),
z.object({
iframe: z.object({ src: z.string() }),
}),
])
)
.optional(),
}),
});
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) { function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
return ( return (
<App <App
@@ -33,7 +51,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
email={data.email} email={data.email}
licenseRequired={data.licenseRequired} licenseRequired={data.licenseRequired}
isProOnly={data.isProOnly} isProOnly={data.isProOnly}
images={source.data?.items as string[] | undefined} descriptionItems={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate} isTemplate={data.isTemplate}
dependencies={data.dependencies} dependencies={data.dependencies}
// tos="https://zoom.us/terms" // tos="https://zoom.us/terms"
@@ -85,10 +103,11 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
source = singleApp.description; source = singleApp.description;
} }
const { content, data } = matter(source); const result = matter(source);
const { content, data } = sourceSchema.parse({ content: result.content, data: result.data });
if (data.items) { if (data.items) {
data.items = data.items.map((item: string) => { data.items = data.items.map((item) => {
if (!item.includes("/api/app-store")) { if (typeof item === "string" && !item.includes("/api/app-store")) {
// Make relative paths absolute // Make relative paths absolute
return `/api/app-store/${appDirname}/${item}`; return `/api/app-store/${appDirname}/${item}`;
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export default function Logout(props: Props) {
</div> </div>
</div> </div>
</div> </div>
<Button href="/auth/login" passHref className="flex w-full justify-center"> <Button href="/auth/login" className="flex w-full justify-center">
{t("go_back_login")} {t("go_back_login")}
</Button> </Button>
</AuthContainer> </AuthContainer>
+1 -1
View File
@@ -223,7 +223,7 @@ export default function Availability() {
</Button> </Button>
</div> </div>
}> }>
<div className="w-full"> <div className="mt-4 w-full md:mt-0">
<Form <Form
form={form} form={form}
id="availability-form" id="availability-form"
+7 -7
View File
@@ -272,10 +272,8 @@ export default function Success(props: SuccessProps) {
const isCancelled = const isCancelled =
status === "CANCELLED" || status === "CANCELLED" ||
status === "REJECTED" || status === "REJECTED" ||
(isCancellationMode && (!!seatReferenceUid &&
(!!seatReferenceUid !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid));
? !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid)
: !userIsOwner));
const telemetry = useTelemetry(); const telemetry = useTelemetry();
useEffect(() => { useEffect(() => {
@@ -433,7 +431,7 @@ export default function Success(props: SuccessProps) {
: "", : "",
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : "" isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
)}> )}>
{giphyImage && !needsConfirmation && ( {giphyImage && !needsConfirmation && !isCancelled && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} alt="Gif from Giphy" /> <img src={giphyImage} alt="Gif from Giphy" />
)} )}
@@ -531,7 +529,9 @@ export default function Success(props: SuccessProps) {
{bookingInfo?.attendees.map((attendee) => ( {bookingInfo?.attendees.map((attendee) => (
<div key={attendee.name} className="mb-3 last:mb-0"> <div key={attendee.name} className="mb-3 last:mb-0">
{attendee.name && <p>{attendee.name}</p>} {attendee.name && <p>{attendee.name}</p>}
<p className="text-bookinglight">{attendee.email}</p> <p data-testid={`attendee-${attendee.email}`} className="text-bookinglight">
{attendee.email}
</p>
</div> </div>
))} ))}
</div> </div>
@@ -606,7 +606,7 @@ export default function Success(props: SuccessProps) {
{!props.recurringBookings && ( {!props.recurringBookings && (
<span className="text-bookinglight inline text-gray-700"> <span className="text-bookinglight inline text-gray-700">
<span className="underline"> <span className="underline" data-testid="reschedule-link">
<Link <Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`} href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
legacyBehavior> legacyBehavior>
+1 -5
View File
@@ -351,11 +351,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
/> />
)} )}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse"> <div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{type.hidden && ( {type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Badge variant="gray" size="lg">
{t("hidden")}
</Badge>
)}
<Tooltip content={t("show_eventtype_on_profile")}> <Tooltip content={t("show_eventtype_on_profile")}>
<div className="self-center rounded-md p-2 hover:bg-gray-200"> <div className="self-center rounded-md p-2 hover:bg-gray-200">
<Switch <Switch
+3 -7
View File
@@ -1,7 +1,6 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { useIntercom } from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib"; import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -33,15 +32,13 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
const BillingView = () => { const BillingView = () => {
const { t } = useLocale(); const { t } = useLocale();
const [, loadChat] = useChat(); const { open } = useIntercom();
const [showChat, setShowChat] = useState(false);
const router = useRouter(); const router = useRouter();
const returnTo = router.asPath; const returnTo = router.asPath;
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`; const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
const onContactSupportClick = () => { const onContactSupportClick = () => {
setShowChat(true); open();
loadChat({ open: true });
}; };
return ( return (
@@ -61,7 +58,6 @@ const BillingView = () => {
{t("contact_support")} {t("contact_support")}
</Button> </Button>
</CtaRow> </CtaRow>
{showChat && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
</div> </div>
</> </>
); );
@@ -1,12 +1,15 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { IdentityProvider } from "@prisma/client"; import { IdentityProvider } from "@prisma/client";
import crypto from "crypto"; import crypto from "crypto";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import type { BaseSyntheticEvent } from "react"; import type { BaseSyntheticEvent } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt"; import { md } from "@calcom/lib/markdownIt";
@@ -315,6 +318,20 @@ const ProfileForm = ({
extraField?: React.ReactNode; extraField?: React.ReactNode;
}) => { }) => {
const { t } = useLocale(); const { t } = useLocale();
const profileFormSchema = z.object({
username: z.string(),
avatar: z.string(),
name: z
.string()
.min(1)
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
}),
email: z.string().email(),
bio: z.string(),
});
const emailMd5 = crypto const emailMd5 = crypto
.createHash("md5") .createHash("md5")
.update(defaultValues.email || "example@example.com") .update(defaultValues.email || "example@example.com")
@@ -322,6 +339,7 @@ const ProfileForm = ({
const formMethods = useForm<FormValues>({ const formMethods = useForm<FormValues>({
defaultValues, defaultValues,
resolver: zodResolver(profileFormSchema),
}); });
const { const {
+11 -9
View File
@@ -21,7 +21,8 @@ const PasswordView = () => {
const { t } = useLocale(); const { t } = useLocale();
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: user } = trpc.viewer.me.useQuery(); const { data: user } = trpc.viewer.me.useQuery();
const metadata = userMetadata.parse(user?.metadata); const metadata = userMetadata.safeParse(user?.metadata);
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
const sessionMutation = trpc.viewer.updateProfile.useMutation({ const sessionMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -36,10 +37,10 @@ const PasswordView = () => {
const previousValue = utils.viewer.me.getData(); const previousValue = utils.viewer.me.getData();
const previousMetadata = userMetadata.parse(previousValue?.metadata); const previousMetadata = userMetadata.parse(previousValue?.metadata);
if (previousValue && metadata?.sessionTimeout) { if (previousValue && sessionTimeout) {
utils.viewer.me.setData(undefined, { utils.viewer.me.setData(undefined, {
...previousValue, ...previousValue,
metadata: { ...previousMetadata, sessionTimeout: metadata?.sessionTimeout }, metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
}); });
} }
return { previousValue }; return { previousValue };
@@ -81,19 +82,19 @@ const PasswordView = () => {
defaultValues: { defaultValues: {
oldPassword: "", oldPassword: "",
newPassword: "", newPassword: "",
sessionTimeout: metadata?.sessionTimeout, sessionTimeout,
}, },
}); });
const sessionTimeoutWatch = formMethods.watch("sessionTimeout"); const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
const handleSubmit = (values: ChangePasswordSessionFormValues) => { const handleSubmit = (values: ChangePasswordSessionFormValues) => {
const { oldPassword, newPassword, sessionTimeout } = values; const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
if (oldPassword && newPassword) { if (oldPassword && newPassword) {
passwordMutation.mutate({ oldPassword, newPassword }); passwordMutation.mutate({ oldPassword, newPassword });
} }
if (metadata?.sessionTimeout !== sessionTimeout) { if (sessionTimeout !== newSessionTimeout) {
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout } }); sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
} }
}; };
@@ -106,6 +107,7 @@ const PasswordView = () => {
const passwordMinLength = data?.user.role === "USER" ? 7 : 15; const passwordMinLength = data?.user.role === "USER" ? 7 : 15;
const isUser = data?.user.role === "USER"; const isUser = data?.user.role === "USER";
return ( return (
<> <>
<Meta title={t("password")} description={t("password_description")} /> <Meta title={t("password")} description={t("password_description")} />
@@ -176,8 +178,8 @@ const PasswordView = () => {
<Select <Select
options={timeoutOptions} options={timeoutOptions}
defaultValue={ defaultValue={
metadata?.sessionTimeout sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === metadata.sessionTimeout) ? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1] : timeoutOptions[1]
} }
isSearchable={false} isSearchable={false}
+20 -12
View File
@@ -93,6 +93,7 @@ interface ProgressBarProps {
} }
function ProgressBar(props: ProgressBarProps) { function ProgressBar(props: ProgressBarProps) {
const { t } = useLocale();
const { startTime, endTime } = props; const { startTime, endTime } = props;
const currentTime = dayjs().second(0).millisecond(0); const currentTime = dayjs().second(0).millisecond(0);
const startingTime = dayjs(startTime).second(0).millisecond(0); const startingTime = dayjs(startTime).second(0).millisecond(0);
@@ -111,7 +112,9 @@ function ProgressBar(props: ProgressBarProps) {
const percentage = prev * (100 / startDuration); const percentage = prev * (100 / startDuration);
return ( return (
<div> <div>
<p>{duration} minutes</p> <p>
{duration} {t("minutes")}
</p>
<div className="relative h-2 max-w-xl overflow-hidden rounded-full"> <div className="relative h-2 max-w-xl overflow-hidden rounded-full">
<div className="absolute h-full w-full bg-gray-500/10" /> <div className="absolute h-full w-full bg-gray-500/10" />
<div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} /> <div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} />
@@ -127,6 +130,7 @@ interface VideoMeetingInfo {
export function VideoMeetingInfo(props: VideoMeetingInfo) { export function VideoMeetingInfo(props: VideoMeetingInfo) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { booking } = props; const { booking } = props;
const { t } = useLocale();
const endTime = new Date(booking.endTime); const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime); const startTime = new Date(booking.startTime);
@@ -139,25 +143,25 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
open ? "translate-x-0" : "-translate-x-[232px]" open ? "translate-x-0" : "-translate-x-[232px]"
)}> )}>
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden border-r border-gray-300/20 bg-black/80 p-4 text-white shadow-sm backdrop-blur-lg"> <main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden border-r border-gray-300/20 bg-black/80 p-4 text-white shadow-sm backdrop-blur-lg">
<h3>What:</h3> <h3>{t("what")}:</h3>
<p>{booking.title}</p> <p>{booking.title}</p>
<h3>Invitee Time Zone:</h3> <h3>{t("invitee_timezone")}:</h3>
<p>{booking.user?.timeZone}</p> <p>{booking.user?.timeZone}</p>
<h3>When:</h3> <h3>{t("when")}:</h3>
<p> <p>
{formatToLocalizedDate(startTime)} <br /> {formatToLocalizedDate(startTime)} <br />
{formatToLocalizedTime(startTime)} {formatToLocalizedTime(startTime)}
</p> </p>
<h3>Time left</h3> <h3>{t("time_left")}</h3>
<ProgressBar <ProgressBar
key={String(open)} key={String(open)}
endTime={endTime.toISOString()} endTime={endTime.toISOString()}
startTime={startTime.toISOString()} startTime={startTime.toISOString()}
/> />
<h3>Who:</h3> <h3>{t("who")}:</h3>
<p> <p>
{booking?.user?.name} - Organizer{" "} {booking?.user?.name} - {t("organizer")}:{" "}
<a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a> <a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a>
</p> </p>
@@ -169,12 +173,16 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
)) ))
: null} : null}
<h3>Description</h3> {booking.description && (
<>
<h3>{t("description")}:</h3>
<div <div
className="prose-sm prose prose-invert" className="prose-sm prose prose-invert"
dangerouslySetInnerHTML={{ __html: md.render(booking.description ?? "") }} dangerouslySetInnerHTML={{ __html: md.render(booking.description ?? "") }}
/> />
</>
)}
</main> </main>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<button <button
+22
View File
@@ -132,4 +132,26 @@ test.describe("pro user", () => {
// This is the only booking in there that needed confirmation and now it should be empty screen // This is the only booking in there that needed confirmation and now it should be empty screen
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible(); await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
}); });
test("can book with multiple guests", async ({ page, users }) => {
const additionalGuests = ["test@gmail.com", "test2@gmail.com"];
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "test1234");
await page.fill('[name="email"]', "test1234@example.com");
await page.locator('[data-testid="add-guests"]').click();
await page.locator('input[type="email"]').nth(1).fill(additionalGuests[0]);
await page.locator('[data-testid="add-another-guest"]').click();
await page.locator('input[type="email"]').nth(2).fill(additionalGuests[1]);
await page.locator('[data-testid="confirm-book-button"]').click();
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
additionalGuests.forEach(async (email) => {
await expect(page.locator(`[data-testid="attendee-${email}"]`)).toHaveText(email);
});
});
}); });
@@ -33,56 +33,108 @@ test.describe("Manage Booking Questions", () => {
await firstEventTypeElement.click(); await firstEventTypeElement.click();
}); });
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => { await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
await addQuestionAndSave({ });
page, });
question: {
name: "how_are_you",
type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await doOnFreshPreview(page, context, async (page) => { test.describe("For Team EventType", () => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page); test("Do a booking with a user added question and verify a few thing in b/w", async ({
const userFieldLocator = allFieldsLocator.nth(5); page,
users,
context,
}, testInfo) => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ users });
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible(); const webhookReceiver = await addWebhook(user);
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?"); await test.step("Go to First Team Event", async () => {
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible(); const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
}); const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
}); });
await test.step("Hide Question and see that it's not shown on Booking Page", async () => { await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
await toggleQuestionAndSave({ });
name: "how_are_you", });
page, });
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => { async function runTestStepsCommonForTeamAndUserEventType(
await toggleQuestionAndSave({ page: Page,
name: "how_are_you", context: PlaywrightTestArgs["context"],
page, webhookReceiver: {
}); port: number;
}); close: () => import("http").Server;
requestList: (import("http").IncomingMessage & { body?: unknown })[];
url: string;
}
) {
await page.click('[href$="tabName=advanced"]');
await test.step('Try to book without providing "How are you?" response', async () => { await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await doOnFreshPreview(page, context, async (page) => { await addQuestionAndSave({
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" }); page,
await expectErrorToBeThereFor({ page, name: "how_are_you" }); question: {
}); name: "how_are_you",
}); type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await test.step("Do a booking", async () => { await doOnFreshPreview(page, context, async (page) => {
await doOnFreshPreview(page, context, async (page) => { const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
});
});
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
});
await test.step("Make rescheduleReason required - It won't be required for a fresh booking", async () => {
await toggleQuestionRequireStatusAndSave({
required: true,
name: "rescheduleReason",
page,
});
});
const previewTabPage =
await test.step("Do a booking and notice that we can book without giving a value for rescheduleReason", async () => {
return await doOnFreshPreview(
page,
context,
async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]'); const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible(); await expect(formBuilderFieldLocator).toBeVisible();
expect( expect(
@@ -133,146 +185,16 @@ test.describe("Manage Booking Questions", () => {
value: "I am great!", value: "I am great!",
}, },
}); });
}); },
}); true
});
});
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,
context,
}, testInfo) => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ users });
const webhookReceiver = await addWebhook(user);
await test.step("Go to First Team Event", async () => {
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
});
});
async function runTestStepsCommonForTeamAndUserEventType(
page: Page,
context: PlaywrightTestArgs["context"],
webhookReceiver: {
port: number;
close: () => import("http").Server;
requestList: (import("http").IncomingMessage & { body?: unknown })[];
url: string;
}
) {
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await addQuestionAndSave({
page,
question: {
name: "how_are_you",
type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await doOnFreshPreview(page, context, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
});
});
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
});
await test.step("Do a booking", async () => {
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible();
expect(await formBuilderFieldLocator.locator('[name="how_are_you"]').getAttribute("placeholder")).toBe(
"I'm fine, thanks"
); );
expect(await formBuilderFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await formBuilderFieldLocator.locator('[name="how_are_you"]').fill("I am great!");
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
expect(
await page.locator('[data-testid="field-response"][data-fob-field="how_are_you"]').innerText()
).toBe("I am great!");
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const payload = (request.body as any).payload as any;
expect(payload.responses).toMatchObject({
email: {
label: "email_address",
value: "booker@example.com",
},
how_are_you: {
label: "How are you?",
value: "I am great!",
},
name: {
label: "your_name",
value: "Booker",
},
});
expect(payload.location).toBe("integrations:daily");
expect(payload.attendees[0]).toMatchObject({
name: "Booker",
email: "booker@example.com",
});
expect(payload.userFieldsResponses).toMatchObject({
how_are_you: {
label: "How are you?",
value: "I am great!",
},
});
}); });
await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => {
const page = previewTabPage;
await rescheduleFromTheLinkOnPage({ page });
await page.pause();
await expectErrorToBeThereFor({ page, name: "rescheduleReason" });
}); });
} }
@@ -333,7 +255,6 @@ async function addQuestionAndSave({
required?: boolean; required?: boolean;
}; };
}) { }) {
await page.click('[href$="tabName=advanced"]');
await page.click('[data-testid="add-field"]'); await page.click('[data-testid="add-field"]');
if (question.type !== undefined) { if (question.type !== undefined) {
@@ -379,11 +300,15 @@ async function expectErrorToBeThereFor({ page, name }: { page: Page; name: strin
async function doOnFreshPreview( async function doOnFreshPreview(
page: Page, page: Page,
context: PlaywrightTestArgs["context"], context: PlaywrightTestArgs["context"],
callback: (page: Page) => Promise<void> callback: (page: Page) => Promise<void>,
persistTab = false
) { ) {
const previewTabPage = await openBookingFormInPreviewTab(context, page); const previewTabPage = await openBookingFormInPreviewTab(context, page);
await callback(previewTabPage); await callback(previewTabPage);
await previewTabPage.close(); if (!persistTab) {
await previewTabPage.close();
}
return previewTabPage;
} }
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) { async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
@@ -391,6 +316,25 @@ async function toggleQuestionAndSave({ name, page }: { name: string; page: Page
await saveEventType(page); await saveEventType(page);
} }
async function toggleQuestionRequireStatusAndSave({
required,
name,
page,
}: {
required: boolean;
name: string;
page: Page;
}) {
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="edit-field-action"]').click();
await page
.locator('[data-testid="edit-field-dialog"]')
.locator('[data-testid="field-required"] button')
.locator(`text=${required ? "Yes" : "No"}`)
.click();
await page.locator('[data-testid="field-add-save"]').click();
await saveEventType(page);
}
async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<typeof createUsersFixture> }) { async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<typeof createUsersFixture> }) {
const user = await users.create(null, { const user = await users.create(null, {
hasTeam: true, hasTeam: true,
@@ -399,6 +343,16 @@ async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<t
return user; return user;
} }
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
await page.locator('[data-testid="reschedule-link"]').click();
await page.waitForLoadState();
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url: (url) => url.pathname.endsWith("/book"),
});
await page.click('[data-testid="confirm-reschedule-button"]');
}
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) { async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
const previewTabPromise = context.waitForEvent("page"); const previewTabPromise = context.waitForEvent("page");
await page.locator('[data-testid="preview-button"]').click(); await page.locator('[data-testid="preview-button"]').click();
@@ -90,6 +90,7 @@
"new_event_scheduled_recurring": "A new recurring event has been scheduled.", "new_event_scheduled_recurring": "A new recurring event has been scheduled.",
"invitee_email": "Invitee Email", "invitee_email": "Invitee Email",
"invitee_timezone": "Invitee Time Zone", "invitee_timezone": "Invitee Time Zone",
"time_left": "Time left",
"event_type": "Event Type", "event_type": "Event Type",
"enter_meeting": "Enter Meeting", "enter_meeting": "Enter Meeting",
"video_call_provider": "Video call provider", "video_call_provider": "Video call provider",
@@ -432,6 +433,7 @@
"password_hint_min": "Minimum 7 characters long", "password_hint_min": "Minimum 7 characters long",
"password_hint_admin_min": "Minimum 15 characters long", "password_hint_admin_min": "Minimum 15 characters long",
"password_hint_num": "Contain at least 1 number", "password_hint_num": "Contain at least 1 number",
"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", "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_password": "Password is incorrect.",
"incorrect_username_password": "Username or password is incorrect.", "incorrect_username_password": "Username or password is incorrect.",
+21
View File
@@ -112,4 +112,25 @@ describe("Tests the slot logic", () => {
}) })
).toHaveLength(11); ).toHaveLength(11);
}); });
it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour ", async () => {
// 72 20-minutes events in a 24h day
expect(
getSlots({
inviteeDate: dayjs.utc().add(1, "day"),
frequency: 20,
minimumBookingNotice: 0,
workingHours: [
{
userId: 1,
days: Array.from(Array(7).keys()),
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END - 14, // 23:45
},
],
eventLength: 20,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(71);
});
}); });
+1 -1
View File
@@ -93,7 +93,7 @@
"dependencies": { "dependencies": {
"city-timezones": "^1.2.1", "city-timezones": "^1.2.1",
"eslint": "^8.34.0", "eslint": "^8.34.0",
"turbo": "^1.4.3" "turbo": "^1.8.3"
}, },
"resolutions": { "resolutions": {
"@apidevtools/json-schema-ref-parser": "9.0.9", "@apidevtools/json-schema-ref-parser": "9.0.9",
@@ -50,7 +50,7 @@ export default function CloseComSetup() {
<h1 className="text-gray-600">{t("provide_api_key")}</h1> <h1 className="text-gray-600">{t("provide_api_key")}</h1>
<div className="mt-1 text-sm"> <div className="mt-1 text-sm">
{t("generate_api_key_description")}{" "} {t("generate_api_key_description", { appName: "Close.com" })}{" "}
<a <a
className="text-indigo-400" className="text-indigo-400"
href="https://app.close.com/settings/api/" href="https://app.close.com/settings/api/"
@@ -5,4 +5,6 @@ items:
- /api/app-store/dailyvideo/3.jpg - /api/app-store/dailyvideo/3.jpg
--- ---
- **Recordings require a team plan**
Cal Video is the in-house web-based video conferencing platform powered by Daily.co, which is minimalistic and lightweight, but has most of the features you need. Cal Video is the in-house web-based video conferencing platform powered by Daily.co, which is minimalistic and lightweight, but has most of the features you need.
@@ -74,6 +74,12 @@ export default class ExchangeCalendarService implements Calendar {
appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email));
} }
if (event.team?.members) {
event.team.members.forEach((member) => {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy); await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy);
return { return {
@@ -106,6 +112,11 @@ export default class ExchangeCalendarService implements Calendar {
for (let i = 0; i < event.attendees.length; i++) { for (let i = 0; i < event.attendees.length; i++) {
appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email));
} }
if (event.team?.members) {
event.team.members.forEach((member) => {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
appointment.Update( appointment.Update(
ConflictResolutionMode.AlwaysOverwrite, ConflictResolutionMode.AlwaysOverwrite,
SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy
@@ -75,6 +75,12 @@ export default class ExchangeCalendarService implements Calendar {
appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email));
} }
if (event.team?.members) {
event.team.members.forEach((member) => {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy); await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy);
return { return {
@@ -106,6 +112,11 @@ export default class ExchangeCalendarService implements Calendar {
for (let i = 0; i < event.attendees.length; i++) { for (let i = 0; i < event.attendees.length; i++) {
appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email));
} }
if (event.team?.members) {
event.team.members.forEach((member) => {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
appointment.Update( appointment.Update(
ConflictResolutionMode.AlwaysOverwrite, ConflictResolutionMode.AlwaysOverwrite,
SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy
@@ -64,6 +64,11 @@ export default class ExchangeCalendarService implements Calendar {
event.attendees.forEach((attendee: Person) => { event.attendees.forEach((attendee: Person) => {
appointment.RequiredAttendees.Add(new Attendee(attendee.email)); appointment.RequiredAttendees.Add(new Attendee(attendee.email));
}); });
if (event.team?.members) {
event.team.members.forEach((member: Person) => {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
return appointment return appointment
.Save(SendInvitationsMode.SendToAllAndSaveCopy) .Save(SendInvitationsMode.SendToAllAndSaveCopy)
.then(() => { .then(() => {
@@ -95,6 +100,11 @@ export default class ExchangeCalendarService implements Calendar {
event.attendees.forEach((attendee: Person) => { event.attendees.forEach((attendee: Person) => {
appointment.RequiredAttendees.Add(new Attendee(attendee.email)); appointment.RequiredAttendees.Add(new Attendee(attendee.email));
}); });
if (event.team?.members) {
event.team.members.forEach((member) => {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
return appointment return appointment
.Update( .Update(
ConflictResolutionMode.AlwaysOverwrite, ConflictResolutionMode.AlwaysOverwrite,
@@ -75,6 +75,13 @@ export default class GoogleCalendarService implements Calendar {
...rest, ...rest,
responseStatus: "accepted", responseStatus: "accepted",
})); }));
// TODO: Check every other CalendarService for team members
const teamMembers =
calEventRaw.team?.members.map((m) => ({
email: m.email,
displayName: m.name,
responseStatus: "accepted",
})) || [];
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken(); const myGoogleAuth = await this.auth.getToken();
const payload: calendar_v3.Schema$Event = { const payload: calendar_v3.Schema$Event = {
@@ -99,11 +106,12 @@ export default class GoogleCalendarService implements Calendar {
: calEventRaw.organizer.email, : calEventRaw.organizer.email,
}, },
...eventAttendees, ...eventAttendees,
...teamMembers,
], ],
reminders: { reminders: {
useDefault: true, useDefault: true,
}, },
guestsCanSeeOtherGuests: calEventRaw.seatsShowAttendees, guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true,
}; };
if (calEventRaw.location) { if (calEventRaw.location) {
@@ -169,6 +177,12 @@ export default class GoogleCalendarService implements Calendar {
...rest, ...rest,
responseStatus: "accepted", responseStatus: "accepted",
})); }));
const teamMembers =
event.team?.members.map((m) => ({
email: m.email,
displayName: m.name,
responseStatus: "accepted",
})) || [];
const payload: calendar_v3.Schema$Event = { const payload: calendar_v3.Schema$Event = {
summary: event.title, summary: event.title,
description: getRichDescription(event), description: getRichDescription(event),
@@ -189,11 +203,12 @@ export default class GoogleCalendarService implements Calendar {
}, },
// eslint-disable-next-line // eslint-disable-next-line
...eventAttendees, ...eventAttendees,
...teamMembers,
], ],
reminders: { reminders: {
useDefault: true, useDefault: true,
}, },
guestsCanSeeOtherGuests: event.seatsShowAttendees, guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true,
}; };
if (event.location) { if (event.location) {
@@ -377,16 +377,25 @@ export default class LarkCalendarService implements Calendar {
}; };
private translateAttendees = (event: CalendarEvent): LarkEventAttendee[] => { private translateAttendees = (event: CalendarEvent): LarkEventAttendee[] => {
const attendees: LarkEventAttendee[] = event.attendees const attendeeArray: LarkEventAttendee[] = [];
event.attendees
.filter((att) => att.email) .filter((att) => att.email)
.map((att) => { .forEach((att) => {
const attendee: LarkEventAttendee = { const attendee: LarkEventAttendee = {
type: "third_party", type: "third_party",
is_optional: false, is_optional: false,
third_party_email: att.email, third_party_email: att.email,
}; };
return attendee; attendeeArray.push(attendee);
}); });
return attendees; event.team?.members.forEach((member) => {
const attendee: LarkEventAttendee = {
type: "third_party",
is_optional: false,
third_party_email: member.email,
};
attendeeArray.push(attendee);
});
return attendeeArray;
}; };
} }
@@ -1,4 +1,5 @@
import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta"; import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta";
import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
@@ -38,6 +39,14 @@ interface IBatchResponse {
responses: ISettledResponse[]; responses: ISettledResponse[];
} }
const refreshTokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z
.number()
.transform((currentTimeOffsetInSeconds) => Math.round(+new Date() / 1000 + currentTimeOffsetInSeconds)),
refresh_token: z.string().optional(),
});
export default class Office365CalendarService implements Calendar { export default class Office365CalendarService implements Calendar {
private url = ""; private url = "";
private integrationName = ""; private integrationName = "";
@@ -201,7 +210,13 @@ export default class Office365CalendarService implements Calendar {
} }
private o365Auth = (credential: CredentialPayload) => { private o365Auth = (credential: CredentialPayload) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000); const isExpired = (expiryDate: number) => {
if (!expiryDate) {
return true;
} else {
return expiryDate < Math.round(+new Date() / 1000);
}
};
const o365AuthCredentials = credential.key as O365AuthCredentials; const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = async (refreshToken: string) => { const refreshAccessToken = async (refreshToken: string) => {
@@ -217,14 +232,7 @@ export default class Office365CalendarService implements Calendar {
client_secret, client_secret,
}), }),
}); });
const responseBody = await handleErrorsJson<{ const o365AuthCredentials = refreshTokenResponseSchema.parse(await handleErrorsJson(response));
access_token: string;
expires_in: number;
refresh_token: string;
}>(response);
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.refresh_token = responseBody.refresh_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
await prisma.credential.update({ await prisma.credential.update({
where: { where: {
id: credential.id, id: credential.id,
@@ -238,14 +246,13 @@ export default class Office365CalendarService implements Calendar {
return { return {
getToken: () => getToken: () =>
!isExpired(o365AuthCredentials.expiry_date) !isExpired(o365AuthCredentials.expires_in)
? Promise.resolve(o365AuthCredentials.access_token) ? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token), : refreshAccessToken(o365AuthCredentials.refresh_token),
}; };
}; };
private translateEvent = (event: CalendarEvent) => { private translateEvent = (event: CalendarEvent) => {
const utcOffset = dayjs(event.startTime, event.organizer.timeZone).utcOffset() / 60;
return { return {
subject: event.title, subject: event.title,
body: { body: {
@@ -253,20 +260,31 @@ export default class Office365CalendarService implements Calendar {
content: getRichDescription(event), content: getRichDescription(event),
}, },
start: { start: {
dateTime: dayjs(event.startTime).utcOffset(utcOffset).format(), dateTime: dayjs(event.startTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
end: { end: {
dateTime: dayjs(event.endTime).utcOffset(utcOffset).format(), dateTime: dayjs(event.endTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
attendees: event.attendees.map((attendee) => ({ attendees: [
emailAddress: { ...event.attendees.map((attendee) => ({
address: attendee.email, emailAddress: {
name: attendee.name, address: attendee.email,
}, name: attendee.name,
type: "required", },
})), type: "required",
})),
...(event.team?.members
? event.team?.members.map((member) => ({
emailAddress: {
address: member.email,
name: member.name,
},
type: "required",
}))
: []),
],
location: event.location ? { displayName: getLocation(event) } : undefined, location: event.location ? { displayName: getLocation(event) } : undefined,
}; };
}; };
@@ -1,5 +1,5 @@
export type O365AuthCredentials = { export type O365AuthCredentials = {
expiry_date: number;
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
expires_in: number;
}; };
@@ -45,7 +45,7 @@ export default function SendgridSetup() {
<h1 className="text-gray-600">{t("provide_api_key")}</h1> <h1 className="text-gray-600">{t("provide_api_key")}</h1>
<div className="mt-1 text-sm"> <div className="mt-1 text-sm">
{t("generate_api_key_description")}{" "} {t("generate_api_key_description", { appName: "Sendgrid" })}{" "}
<a <a
className="text-indigo-400" className="text-indigo-400"
href="https://app.sendgrid.com/settings/api_keys" href="https://app.sendgrid.com/settings/api_keys"
@@ -1,6 +1,7 @@
--- ---
description: Adds a link to copy Typeform Redirect URL to integrate with Routing Forms. description: Adds a link to copy Typeform Redirect URL to integrate with Routing Forms.
items: items:
- iframe: { src: https://www.youtube.com/embed/vcV-N2gLPZc }
- /api/app-store/typeform/copy-typeform-redirect-url.png - /api/app-store/typeform/copy-typeform-redirect-url.png
- /api/app-store/typeform/how-it-looks-in-typeform.png - /api/app-store/typeform/how-it-looks-in-typeform.png
--- ---
@@ -196,12 +196,11 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
}; };
const recurrence = getRecurrence(event); const recurrence = getRecurrence(event);
const utcOffset = dayjs(event.startTime, event.organizer.timeZone).utcOffset() / 60;
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return { return {
topic: event.title, topic: event.title,
type: 2, // Means that this is a scheduled meeting type: 2, // Means that this is a scheduled meeting
start_time: dayjs(event.startTime).utcOffset(utcOffset).format(), start_time: dayjs(event.startTime).utc().format(),
duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000, duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.organizer.timeZone, timezone: event.organizer.timeZone,
+3 -6
View File
@@ -439,12 +439,9 @@ export default class EventManager {
}); });
} }
if (newBooking) { calendarReference = newBooking?.references.length
calendarReference = newBooking.references.find((reference) => reference.type.includes("_calendar")); ? newBooking.references.find((reference) => reference.type.includes("_calendar"))
} else { : booking.references.find((reference) => reference.type.includes("_calendar"));
// Bookings should only have one calendar reference
calendarReference = booking.references.find((reference) => reference.type.includes("_calendar"));
}
if (!calendarReference) { if (!calendarReference) {
return []; return [];
@@ -60,7 +60,11 @@ export const BaseScheduledEmail = (
subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}> subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}>
{props.calEvent.cancellationReason && ( {props.calEvent.cancellationReason && (
<Info <Info
label={t("cancellation_reason")} label={t(
props.calEvent.cancellationReason.startsWith("$RCH$")
? "reschedule_reason"
: "cancellation_reason"
)}
description={ description={
!!props.calEvent.cancellationReason && props.calEvent.cancellationReason.replace("$RCH$", "") !!props.calEvent.cancellationReason && props.calEvent.cancellationReason.replace("$RCH$", "")
} // Removing flag to distinguish reschedule from cancellation } // Removing flag to distinguish reschedule from cancellation
+1 -1
View File
@@ -124,7 +124,7 @@ Make `dist/embed.umd.js` servable on URL <http://cal.com/embed.js>
- Why do we NOT want to provide completely flexible CSS customization by adding whatever CSS user wants. ? - Why do we NOT want to provide completely flexible CSS customization by adding whatever CSS user wants. ?
- Feature Documentation - Feature Documentation
- Inline mode doesn't cause any scroll in iframe by default. It looks like it is part of the website. - Inline mode doesn't cause any scroll in iframe by default. It looks like it is part of the website.
- docs.cal.com - cal.com/docs
- A complete document on how to use embed - A complete document on how to use embed
@@ -84,7 +84,7 @@ async function selectFirstAvailableTimeSlotNextMonth(frame: Frame, page: Page) {
// Waiting for full month increment // Waiting for full month increment
await frame.waitForTimeout(1000); await frame.waitForTimeout(1000);
expect(await page.screenshot()).toMatchSnapshot("availability-page-2.png"); // expect(await page.screenshot()).toMatchSnapshot("availability-page-2.png");
// TODO: Find out why the first day is always booked on tests // TODO: Find out why the first day is always booked on tests
await frame.locator('[data-testid="day"][data-disabled="false"]').nth(1).click(); await frame.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
await frame.click('[data-testid="time"]'); await frame.click('[data-testid="time"]');
@@ -104,7 +104,7 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
// This doesn't seem to be replicable with the speed of a person, only during automation. // 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. // It would also allow correct snapshot to be taken for current month.
await frame.waitForTimeout(1000); await frame.waitForTimeout(1000);
expect(await page.screenshot()).toMatchSnapshot("availability-page-1.png"); // expect(await page.screenshot()).toMatchSnapshot("availability-page-1.png");
const eventSlug = new URL(frame.url()).pathname; const eventSlug = new URL(frame.url()).pathname;
await selectFirstAvailableTimeSlotNextMonth(frame, page); await selectFirstAvailableTimeSlotNextMonth(frame, page);
await frame.waitForNavigation({ await frame.waitForNavigation({
@@ -112,7 +112,7 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
return url.pathname.includes(`/${username}/book`); return url.pathname.includes(`/${username}/book`);
}, },
}); });
expect(await page.screenshot()).toMatchSnapshot("booking-page.png"); // expect(await page.screenshot()).toMatchSnapshot("booking-page.png");
// --- fill form // --- fill form
await frame.fill('[name="name"]', "Embed User"); await frame.fill('[name="name"]', "Embed User");
await frame.fill('[name="email"]', "embed-user@example.com"); await frame.fill('[name="email"]', "embed-user@example.com");
@@ -123,7 +123,7 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
// Make sure we're navigated to the success page // Make sure we're navigated to the success page
await expect(frame.locator("[data-testid=success-page]")).toBeVisible(); await expect(frame.locator("[data-testid=success-page]")).toBeVisible();
expect(await page.screenshot()).toMatchSnapshot("success-page.png"); // expect(await page.screenshot()).toMatchSnapshot("success-page.png");
//NOTE: frame.click('body') won't work here. Because the way it works, it clicks on the center of the body tag which is an element inside the popup view and that won't close the popup //NOTE: frame.click('body') won't work here. Because the way it works, it clicks on the center of the body tag which is an element inside the popup view and that won't close the popup
await frame.evaluate(() => { await frame.evaluate(() => {
@@ -68,7 +68,7 @@ test.describe("Popup Tests", () => {
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, { await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/free", pathname: "/free",
}); });
expect(await page.screenshot()).toMatchSnapshot("event-types-list.png"); // expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
if (!embedIframe) { if (!embedIframe) {
throw new Error("Embed iframe not found"); throw new Error("Embed iframe not found");
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

@@ -19,7 +19,7 @@ test("Inline Iframe - Configured with Dark Theme", async ({
theme: "dark", theme: "dark",
}, },
}); });
expect(await page.screenshot()).toMatchSnapshot("event-types-list.png"); // expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
if (!embedIframe) { if (!embedIframe) {
throw new Error("Embed iframe not found"); throw new Error("Embed iframe not found");
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

@@ -14,5 +14,5 @@ test("Inline Usage Snapshot", async ({ page, getActionFiredDetails, addEmbedList
theme: "dark", theme: "dark",
}, },
}); });
expect(await page.screenshot()).toMatchSnapshot("react-component-inline.png"); // expect(await page.screenshot()).toMatchSnapshot("react-component-inline.png");
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

+47 -22
View File
@@ -21,6 +21,7 @@ import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/fea
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto"; import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies"; import { defaultCookies } from "@calcom/lib/default-cookies";
import { isENVDev } from "@calcom/lib/env";
import { randomString } from "@calcom/lib/random"; import { randomString } from "@calcom/lib/random";
import rateLimit from "@calcom/lib/rateLimit"; import rateLimit from "@calcom/lib/rateLimit";
import { serverConfig } from "@calcom/lib/serverConfig"; import { serverConfig } from "@calcom/lib/serverConfig";
@@ -165,28 +166,25 @@ const providers: Provider[] = [
}).length > 0; }).length > 0;
// authentication success- but does it meet the minimum password requirements? // authentication success- but does it meet the minimum password requirements?
if ( const validateRole = (role: UserPermissionRole) => {
user.role === "ADMIN" && // User's role is not "ADMIN"
((user.identityProvider === IdentityProvider.CAL && if (role !== "ADMIN") return role;
!isPasswordValid(credentials.password, false, true)) || // User's identity provider is not "CAL"
!user.twoFactorEnabled) if (user.identityProvider !== IdentityProvider.CAL) return role;
) { // User's password is valid and two-factor authentication is enabled
return { if (isPasswordValid(credentials.password, false, true) && user.twoFactorEnabled) return role;
id: user.id, // Code is running in a development environment
username: user.username, if (isENVDev) return role;
email: user.email, // By this point it is an ADMIN without valid security conditions
name: user.name, return "INACTIVE_ADMIN";
role: "INACTIVE_ADMIN", };
belongsToActiveTeam: hasActiveTeams,
};
}
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: validateRole(user.role),
belongsToActiveTeam: hasActiveTeams, belongsToActiveTeam: hasActiveTeams,
}; };
}, },
@@ -444,7 +442,7 @@ export const AUTH_OPTIONS: AuthOptions = {
identityProvider: idP, identityProvider: idP,
}, },
{ {
identityProviderId: account.providerAccountId as string, identityProviderId: account.providerAccountId,
}, },
], ],
}, },
@@ -523,11 +521,11 @@ export const AUTH_OPTIONS: AuthOptions = {
return "/auth/error?error=unverified-email"; return "/auth/error?error=unverified-email";
} }
const existingUser = await prisma.user.findFirst({ let existingUser = await prisma.user.findFirst({
include: { include: {
accounts: { accounts: {
where: { where: {
provider: idP, provider: account.provider,
}, },
}, },
}, },
@@ -537,6 +535,33 @@ export const AUTH_OPTIONS: AuthOptions = {
}, },
}); });
/* --- START FIX LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (!existingUser) {
existingUser = await prisma.user.findFirst({
include: {
accounts: {
where: {
provider: account.provider,
},
},
},
where: {
identityProvider: idP,
identityProviderId: String(user.id),
},
});
if (existingUser) {
await prisma.user.update({
where: {
id: existingUser?.id,
},
data: {
identityProviderId: account.providerAccountId,
},
});
}
}
/* --- END FIXES LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (existingUser) { if (existingUser) {
// In this case there's an existing user and their email address // In this case there's an existing user and their email address
// hasn't changed since they last logged in. // hasn't changed since they last logged in.
@@ -617,7 +642,7 @@ export const AUTH_OPTIONS: AuthOptions = {
emailVerified: new Date(Date.now()), emailVerified: new Date(Date.now()),
name: user.name, name: user.name,
identityProvider: idP, identityProvider: idP,
identityProviderId: String(user.id), identityProviderId: account.providerAccountId,
}, },
}); });
@@ -640,7 +665,7 @@ export const AUTH_OPTIONS: AuthOptions = {
password: null, password: null,
email: user.email, email: user.email,
identityProvider: idP, identityProvider: idP,
identityProviderId: String(user.id), identityProviderId: account.providerAccountId,
}, },
}); });
if (existingUserWithEmail.twoFactorEnabled) { if (existingUserWithEmail.twoFactorEnabled) {
@@ -664,7 +689,7 @@ export const AUTH_OPTIONS: AuthOptions = {
name: user.name, name: user.name,
email: user.email, email: user.email,
identityProvider: idP, identityProvider: idP,
identityProviderId: String(user.id), identityProviderId: account.providerAccountId,
}, },
}); });
@@ -11,6 +11,13 @@ import {
export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber"; export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber";
/**
* PHONE -> Phone
*/
function upperCaseToCamelCase(upperCaseString: string) {
return upperCaseString[0].toUpperCase() + upperCaseString.slice(1).toLowerCase();
}
export const getSmsReminderNumberField = () => export const getSmsReminderNumberField = () =>
({ ({
name: SMS_REMINDER_NUMBER_FIELD, name: SMS_REMINDER_NUMBER_FIELD,
@@ -55,16 +62,6 @@ export const SystemField = z.enum([
"smsReminderNumber", "smsReminderNumber",
]); ]);
export const SystemFieldsEditability: Record<z.infer<typeof SystemField>, Fields[number]["editable"]> = {
name: "system",
email: "system",
location: "system",
notes: "system-but-optional",
guests: "system-but-optional",
rescheduleReason: "system",
smsReminderNumber: "system",
};
/** /**
* This fn is the key to ensure on the fly mapping of customInputs to bookingFields and ensuring that all the systems fields are present and correctly ordered in bookingFields * This fn is the key to ensure on the fly mapping of customInputs to bookingFields and ensuring that all the systems fields are present and correctly ordered in bookingFields
*/ */
@@ -165,6 +162,7 @@ export const ensureBookingInputsHaveSystemFields = ({
defaultLabel: "your_name", defaultLabel: "your_name",
type: "name", type: "name",
name: "name", name: "name",
editable: "system",
required: true, required: true,
sources: [ sources: [
{ {
@@ -179,6 +177,7 @@ export const ensureBookingInputsHaveSystemFields = ({
type: "email", type: "email",
name: "email", name: "email",
required: true, required: true,
editable: "system",
sources: [ sources: [
{ {
label: "Default", label: "Default",
@@ -191,6 +190,7 @@ export const ensureBookingInputsHaveSystemFields = ({
defaultLabel: "location", defaultLabel: "location",
type: "radioInput", type: "radioInput",
name: "location", name: "location",
editable: "system",
required: false, required: false,
// Populated on the fly from locations. I don't want to duplicate storing locations and instead would like to be able to refer to locations in eventType. // Populated on the fly from locations. I don't want to duplicate storing locations and instead would like to be able to refer to locations in eventType.
// options: `eventType.locations` // options: `eventType.locations`
@@ -222,6 +222,7 @@ export const ensureBookingInputsHaveSystemFields = ({
defaultLabel: "additional_notes", defaultLabel: "additional_notes",
type: "textarea", type: "textarea",
name: "notes", name: "notes",
editable: "system-but-optional",
required: additionalNotesRequired, required: additionalNotesRequired,
defaultPlaceholder: "share_additional_notes", defaultPlaceholder: "share_additional_notes",
sources: [ sources: [
@@ -235,6 +236,7 @@ export const ensureBookingInputsHaveSystemFields = ({
{ {
defaultLabel: "additional_guests", defaultLabel: "additional_guests",
type: "multiemail", type: "multiemail",
editable: "system-but-optional",
name: "guests", name: "guests",
required: false, required: false,
hidden: disableGuests, hidden: disableGuests,
@@ -249,9 +251,16 @@ export const ensureBookingInputsHaveSystemFields = ({
{ {
defaultLabel: "reschedule_reason", defaultLabel: "reschedule_reason",
type: "textarea", type: "textarea",
editable: "system-but-optional",
name: "rescheduleReason", name: "rescheduleReason",
defaultPlaceholder: "reschedule_placeholder", defaultPlaceholder: "reschedule_placeholder",
required: false, required: false,
views: [
{
id: "reschedule",
label: "Reschedule View",
},
],
sources: [ sources: [
{ {
label: "Default", label: "Default",
@@ -264,9 +273,16 @@ export const ensureBookingInputsHaveSystemFields = ({
const missingSystemBeforeFields = []; const missingSystemBeforeFields = [];
for (const field of systemBeforeFields) { for (const field of systemBeforeFields) {
const existingBookingFieldIndex = bookingFields.findIndex((f) => f.name === field.name);
// Only do a push, we must not update existing system fields as user could have modified any property in it, // Only do a push, we must not update existing system fields as user could have modified any property in it,
if (!bookingFields.find((f) => f.name === field.name)) { if (existingBookingFieldIndex === -1) {
missingSystemBeforeFields.push(field); missingSystemBeforeFields.push(field);
} else {
// Adding the fields from Code first and then fields from DB. Allows, the code to push new properties to the field
bookingFields[existingBookingFieldIndex] = {
...field,
...bookingFields[existingBookingFieldIndex],
};
} }
} }
@@ -286,12 +302,14 @@ export const ensureBookingInputsHaveSystemFields = ({
// Backward Compatibility: If we are migrating from old system, we need to map `customInputs` to `bookingFields` // Backward Compatibility: If we are migrating from old system, we need to map `customInputs` to `bookingFields`
if (handleMigration) { if (handleMigration) {
customInputs.forEach((input) => { customInputs.forEach((input, index) => {
const label = input.label || `${upperCaseToCamelCase(input.type)}`;
bookingFields.push({ bookingFields.push({
label: input.label, label: label,
editable: "user", editable: "user",
// Custom Input's slugified label was being used as query param for prefilling. So, make that the name of the field // Custom Input's slugified label was being used as query param for prefilling. So, make that the name of the field
name: slugify(input.label), // Also Custom Input's label could have been empty string as well. But it's not possible to have empty name. So generate a name automatically.
name: slugify(input.label || `${input.type}-${index + 1}`),
placeholder: input.placeholder, placeholder: input.placeholder,
type: CustomInputTypeToFieldType[input.type], type: CustomInputTypeToFieldType[input.type],
required: input.required, required: input.required,
@@ -310,25 +328,20 @@ export const ensureBookingInputsHaveSystemFields = ({
const missingSystemAfterFields = []; const missingSystemAfterFields = [];
for (const field of systemAfterFields) { for (const field of systemAfterFields) {
const existingBookingFieldIndex = bookingFields.findIndex((f) => f.name === field.name);
// Only do a push, we must not update existing system fields as user could have modified any property in it, // Only do a push, we must not update existing system fields as user could have modified any property in it,
if (!bookingFields.find((f) => f.name === field.name)) { if (existingBookingFieldIndex === -1) {
missingSystemAfterFields.push(field); missingSystemAfterFields.push(field);
} else {
bookingFields[existingBookingFieldIndex] = {
// Adding the fields from Code first and then fields from DB. Allows, the code to push new properties to the field
...field,
...bookingFields[existingBookingFieldIndex],
};
} }
} }
bookingFields = bookingFields.concat(missingSystemAfterFields); bookingFields = bookingFields.concat(missingSystemAfterFields);
bookingFields = bookingFields.map((field) => {
const foundEditableMap = SystemFieldsEditability[field.name as keyof typeof SystemFieldsEditability];
if (!foundEditableMap) {
return field;
}
// Ensure that system fields editability, even if modified to something else in DB(accidentally), get's reset to what's in the code.
return {
...field,
editable: foundEditableMap,
};
});
return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(bookingFields); return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(bookingFields);
}; };
@@ -1,22 +1,32 @@
import { isValidPhoneNumber } from "libphonenumber-js"; import { isValidPhoneNumber } from "libphonenumber-js";
import z from "zod"; import z from "zod";
import type { ALL_VIEWS } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses } from "@calcom/prisma/zod-utils"; import { bookingResponses } from "@calcom/prisma/zod-utils";
type EventType = Parameters<typeof preprocess>[0]["eventType"]; type EventType = Parameters<typeof preprocess>[0]["eventType"];
export const getBookingResponsesPartialSchema = (eventType: EventType) => { // eslint-disable-next-line @typescript-eslint/ban-types
type View = ALL_VIEWS | (string & {});
export const getBookingResponsesPartialSchema = ({
eventType,
view,
}: {
eventType: EventType;
view: View;
}) => {
const schema = bookingResponses.unwrap().partial().and(z.record(z.any())); const schema = bookingResponses.unwrap().partial().and(z.record(z.any()));
return preprocess({ schema, eventType, isPartialSchema: true }); return preprocess({ schema, eventType, isPartialSchema: true, view });
}; };
// Should be used when we know that not all fields responses are present // Should be used when we know that not all fields responses are present
// - Can happen when we are parsing the prefill query string // - Can happen when we are parsing the prefill query string
// - Can happen when we are parsing a booking's responses (which was created before we added a new required field) // - Can happen when we are parsing a booking's responses (which was created before we added a new required field)
export default function getBookingResponsesSchema(eventType: EventType) { export default function getBookingResponsesSchema({ eventType, view }: { eventType: EventType; view: View }) {
const schema = bookingResponses.and(z.record(z.any())); const schema = bookingResponses.and(z.record(z.any()));
return preprocess({ schema, eventType, isPartialSchema: false }); return preprocess({ schema, eventType, isPartialSchema: false, view });
} }
// TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder // TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder
@@ -25,12 +35,14 @@ function preprocess<T extends z.ZodType>({
schema, schema,
eventType, eventType,
isPartialSchema, isPartialSchema,
view: currentView,
}: { }: {
schema: T; schema: T;
isPartialSchema: boolean; isPartialSchema: boolean;
eventType: { eventType: {
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">; bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
}; };
view: View;
}): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> { }): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> {
const preprocessed = z.preprocess( const preprocessed = z.preprocess(
(responses) => { (responses) => {
@@ -42,8 +54,15 @@ function preprocess<T extends z.ZodType>({
// If there is no response for the field, then we don't need to do any processing // If there is no response for the field, then we don't need to do any processing
return; return;
} }
// Turn a boolean in string to a real boolean const views = field.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
if (!isFieldApplicableToCurrentView) {
// If the field is not applicable in the current view, then we don't need to do any processing
return;
}
if (field.type === "boolean") { if (field.type === "boolean") {
// Turn a boolean in string to a real boolean
newResponses[field.name] = value === "true" || value === true; newResponses[field.name] = value === "true" || value === true;
} }
// Make sure that the value is an array // Make sure that the value is an array
@@ -76,8 +95,16 @@ function preprocess<T extends z.ZodType>({
: z.string().refine((val) => isValidPhoneNumber(val)); : z.string().refine((val) => isValidPhoneNumber(val));
// Tag the message with the input name so that the message can be shown at appropriate place // Tag the message with the input name so that the message can be shown at appropriate place
const m = (message: string) => `{${bookingField.name}}${message}`; const m = (message: string) => `{${bookingField.name}}${message}`;
const views = bookingField.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
// If the field is hidden, then it can never be required // If the field is hidden, then it can never be required
const isRequired = bookingField.hidden ? false : bookingField.required; const isRequired = bookingField.hidden
? false
: isFieldApplicableToCurrentView
? bookingField.required
: false;
if ((isPartialSchema || !isRequired) && value === undefined) { if ((isPartialSchema || !isRequired) && value === undefined) {
return; return;
} }
@@ -1,4 +1,4 @@
import type { WebhookTriggerEvents, WorkflowReminder } from "@prisma/client"; import type { WebhookTriggerEvents, WorkflowReminder, Prisma } from "@prisma/client";
import { BookingStatus, MembershipRole, WorkflowMethods } from "@prisma/client"; import { BookingStatus, MembershipRole, WorkflowMethods } from "@prisma/client";
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
@@ -266,10 +266,13 @@ async function handler(req: CustomRequest) {
await Promise.all(integrationsToDelete).then(async () => { await Promise.all(integrationsToDelete).then(async () => {
if (lastAttendee) { if (lastAttendee) {
await prisma.booking.delete({ await prisma.booking.update({
where: { where: {
id: bookingToDelete.id, id: bookingToDelete.id,
}, },
data: {
status: BookingStatus.CANCELLED,
},
}); });
} }
}); });
@@ -388,11 +391,11 @@ async function handler(req: CustomRequest) {
}, },
}); });
} }
const where: Prisma.BookingWhereUniqueInput = uid ? { uid } : { id };
const updatedBooking = await prisma.booking.update({ const updatedBooking = await prisma.booking.update({
where: { where,
id,
uid,
},
data: { data: {
status: BookingStatus.CANCELLED, status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason, cancellationReason: cancellationReason,
@@ -38,7 +38,6 @@ import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/re
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getVideoCallUrl } from "@calcom/lib/CalEventParser"; import { getVideoCallUrl } from "@calcom/lib/CalEventParser";
import { getDSTDifference, isInDST } from "@calcom/lib/date-fns";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import getPaymentAppData from "@calcom/lib/getPaymentAppData";
@@ -125,16 +124,16 @@ const isWithinAvailableHours = (
) => { ) => {
const timeSlotStart = dayjs(timeSlot.start).utc(); const timeSlotStart = dayjs(timeSlot.start).utc();
const timeSlotEnd = dayjs(timeSlot.end).utc(); const timeSlotEnd = dayjs(timeSlot.end).utc();
const isOrganizerInDSTWhenSlotStart = isInDST(timeSlotStart.tz(organizerTimeZone)); const organizerDSTDiff =
const organizerDSTDifference = getDSTDifference(organizerTimeZone); dayjs().tz(organizerTimeZone).utcOffset() - timeSlotStart.tz(organizerTimeZone).utcOffset();
const getTime = (slotTime: Dayjs, minutes: number) =>
slotTime.startOf("day").add(minutes + organizerDSTDiff, "minutes");
const getTime = (slotTime: Dayjs, minutes: number) => {
const minutesDTS = isOrganizerInDSTWhenSlotStart ? minutes - organizerDSTDifference : minutes;
return slotTime.startOf("day").add(minutesDTS, "minutes");
};
for (const workingHour of workingHours) { for (const workingHour of workingHours) {
const startTime = getTime(timeSlotStart, workingHour.startTime); const startTime = getTime(timeSlotStart, workingHour.startTime);
const endTime = getTime(timeSlotEnd, workingHour.endTime); // workingHours function logic set 1439 minutes when user select the end of the day (11:59) in his schedule
// so, we need to add a minute, to avoid, "No available user" error when the last available slot is selected.
const endTime = getTime(timeSlotEnd, workingHour.endTime === 1439 ? 1440 : workingHour.endTime);
if ( if (
workingHour.days.includes(timeSlotStart.day()) && workingHour.days.includes(timeSlotStart.day()) &&
// UTC mode, should be performant. // UTC mode, should be performant.
@@ -377,7 +376,10 @@ function getBookingData({
? extendedBookingCreateBody.merge( ? extendedBookingCreateBody.merge(
z.object({ z.object({
responses: getBookingResponsesSchema({ responses: getBookingResponsesSchema({
bookingFields: eventType.bookingFields, eventType: {
bookingFields: eventType.bookingFields,
},
view: req.body.rescheduleUid ? "reschedule" : "booking",
}), }),
}) })
) )
@@ -610,6 +612,20 @@ async function handler(
: user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN, : user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN,
})); }));
let locationBodyString = location;
let defaultLocationUrl = undefined;
if (dynamicUserList.length > 1) {
users = users.sort((a, b) => {
const aIndex = (a.username && dynamicUserList.indexOf(a.username)) || 0;
const bIndex = (b.username && dynamicUserList.indexOf(b.username)) || 0;
return aIndex - bIndex;
});
const firstUsersMetadata = userMetadataSchema.parse(users[0].metadata);
const app = getAppFromSlug(firstUsersMetadata?.defaultConferencingApp?.appSlug);
locationBodyString = app?.appData?.location?.type || locationBodyString;
defaultLocationUrl = firstUsersMetadata?.defaultConferencingApp?.appLink;
}
if (eventType && eventType.hasOwnProperty("bookingLimits") && eventType?.bookingLimits) { if (eventType && eventType.hasOwnProperty("bookingLimits") && eventType?.bookingLimits) {
const startAsDate = dayjs(reqBody.start).toDate(); const startAsDate = dayjs(reqBody.start).toDate();
await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id); await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id);
@@ -689,38 +705,21 @@ async function handler(
const guests = (reqGuests || []).reduce((guestArray, guest) => { const guests = (reqGuests || []).reduce((guestArray, guest) => {
// If it's a team event, remove the team member from guests // If it's a team event, remove the team member from guests
if (isTeamEventType) { if (isTeamEventType && users.some((user) => user.email === guest)) {
if (users.some((user) => user.email === guest)) { return guestArray;
return guestArray;
} else {
guestArray.push({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
});
}
} }
guestArray.push({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
});
return guestArray; return guestArray;
}, [] as typeof invitee); }, [] as typeof invitee);
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`; const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
let locationBodyString = location;
let defaultLocationUrl = undefined;
if (dynamicUserList.length > 1) {
users = users.sort((a, b) => {
const aIndex = (a.username && dynamicUserList.indexOf(a.username)) || 0;
const bIndex = (b.username && dynamicUserList.indexOf(b.username)) || 0;
return aIndex - bIndex;
});
const firstUsersMetadata = userMetadataSchema.parse(users[0].metadata);
const app = getAppFromSlug(firstUsersMetadata?.defaultConferencingApp?.appSlug);
locationBodyString = app?.appData?.location?.type || locationBodyString;
defaultLocationUrl = firstUsersMetadata?.defaultConferencingApp?.appLink;
}
const bookingLocation = getLocationValueForDB(locationBodyString, eventType.locations); const bookingLocation = getLocationValueForDB(locationBodyString, eventType.locations);
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs); const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
const teamMemberPromises = const teamMemberPromises =
@@ -1486,6 +1485,9 @@ async function handler(
newBookingData.recurringEventId = reqBody.recurringEventId; newBookingData.recurringEventId = reqBody.recurringEventId;
} }
if (originalRescheduledBooking) { if (originalRescheduledBooking) {
newBookingData.metadata = {
...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata),
};
newBookingData["paid"] = originalRescheduledBooking.paid; newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid; newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
if (newBookingData.attendees?.createMany?.data) { if (newBookingData.attendees?.createMany?.data) {
@@ -1541,7 +1543,7 @@ async function handler(
return prisma.booking.create(createBookingObj); return prisma.booking.create(createBookingObj);
} }
let results: EventResult<AdditionalInformation>[] = []; let results: EventResult<AdditionalInformation & { url?: string }>[] = [];
let referencesToCreate: PartialReference[] = []; let referencesToCreate: PartialReference[] = [];
type Booking = Prisma.PromiseReturnType<typeof createBooking>; type Booking = Prisma.PromiseReturnType<typeof createBooking>;
@@ -1653,7 +1655,7 @@ async function handler(
// Use EventManager to conditionally use all needed integrations. // Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.reschedule(evt, originalRescheduledBooking.uid, booking?.id); const updateManager = await eventManager.reschedule(evt, originalRescheduledBooking.uid);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails. // to the default description when we are sending the emails.
evt.description = eventType.description; evt.description = eventType.description;
@@ -1680,7 +1682,7 @@ async function handler(
metadata.conferenceData = updatedEvent.conferenceData; metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints; metadata.entryPoints = updatedEvent.entryPoints;
handleAppsStatus(results, booking); handleAppsStatus(results, booking);
videoCallUrl = metadata.hangoutLink || videoCallUrl; videoCallUrl = metadata.hangoutLink || videoCallUrl || updatedEvent?.url;
} }
} }
if (noEmail !== true) { if (noEmail !== true) {
@@ -1951,7 +1953,7 @@ async function handler(
await scheduleWorkflowReminders( await scheduleWorkflowReminders(
eventType.workflows, eventType.workflows,
smsReminderNumber || null, smsReminderNumber || null,
{ ...evt, ...{ metadata } }, { ...evt, responses, ...{ metadata } },
evt.requiresConfirmation || false, evt.requiresConfirmation || false,
rescheduleUid ? true : false, rescheduleUid ? true : false,
true true
@@ -139,16 +139,6 @@ const DestinationCalendarSelector = ({
styles={{ styles={{
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }), placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }), singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
option: (defaultStyles, state) => ({
...defaultStyles,
backgroundColor: state.isSelected
? state.isFocused
? "var(--brand-color)"
: "var(--brand-color)"
: state.isFocused
? "var(--brand-color-dark-mode)"
: "var(--brand-text-color)",
}),
control: (defaultStyles) => { control: (defaultStyles) => {
return { return {
...defaultStyles, ...defaultStyles,
@@ -1,4 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -13,10 +14,6 @@ const SAMLSSO = () => {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
if (!HOSTED_CAL_FEATURES) {
router.push("/404");
}
const teamId = Number(router.query.id); const teamId = Number(router.query.id);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery( const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
@@ -28,6 +25,12 @@ const SAMLSSO = () => {
} }
); );
useEffect(() => {
if (!HOSTED_CAL_FEATURES) {
router.push("/404");
}
}, []);
if (isLoading) { if (isLoading) {
return <SkeletonLoader />; return <SkeletonLoader />;
} }
@@ -1,4 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -11,9 +12,11 @@ const SAMLSSO = () => {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
if (HOSTED_CAL_FEATURES) { useEffect(() => {
router.push("/404"); if (HOSTED_CAL_FEATURES) {
} router.push("/404");
}
}, []);
return ( return (
<div className="w-full bg-white sm:mx-0 xl:mt-0"> <div className="w-full bg-white sm:mx-0 xl:mt-0">
@@ -9,6 +9,7 @@ import { FiExternalLink, FiAlertTriangle } from "@calcom/ui/components/icon";
import { useFreshChat } from "../lib/freshchat/FreshChatProvider"; import { useFreshChat } from "../lib/freshchat/FreshChatProvider";
import { isFreshChatEnabled } from "../lib/freshchat/FreshChatScript"; import { isFreshChatEnabled } from "../lib/freshchat/FreshChatScript";
import { isInterComEnabled, useIntercom } from "../lib/intercom/useIntercom";
import ContactMenuItem from "./ContactMenuItem"; import ContactMenuItem from "./ContactMenuItem";
interface HelpMenuItemProps { interface HelpMenuItemProps {
@@ -17,6 +18,7 @@ interface HelpMenuItemProps {
export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) { export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
const [rating, setRating] = useState<null | string>(null); const [rating, setRating] = useState<null | string>(null);
const { open } = useIntercom();
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [disableSubmit, setDisableSubmit] = useState(true); const [disableSubmit, setDisableSubmit] = useState(true);
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
@@ -47,24 +49,12 @@ export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
<div className="w-full py-5"> <div className="w-full py-5">
<p className="mb-1 px-5 text-gray-500">{t("resources").toUpperCase()}</p> <p className="mb-1 px-5 text-gray-500">{t("resources").toUpperCase()}</p>
<a <a
href="https://docs.cal.com/" onClick={() => onHelpItemSelect()}
href="https://cal.com/docs/"
target="_blank" target="_blank"
className="flex w-full px-5 py-2 pr-4 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900" className="flex w-full px-5 py-2 pr-4 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900"
rel="noreferrer"> rel="noreferrer">
{t("support_documentation")} {t("documentation")}
<FiExternalLink
className={classNames(
"text-gray-400 group-hover:text-gray-500",
"ml-1 mt-px h-4 w-4 flex-shrink-0 ltr:mr-3"
)}
/>
</a>
<a
href="https://developer.cal.com/"
target="_blank"
className="flex w-full px-5 py-2 pr-4 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900"
rel="noreferrer">
{t("developer_documentation")}
<FiExternalLink <FiExternalLink
className={classNames( className={classNames(
"text-gray-400 group-hover:text-gray-500", "text-gray-400 group-hover:text-gray-500",
@@ -77,7 +67,7 @@ export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
</div> </div>
</div> </div>
<hr className=" bg-gray-200" /> <hr className="bg-gray-200 " />
<div className="w-full p-5"> <div className="w-full p-5">
<p className="mb-1 text-gray-500">{t("feedback").toUpperCase()}</p> <p className="mb-1 text-gray-500">{t("feedback").toUpperCase()}</p>
<p className="flex w-full py-2 text-sm font-medium text-gray-700">{t("comments")}</p> <p className="flex w-full py-2 text-sm font-medium text-gray-700">{t("comments")}</p>
@@ -86,7 +76,7 @@ export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
name="comment" name="comment"
rows={3} rows={3}
onChange={(event) => setComment(event.target.value)} onChange={(event) => setComment(event.target.value)}
className="my-1 block w-full rounded-sm border-gray-300 py-2 pb-2 text-sm" className="my-1 block w-full rounded-sm border-gray-300 py-2 pb-2 text-sm"
/> />
<div className="my-3 flex justify-end"> <div className="my-3 flex justify-end">
@@ -201,14 +191,17 @@ export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
<div className="w-full bg-neutral-50 p-5 text-gray-500"> <div className="w-full bg-neutral-50 p-5 text-gray-500">
<p className="">{t("specific_issue")}</p> <p className="">{t("specific_issue")}</p>
<button <button
className="font-medium underline hover:text-gray-700" className="font-medium underline hover:text-gray-700"
onClick={() => { onClick={() => {
setActive(true); setActive(true);
if (isFreshChatEnabled) { if (isFreshChatEnabled) {
setFreshChat(true); setFreshChat(true);
} else if (isInterComEnabled) {
open();
} else { } else {
loadChat({ open: true }); loadChat({ open: true });
} }
onHelpItemSelect(); onHelpItemSelect();
}}> }}>
{t("contact_support")} {t("contact_support")}
@@ -216,8 +209,8 @@ export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
<span> {t("or").toLowerCase()} </span> <span> {t("or").toLowerCase()} </span>
<a <a
onClick={() => onHelpItemSelect()} onClick={() => onHelpItemSelect()}
className="font-medium underline hover:text-gray-700" className="font-medium underline hover:text-gray-700"
href="https://docs.cal.com/" href="https://cal.com/docs/"
target="_blank" target="_blank"
rel="noreferrer"> rel="noreferrer">
{t("browse_our_docs")} {t("browse_our_docs")}
@@ -1,7 +1,9 @@
import Script from "next/script"; import Script from "next/script";
import { z } from "zod";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
const nonEmptySchema = z.string().min(1);
declare global { declare global {
interface Window { interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,7 +16,8 @@ const host = process.env.NEXT_PUBLIC_FRESHCHAT_HOST;
// eslint-disable-next-line turbo/no-undeclared-env-vars // eslint-disable-next-line turbo/no-undeclared-env-vars
const token = process.env.NEXT_PUBLIC_FRESHCHAT_TOKEN; const token = process.env.NEXT_PUBLIC_FRESHCHAT_TOKEN;
export const isFreshChatEnabled = host !== "undefined" && token !== "undefined"; export const isFreshChatEnabled =
nonEmptySchema.safeParse(host).success && nonEmptySchema.safeParse(token).success;
export default function FreshChatScript() { export default function FreshChatScript() {
const { data } = trpc.viewer.me.useQuery(); const { data } = trpc.viewer.me.useQuery();
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more