Compare commits

..
52 Commits
Author SHA1 Message Date
Omar López d8176b2eb4 v2.7.6
release.yml / release (push) Canceled after 0s
2023-03-28 16:28:17 -07:00
6c51e2a970 insights follow up (#7922)
* init page

* init insights frontend

* nit

* nit

* nit

* merge

* fixed icons

* i18n, needs features

* Init insights trpc

* Using trpc on client

* Added events timeline

* Seed analytics script

* connect ui with trpc

* Added and fixed event time lines

* WIP popular days and avg time duration event type

* added new metric graphs

* improved upgrade tip

* always show upgrade screen

* upgrade tremor.so and select inputs for page

* Remove log

* Move everything to components and add context

* Fix select types using calcom ui one

* Adding translations

* Add missing translations

* Add more translations

* min fix

* Fixes for date select

* Prefer early return and mobile design fixes

* Fix style for mobile

* Fix data with userId filter from popular events

* add user id to average time duration

* fix types for select-react

* Removed submodules

* Delete website

* Update yarn.lock

* Code organization and type fixes

* trpc fixes

* Builds are now passing

* Relocates server code

* Add url state in insights

* Update FiltersProvider.tsx

* Cleanup

* Update embed-iframe.ts

* Update FilterType.tsx

* Update seed-app-store.config.json

* Update index.tsx

* Renamed seeder

* Update FiltersProvider.tsx

* Fix for query params

* no wrap on lg screen

* Fix shadow borders from tremor components, fix title font

* Add ring-gray to match filters

* add cursor pointer

* copy improvements

* blue to black

* fixed date select focus

* Adds missing translation strings

* Fix url state for filter type

* Apply suggestions from code review

* Updated yarn lock

* Adds feature flag

* Type fix

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-03-28 16:24:57 -07:00
zomars 3a08eb3dca Adds pagination 2023-03-28 15:11:27 -07:00
cee905bc5a Improves reported type errors on CI (#7813)
* Create env file workflow

* Add env-cache

* Fix env setter

* Fix

* Another fix

* Fix

* Fix

* Fixes

* FFS

* Fix

* Fix

* Fix

* Fix

* Fix

* Cache fixes

* Fixes

* Adds skipping steps

* db-cache fixes

* Test

* Cache fixes

* e2e

* Possible caching conflicts

* Running out of ideas

* Caching is hard

* One more time

* cache-build not skipping

* Fingers crossed

* a

* Test

* Pls

* Please

* LFG

* Build fix

* fix

* Whitespace!!

* Zomars/cal 884 paid events not sending the link (#7318)

* WIP

* Sends correct emails for paid bookings

* Update PaymentService.ts

* Update webhook.ts

* Update webhook.ts

* Update settings back button redirect link (#7403)

* fix(schedule): close on click #7143

* fix(EventSetupTab): validLocations length will always match validLocations length #7138

* fix(SettingsLayout): go back to right route #7379

* feat: get country code from ip geolocation (#6880)

* feat: get coutnry code from ip geolocation

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

* fix: create new api route for fetching code

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

* chore: replace city with country

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

* refactor: create hook for country

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

---------

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

* Team Workflows (#7038)

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* Add destination calendar name to DestinationCalendarSelector (#6701)

* Add destination calendar name

* Type fix

* Search through calendars only for destination calendar credential

* Refactor get connected calendars

* Clean up

---------

Co-authored-by: zomars <zomars@me.com>

* Update viewer.tsx (#7428)

* Fix - add team members to emails (#7207)

* On booking add team members & translation

* Add team members to round robin create

* Only update calendars on reschedule if there is a calendar reference

* Send email on reschedules

* Send team email on cancelled event

* Add team members to calendar event description

* Clean up

* Convert other emails to organizer & teams

* Type check fixes

* More type fixes

* Change organizer scheduled input to an object

* early return updateCalendarEvent

* Introduce team member type

* Fix type errors

* Put team members before attendees

* Remove lodash cloneDeep

* Update packages/core/EventManager.ts

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

* Remove booking select object

* Revert "Remove booking select object"

This reverts commit 9f121ff4ebbaec9a0643111f96c5337b930ddd5b.

* Refactor email manager (#7270)

Co-authored-by: zomars <zomars@me.com>

* Type change

* Remove conditional check for updateAllCalendarEvents

---------

Co-authored-by: zomars <zomars@me.com>

* Typefix

* Updates webhook response

* Update pr.yml

* Update action.yml

* Update action.yml

* Update action.yml

* Update action.yml

* Update action.yml

* Is this redundant?

* Removed setup

* Update action.yml

* Update action.yml

* Consolitades setup step

* Revert "Consolitades setup step"

This reverts commit 5e8d1983cc5ac309fa6875a2fee77aa6287b7151.

* Fix?

* One more time

* Revert "One more time"

This reverts commit fd8b559a135820567f40dbb47a4b4dfc2ef44755.

* Benchmarking buildjet

* Update action.yml

* Re-introduce setup

* Adds embeds to missing pro cache

* Lint fixes

* Adds prettier ignore

* Upgrades to yarn 3

* Updates lockfile

* Reverts CI to ubuntu

* Testing new yarn install

* We cannot use immutable due to our private submodules

* Adds CI skip

* Fixes

* Adds plugin

* Forces local embed package

* Moves eslint to root

* Update yarn.lock

* Playwright fixes

* Embed test fixes

* Splits embed react tests

* Splits embed react tests

* Removes install step to benchmark

* Update playwright.config.ts

* One playwright config for all

* More test fixes

* Update basic.e2e.ts

* Added typescript as a global monorepo dev

* Update to v18

* Update yarn.lock

* Update env-create-file.yml

* Update .github/workflows/pr.yml

* Adds type check special for CI

* Update turbo.json

* Update turbo.json

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Esaú Morais <55207584+esau-morais@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-03-28 13:12:40 -07:00
72fd6334c6 Add the new Team->useHostDefault option (#7811)
* Add the new Team->useHostDefault option

* Added user.metadata to defaultEvents

* Type fixes

* Another type fix

* Implement feedback @jaibles

* Default -> Conferencing rename

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-03-28 20:03:54 +00:00
Omar López 63f51abd84 Adds cron endpoint to keep team subscriptions in sync (#7992)
* Fix missing edge case where team is paid but needs to be updated

* Reuses cron endpoint to update team subscriptions

* Update cron-downgradeUsers.yml

* Update payments.ts

* Update cron-downgradeUsers.yml

* update to 90 writes per second

Stripe allow up to 100 per second
2023-03-28 12:38:01 -07:00
Omar López 6e856b3bd1 Fix missing edge case where team is paid but needs to be updated (#7988) 2023-03-28 18:45:13 +00:00
Hariom Balhara 5fceee27fe Embed: Strictly type the codebase and fixes a few bugs found (#7536)
* Add strict types

* Make it array

* Make api consistent and support calOrigin in floatingPopup and modal

* Support calOrigin everywhere and fix branding config issue

* Fix styles not being uniformly applied across components

* Fix unused code
2023-03-28 11:17:40 -07:00
Alex van Andel 4f590ebbf7 Remove 'avatar' (which is unused) from eventType.hosts query (#7986) 2023-03-28 18:44:30 +02:00
Hariom Balhara 736cd63509 Fix: AppStore CLI Create command not working after upgrading to yarn 3 (#7975)
* Remove README.md that is now replaced by DESCRIPTION/md

* Fix AppStore Cli create command failure
2023-03-28 10:25:51 +00:00
3aaa1cde0d Fixes formatted description in email + sanitize html everywhere (#7928)
* use sanitize-html instead

* add list formatting

* add lost changes from merging main

* fixes caused by merge

* remove dompurify

* Revert "remove dompurify"

This reverts commit 583bb623c7d3c0c7daa28e63ab2b1ac2da68c0d8.

* sanitize already done in editor

* Update yarn.lock

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-28 09:40:13 +00:00
Keith Williams 02d32321aa Updated the PR template to not use checkboxes for 'Type of change' (#7960)
* Updated the PR template to not use checkboxes for 'Type of change'

* Changed wording of options to bullets
2023-03-28 10:43:27 +02:00
GitStart-Cal.comandgitstart-calcom 697f6db2c4 CALCOM-7962 - [CAL-1350] create "collective or round robin" doenst open dialog anymore (#7969)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-03-28 10:36:19 +02:00
Alex van Andel 83364a9e97 Update yarn.lock 2023-03-28 10:30:57 +02:00
Julius Marminge 1620ae660f fix(embeds): add support for RSC by adding use client directives (#7919) 2023-03-28 11:30:02 +05:30
Alex van Andel 605f9c61f3 bookingSeat could absolutely be unset at this point, resulting in Internal Error. (#7964)
release.yml / release (push) Canceled after 0s
2023-03-27 18:16:07 -07:00
Omar López 564862369f v2.7.5 2023-03-27 17:30:40 -07:00
Alex van Andel be6cf4705c An empty array is truthy, [0] potentially isn't (#7961) 2023-03-28 00:16:52 +02:00
Joe Au-YeungandPeer Richelsen a2457ab619 Fix/availability not loading (#7789)
* Set default schedule if there is not one

* Removing last schedule sets user.defaultScheduleId to null

* Create new schedule from event type

* First schedule created is the default

* Go back if new schedule was created

* Moved update logic out of getter

* Remove flashing of empty screen

* Add border prop to EmptyScreen

* User requires a schedule

* Change back schema

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-27 19:53:35 +00:00
Alex van Andel 867966dd75 Fixup whereby config.json, invalid JSON 2023-03-27 21:12:57 +02:00
Peer RichelsenandAlex van Andel 08ad4c07ea added better regex for whereby allowing subdomains (#7957)
* added better regex for whereby allowing subdomains

* Update packages/app-store/whereby/config.json

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

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-27 21:01:34 +02:00
Joe 78da57055e fix: updating password ui bug #7775 (#7815)
* fix: updating password ui bug #7775

* fix: type error
2023-03-27 20:58:46 +02:00
Udit TakkarandPeer Richelsen d5c3d9d967 fix: create team form (#7896)
* fix: create team form

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

* fix: change hover border

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>
2023-03-27 16:35:46 +00:00
aa0e9e6a02 Prevent GCal from sending update (#7920)
* Do not send updates

* Clean up

* Clean up

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

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

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-27 14:22:27 +00:00
Alex van Andel cad97f1779 Remove 3rd param from getBooking 2023-03-27 13:32:35 +02:00
GitStart-Cal.comandgitstart-calcom 31ee689672 CALCOM-7848 - Confirm button also in the branding color (#7932)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-03-27 12:31:17 +02:00
3edb6f5ffd Delete rescheduled booking (#7910)
* deleted orignal booking and fix open seats number on booking page

* fix type error

* Adjust tests

* fix type error

* include fix in e2e test

* fix e2e test

* fixes e2e test

* fix type error in e2e test

* increase timeout time

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2023-03-27 11:34:41 +02:00
Hariom Balhara a8825badec Fix: Send responses in confirm booking flow (#7830) 2023-03-27 13:57:10 +05:30
sean-brydonandEfraín Rochín 04c634ec4b Fix timezone select - create two variants (#7875)
* Fix timezone select - create two variants

* Remove menu is open

* Timezone fixes

---------

Co-authored-by: Efraín Rochín <roae.85@gmail.com>
2023-03-26 21:19:13 +02:00
Joe Au-Yeung 8dd9b8d74b Fix Zod errors in Outlook auth function (#7931)
* Add quick fixes

* Add error message

* Wooops
2023-03-25 21:19:33 +00:00
Efraín Rochín 0365157638 Revalidate booking pages on updateProfile (#7941) 2023-03-25 22:06:43 +01:00
zomars a571d40958 Update yarn.lock 2023-03-24 18:00:50 -07:00
69491d5700 Adds basic global feature flags (#7459)
* Adds basic feature flag model

* Create router.ts

* WIP

* WIP

* WIP

* WIP

* Emails kill switch

* Adds missing migrations

* Type fix

* Cleanup

* Revert

* Cleanup

* Fixes migration

* Update packages/features/flags/server/utils.ts

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

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-03-24 17:59:04 -07:00
Joe Au-Yeung 20022e2cdd Fix minimum booking notice set to 0 (#7750)
* Only use the current day

* Set min of booking notice to 0

* Set min minimumBookingNotice to 0
2023-03-24 17:37:46 -07:00
Syed Ali Shahbaz 492858b45b Fix for Apple Calendar failing entire availability check because of Shared Calendars (#7934) 2023-03-24 23:55:30 +01:00
Bailey Pumfleet b49ad8294b new yarn.lock 2023-03-24 16:43:30 +00:00
Peer Richelsen aa30ff7d1a insights improvements 2023-03-24 00:17:12 +01:00
cfa720a523 /insights (#6511)
* init page

* init insights frontend

* nit

* nit

* nit

* merge

* fixed icons

* i18n, needs features

* Init insights trpc

* Using trpc on client

* Added events timeline

* Seed analytics script

* connect ui with trpc

* Added and fixed event time lines

* WIP popular days and avg time duration event type

* added new metric graphs

* improved upgrade tip

* always show upgrade screen

* upgrade tremor.so and select inputs for page

* Remove log

* Move everything to components and add context

* Fix select types using calcom ui one

* Adding translations

* Add missing translations

* Add more translations

* min fix

* Fixes for date select

* Prefer early return and mobile design fixes

* Fix style for mobile

* Fix data with userId filter from popular events

* add user id to average time duration

* Removed submodules

* Delete website

* Update yarn.lock

* Code organization and type fixes

* trpc fixes

* Builds are now passing

* Relocates server code

* Cleanup

* Medium size screen fixes

* Added TODO

---------

Co-authored-by: Alan <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-03-23 22:10:01 +00:00
Omar López 9ab196c30e Revert "Fixes formatted description in email " (#7917) 2023-03-23 21:20:35 +00:00
Peer Richelsen 47e13cd2fd Update package.json
release.yml / release (push) Canceled after 0s
2023-03-23 21:18:33 +01:00
Hariom Balhara 74ab6d209e Fix/correctly handle Pending Status and fix conditional require confirmation (#7911)
* Use pending status

* Use requiresConfirmation variable instead of eventType.requiresConfirmation as the earlier handles requiresConfirmation threshold as well
2023-03-23 19:50:39 +01:00
Alex van Andel 46f0b55a45 Techdebt/bundle size experiments (#7894)
* Checking package size effects when we remove zod from middleware

* Remove 'crypto' from client code

* Revert "Checking package size effects when we remove zod from middleware"

This reverts commit ecc2038411c73fc562d57712d5c83d66925e7e13.

* Adding lodash to dynamicImports + consistent imports

* Remove the locales from the global Dayjs object
2023-03-23 11:49:28 -07:00
Alex van Andel e4893c2218 Removes the getUser logic from createContext, instead use isAuthed (#7902)
* Removes the getUser logic from createContext, instead use isAuthed

* Fix types :party
2023-03-23 19:46:33 +01:00
Udit Takkar fa17139e8f fix: add belongsToActiveTeam (#7890)
* fix: add belongsToActiveTeam

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

* fix: create a common function

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

* fix: add suggestion

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

* chore: use deconstructing

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

* chore

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

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-03-23 19:23:06 +01:00
Efraín Rochín 1cdc97dd37 Add detailed error logs to handleNewBooking (#7907)
* Add logs to get more context on "No user available" error

* remove nit
2023-03-23 19:03:49 +01:00
Hariom Balhara f630e8af40 Add videocallData (#7899) 2023-03-23 15:18:27 +00:00
Peer Richelsen fe0745121b minor fix to adding to badge 2023-03-23 13:07:11 +01:00
f6d7568d0a Fixes formatted description in email (#7696)
* fix description in email

* add styling for lists

* sanitize editor input before saving

* sanitize eventTypeDescription

* santize html when used dangerouslySetInnerHTML

* fix server side sanitation

* add missing formatting and sanitation

* move @ts-expect-error to correct line

* fix type error and add yarn.lock

* fix build error

* sanitize description for booking page and availability page

* rename to markdownAndSanitize

* always add list formatting

* handle empty description in markdownAndSanitize for cleaner code

* create function for clientside markdown rendering and sanitizing

* fix type error

* code clean up

* Now that eventType.descriptionAsSafeHTML is added at all the missing places, we can do away with ts-ignore and get type safety

* Remove unused variable

* Remove one more ts-expect-error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-23 12:00:42 +00:00
Nafees Nazik 520e7fe036 fix: remove "Nameless" from title in booking cancel page (#7888) 2023-03-23 10:22:30 +00:00
Nafees Nazik 1bf50e09cd fix: meeting not getting deleted (#7878) 2023-03-22 11:18:00 -07:00
darshanandBailey Pumfleet d1ca7b4bdd fix redirect for recurring events (#7821)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-03-22 11:22:40 -04:00
Richard Poelderl 4e2473531f fix: set canonical to https://cal.com on .dev as well (#7883) 2023-03-22 15:43:59 +01:00
198 changed files with 5284 additions and 1681 deletions
+6 -6
View File
@@ -12,13 +12,13 @@ Fixes # (issue)
## Type of change
<!-- Please delete options that are not relevant. -->
<!-- Please delete bullets that are not relevant. -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Chore (refactoring code, technical debt, workflow improvements)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- Bug fix (non-breaking change which fixes an issue)
- Chore (refactoring code, technical debt, workflow improvements)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
- This change requires a documentation update
## How should this be tested?
+18
View File
@@ -0,0 +1,18 @@
{
"problemMatcher": [
{
"owner": "tsc-absolute",
"pattern": [
{
"regexp": "(?:^|\\s)([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"code": 5,
"message": 6
}
]
}
]
}
+5 -3
View File
@@ -10,8 +10,10 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- name: Show info
run: node -e "console.log(require('v8').getHeapStatistics())"
- run: yarn type-check
- name: Configure TSC problem matcher
run: |
echo "::remove-matcher owner=tsc::"
echo "::add-matcher::.github/matchers/tsc-absolute.json"
- run: yarn type-check:ci
+3 -2
View File
@@ -1,11 +1,12 @@
name: Cron - downgradeUsers
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
- cron: "0,15,30,45 * * * *"
# Runs “Every month at 1st (see https://crontab.guru)
- cron: "0 0 1 * *"
jobs:
cron-downgradeUsers:
env:
+4 -5
View File
@@ -350,8 +350,7 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
<source media="(prefers-color-scheme: dark)" srcset="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=dark">
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
</picture>
</a>
</a>
<!-- CONTRIBUTORS -->
@@ -456,12 +455,12 @@ following
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
2. From within the API console page, go to "Applications".
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
4. Fill in any information you want in the "Client Details" tab
5. Go to tab "Client Secret" tab.
5. Go to tab "Client Secret" tab.
6. Now copy the Client ID and Client Secret to your .env file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
9. Click the "Save"/ "UPDATE" button at the bottom footer.
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
+2 -1
View File
@@ -11,7 +11,8 @@
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
"start": "PORT=4000 next start",
"type-check": "tsc --pretty --noEmit"
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit"
},
"author": "Cal.com, Inc.",
"license": "MIT",
+4 -4
View File
@@ -167,7 +167,7 @@ const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
const height = getDimension(previewState.inline.height);
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
useEffect(()=>{
(async function () {
@@ -187,12 +187,12 @@ function MyComponent() {
}) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
Cal("floatingButton", ${floatingButtonArg});
cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode}
})();
}, [])
@@ -201,7 +201,7 @@ function MyComponent() {
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
useEffect(()=>{
(async function () {
+1 -1
View File
@@ -1,5 +1,5 @@
import { AdminRequired } from "components/ui/AdminRequired";
import noop from "lodash/noop";
import { noop } from "lodash";
import type { LinkProps } from "next/link";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -8,54 +8,61 @@ import { FiLink } from "@calcom/ui/components/icon";
import type { Props } from "./pages/AvailabilityPage";
const excludeNullValues = (value: unknown) => !!value;
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
const { t } = useLocale();
return locations.length ? (
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
{locations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
// It's possible that the location app got uninstalled
return null;
}
const renderLocations = locations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
// It's possible that the location app got uninstalled
return null;
}
if (eventLocationType.variable === "hostDefault") {
return null;
}
const translateAbleKeys = [
"attendee_in_person",
"in_person",
"attendee_phone_number",
"link_meeting",
"organizer_phone_number",
];
const translateAbleKeys = [
"attendee_in_person",
"in_person",
"attendee_phone_number",
"link_meeting",
"organizer_phone_number",
];
const locationKey = z.string().default("").parse(locationKeyToString(location));
const locationKey = z.string().default("").parse(locationKeyToString(location));
const translatedLocation = location.type.startsWith("integrations:")
? eventLocationType.label
: translateAbleKeys.includes(locationKey)
? t(locationKey)
: locationKey;
const translatedLocation = location.type.startsWith("integrations:")
? eventLocationType.label
: translateAbleKeys.includes(locationKey)
? t(locationKey)
: locationKey;
return (
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
{eventLocationType.iconUrl === "/link.svg" ? (
<FiLink className="dark:text-darkgray-600 min-h-4 min-w-4 ml-[2px] opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
) : (
<img
src={eventLocationType.iconUrl}
className={classNames(
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
)}
alt={`${eventLocationType.label} icon`}
/>
return (
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
{eventLocationType.iconUrl === "/link.svg" ? (
<FiLink className="dark:text-darkgray-600 min-h-4 min-w-4 ml-[2px] opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
) : (
<img
src={eventLocationType.iconUrl}
className={classNames(
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
)}
<Tooltip content={translatedLocation}>
<p className="line-clamp-1">{translatedLocation}</p>
</Tooltip>
</div>
);
})}
alt={`${eventLocationType.label} icon`}
/>
)}
<Tooltip content={translatedLocation}>
<p className="line-clamp-1">{translatedLocation}</p>
</Tooltip>
</div>
);
});
const filteredLocations = renderLocations.filter(excludeNullValues) as JSX.Element[];
return filteredLocations.length ? (
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
{filteredLocations}
</div>
) : null;
}
@@ -5,6 +5,8 @@ import { useState } from "react";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns";
@@ -77,7 +77,7 @@ export default function CancelBooking(props: Props) {
{t("nevermind")}
</Button>
<Button
className="flex justify-center"
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast flex justify-center"
data-testid="cancel"
onClick={async () => {
setLoading(true);
+1 -4
View File
@@ -133,10 +133,7 @@ export const SlotPicker = ({
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
usernameList: users,
startTime:
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
: browsingDate?.startOf("month"),
startTime: dayjs().startOf("day"),
endTime: browsingDate?.endOf("month"),
timeZone,
duration,
+5 -53
View File
@@ -1,12 +1,9 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import useTheme from "@calcom/lib/hooks/useTheme";
import type { ITimezoneOption } from "@calcom/ui";
import { TimezoneSelect } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
import { timeZone } from "../../lib/clock";
type Props = {
@@ -15,8 +12,6 @@ type Props = {
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const query = useMeQuery();
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
useEffect(() => {
setSelectedTimeZone(timeZone());
@@ -28,59 +23,16 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
}
}, [selectedTimeZone, onSelectTimeZone]);
const customStyles = {
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
padding: "10px 12px !important",
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
...(userTheme === "dark" && {
color: state.isSelected || state.isFocused ? "white" : "#80868B",
}),
}),
control: () => ({
display: "flex",
cursor: "pointer",
backgroundColor: "transparent !important",
minWidth: "5rem",
height: "24px",
minHeight: "24px !important",
boxShadow: "none !important",
}),
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
...(userTheme === "dark" && {
color: "#a5a5a5 !important",
}),
}),
menu: (provided: object) => ({
...provided,
minWidth: "20rem",
margin: "8px 0 0 -20px",
}),
valueContainer: (provided: object) => ({
...provided,
padding: "0 0 0 6px",
}),
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
marginLeft: 4,
padding: 0,
...(userTheme === "dark" && {
color: "#80868B",
}),
}),
};
return !!selectedTimeZone ? (
<TimezoneSelect
id="timeZone"
classNames={{
singleValue: () => "dark:text-darkgray-600 text-gray-600",
menu: () => "!w-64 max-w-[90vw]",
}}
variant="minimal"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
className="flex h-6 text-sm font-medium"
styles={customStyles}
/>
) : null;
};
@@ -17,8 +17,8 @@ export function TimezoneDropdown({
return (
<>
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</div>
</>
@@ -205,6 +205,7 @@ const routerQuerySchema = z
const BookingPage = ({
eventType,
booking,
currentSlotBooking,
profile,
isDynamicGroupBooking,
recurringEventCount,
@@ -287,6 +288,7 @@ const BookingPage = ({
return router.push({
pathname: `/booking/${uid}`,
query: {
isSuccessBookingPage: true,
allRemainingBookings: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventType.slug,
@@ -583,23 +585,27 @@ const BookingPage = ({
<div className="text-bookinghighlight flex items-start text-sm">
<FiUser
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
}`}
/>
<p
className={`${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
} mb-2 font-medium`}>
{booking
? eventType.seatsPerTimeSlot - booking.attendees.length
{currentSlotBooking
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
: eventType.seatsPerTimeSlot}{" "}
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
</p>
@@ -631,6 +637,7 @@ const BookingPage = ({
</Button>
<Button
type="submit"
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast"
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
loading={mutation.isLoading || recurringMutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
@@ -33,6 +33,7 @@ interface ISetLocationDialog {
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
selection?: LocationOption;
booking?: BookingItem;
isTeamEvent?: boolean;
defaultValues?: LocationObject[];
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
isOpenDialog: boolean;
@@ -74,6 +75,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
saveLocation,
selection,
booking,
isTeamEvent,
setShowLocationModal,
isOpenDialog,
defaultValues,
@@ -290,7 +292,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
query={locationsQuery}
success={({ data }) => {
if (!data.length) return null;
const locationOptions = [...data];
const locationOptions = [...data].filter((option) => {
return !isTeamEvent ? option.label !== "Conferencing" : true;
});
if (booking) {
locationOptions.map((location) =>
location.options.filter((l) => !["phone", "attendeeInPerson"].includes(l.value))
@@ -4,13 +4,15 @@ import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
import dayjs from "@calcom/dayjs";
import { NewScheduleButton } from "@calcom/features/schedules";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
import { FiExternalLink, FiGlobe } from "@calcom/ui/components/icon";
import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui";
import { FiExternalLink, FiGlobe, FiClock } from "@calcom/ui/components/icon";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
@@ -52,23 +54,24 @@ const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
const AvailabilitySelect = ({
className = "",
isLoading,
schedules,
...props
}: {
className?: string;
name: string;
value: number;
isLoading: boolean;
schedules: RouterOutputs["viewer"]["availability"]["list"]["schedules"] | [];
onBlur: () => void;
onChange: (value: AvailabilityOption | null) => void;
}) => {
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
const { t } = useLocale();
if (isLoading) {
return <SelectSkeletonLoader />;
}
const schedules = data?.schedules || [];
const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
@@ -107,6 +110,7 @@ const EventTypeScheduleDetails = () => {
const { watch } = useFormContext<FormValues>();
const scheduleId = watch("schedule");
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
@@ -174,6 +178,19 @@ const EventTypeScheduleDetails = () => {
const EventTypeSchedule = () => {
const { t } = useLocale();
const { data: schedules, isLoading } = trpc.viewer.availability.list.useQuery();
if (!schedules?.schedules.length && !isLoading)
return (
<EmptyScreen
Icon={FiClock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
buttonRaw={<NewScheduleButton fromEventType />}
border={false}
/>
);
return (
<div className="space-y-4">
<div>
@@ -183,14 +200,18 @@ const EventTypeSchedule = () => {
<Controller
name="schedule"
render={({ field }) => (
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
<>
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
schedules={schedules?.schedules || []}
isLoading={isLoading}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
</>
)}
/>
</div>
@@ -88,6 +88,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
label={t("minimum_booking_notice")}
type="number"
placeholder="0"
min={0}
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
/>
<input type="hidden" ref={ref} {...passThroughProps} />
@@ -35,16 +35,6 @@ const getLocationFromType = (
}
};
const getDefaultLocationValue = (options: EventTypeSetupProps["locationOptions"], type: string) => {
for (const locationType of options) {
for (const location of locationType.options) {
if (location.value === type && location.disabled === false) {
return location;
}
}
}
};
export const EventSetupTab = (
props: Pick<
EventTypeSetupProps,
@@ -53,12 +43,16 @@ export const EventSetupTab = (
) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType, locationOptions, team, destinationCalendar } = props;
const { eventType, team, destinationCalendar } = props;
const [showLocationModal, setShowLocationModal] = useState(false);
const [editingLocationType, setEditingLocationType] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
const locationOptions = props.locationOptions.filter((option) => {
return !team ? option.label !== "Conferencing" : true;
});
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
@@ -409,6 +403,7 @@ export const EventSetupTab = (
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isTeamEvent={!!team}
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
@@ -1,11 +1,11 @@
export type EventTypeDescriptionSafeProps = {
eventType: { description: string | null };
eventType: { description: string | null; descriptionAsSafeHTML: string | null };
};
export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => {
const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true };
// @ts-expect-error: @see packages/prisma/middleware/eventTypeDescriptionParseAndSanitize.ts
if (eventType.description) props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML };
if (eventType.description)
props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML || "" };
return <div {...props} />;
};
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
tooltip={t("embed")}
/>
<Button
color="secondary"
color="destructive"
variant="icon"
StartIcon={FiTrash}
tooltip={t("delete")}
@@ -75,7 +75,9 @@ export function CalendarSwitch(props: {
/>
{props.defaultSelected && (
<Badge variant="gray">
<FiArrowLeft className="mr-1" /> {t("adding_events_to")}
<div className="flex">
<FiArrowLeft className="mr-1" /> {t("adding_events_to")}
</div>
</Badge>
)}
</div>
+6 -5
View File
@@ -2,13 +2,14 @@ import Link from "next/link";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui";
type TeamType = NonNullable<TeamWithMembers>;
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
type MemberType = MembersType[number] & { safeBio: string | null };
type TeamTypeWithSafeHtml = Omit<TeamType, "members"> & { members: MemberType[] };
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale();
@@ -30,7 +31,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
<>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(member.bio || "") }}
dangerouslySetInnerHTML={{ __html: member.safeBio || "" }}
/>
</>
) : (
@@ -43,7 +44,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
);
};
const Members = ({ members, teamName }: { members: MembersType; teamName: string | null }) => {
const Members = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
if (!members || members.length === 0) {
return null;
}
@@ -57,7 +58,7 @@ const Members = ({ members, teamName }: { members: MembersType; teamName: string
);
};
const Team = ({ team }: { team: TeamType }) => {
const Team = ({ team }: { team: TeamTypeWithSafeHtml }) => {
return (
<div>
<Members members={team.members} teamName={team.name} />
+10 -1
View File
@@ -10,6 +10,8 @@ import type { ComponentProps, ReactNode } from "react";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { trpc } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui";
@@ -58,6 +60,11 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
return <I18nextAdapter {...passedProps} />;
};
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
}
const AppProviders = (props: AppPropsWithChildren) => {
const session = trpc.viewer.public.session.useQuery().data;
// No need to have intercom on public pages - Good for Page Performance
@@ -85,7 +92,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
storageKey={storageKey}
forcedTheme={forcedTheme}
attribute="class">
<MetaProvider>{props.children}</MetaProvider>
<FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider>
</FeatureFlagsProvider>
</ThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
+6 -22
View File
@@ -1,9 +1,8 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { z } from "zod";
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import slugify from "@calcom/lib/slugify";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
type BookingSelect = {
description: true;
@@ -45,11 +44,7 @@ function getResponsesFromOldBooking(
};
}
async function getBooking(
prisma: PrismaClient,
uid: string,
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">
) {
async function getBooking(prisma: PrismaClient, uid: string) {
const rawBooking = await prisma.booking.findFirst({
where: {
uid,
@@ -82,9 +77,7 @@ async function getBooking(
return rawBooking;
}
const booking = getBookingWithResponses(rawBooking, {
bookingFields,
});
const booking = getBookingWithResponses(rawBooking);
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
@@ -104,20 +97,11 @@ export const getBookingWithResponses = <
};
}>
>(
booking: T,
eventType: {
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
}
booking: T
) => {
return {
...booking,
responses: getBookingResponsesPartialSchema({
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)),
};
responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
} as Omit<T, "responses"> & { responses: z.infer<typeof bookingResponsesDbSchema> };
};
export default getBooking;
+8
View File
@@ -99,6 +99,14 @@ const nextConfig = {
skipDefaultConversion: true,
preventFullImport: true,
},
"@calcom/features/insights/components": {
transform: "@calcom/features/insights/components/{{member}}",
skipDefaultConversion: true,
preventFullImport: true,
},
lodash: {
transform: "lodash/{{member}}",
},
// TODO: We need to have all components in `@calcom/ui/components` in order to use this
// "@calcom/ui": {
// transform: "@calcom/ui/components/{{member}}",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.7.3",
"version": "2.7.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -11,6 +11,7 @@
"dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"build": "next build",
"start": "next start",
"lint": "eslint . --ignore-path .gitignore",
@@ -60,6 +61,7 @@
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^4.3.9",
"@tremor/react": "^2.0.0",
"@types/turndown": "^5.0.1",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
@@ -118,6 +120,7 @@
"react-window": "^1.8.7",
"remark": "^14.0.2",
"rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0",
"short-uuid": "^4.2.0",
"strip-markdown": "^5.0.0",
@@ -155,6 +158,7 @@
"@types/react-phone-number-input": "^3.0.14",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/sanitize-html": "^2.9.0",
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
"autoprefixer": "^10.4.12",
+38 -1
View File
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
import { FiFileText, FiCheck, FiBookOpen, FiChevronRight } from "@calcom/ui/components/icon";
import { FiBookOpen, FiCheck, FiChevronRight, FiFileText } from "@calcom/ui/components/icon";
import { ssgInit } from "@server/lib/ssg";
@@ -40,6 +40,43 @@ export default function Custom404() {
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
const isSignup = router.asPath.startsWith("/signup");
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
/**
* If we're on 404 and the route is insights it means it is disabled
* TODO: Abstract this for all disabled features
**/
const isInsights = router.asPath.startsWith("/insights");
if (isInsights) {
return (
<>
<HeadSeo
title="Feature is currently disabled"
description={t("404_page_not_found")}
nextSeoProps={{
nofollow: true,
noindex: true,
}}
/>
<div className="min-h-screen bg-white px-4" data-testid="404-page">
<main className="mx-auto max-w-xl pt-16 pb-6 sm:pt-24">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-black">{t("error_404")}</p>
<h1 className="font-cal mt-2 text-4xl font-extrabold text-gray-900 sm:text-5xl">
Feature is currently disabled
</h1>
</div>
<div className="mt-12">
<div className="mt-8">
<Link href="/" className="text-base font-medium text-black hover:text-gray-500">
{t("or_go_back_home")}
<span aria-hidden="true"> &rarr;</span>
</Link>
</div>
</div>
</main>
</div>
</>
);
}
return (
<>
+6 -2
View File
@@ -24,7 +24,7 @@ import defaultEvents, {
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
@@ -147,7 +147,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<>
<div
className=" dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(user.bio || "") }}
dangerouslySetInnerHTML={{ __html: props.safeBio }}
/>
</>
)}
@@ -343,6 +343,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}));
const isSingleUser = users.length === 1;
@@ -352,9 +353,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
})
: [];
const safeBio = markdownToSafeHTML(user.bio) || "";
return {
props: {
users,
safeBio,
profile,
user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
+2 -6
View File
@@ -5,7 +5,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { addListFormatting } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { User } from "@calcom/prisma/client";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@@ -59,7 +59,6 @@ Type.isThemeSupported = true;
const paramsSchema = z.object({ type: z.string(), user: z.string() });
async function getUserPageProps(context: GetStaticPropsContext) {
// load server side dependencies
const MarkdownIt = await import("markdown-it").then((mod) => mod.default);
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
@@ -124,9 +123,6 @@ async function getUserPageProps(context: GetStaticPropsContext) {
},
},
});
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
if (!user || !user.eventTypes.length) return { notFound: true };
const [eventType]: ((typeof user.eventTypes)[number] & {
@@ -153,7 +149,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations),
descriptionAsSafeHTML: eventType.description ? addListFormatting(md.render(eventType.description)) : null,
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
});
// Check if the user you are logging into has any active teams or premium user name
const hasActiveTeam =
+9 -6
View File
@@ -14,6 +14,7 @@ import {
getUsernameList,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import {
customInputSchema,
@@ -189,6 +190,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: u.username,
theme: u.theme,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
@@ -217,7 +219,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
}
} else if (eventTypeRaw.seatsPerTimeSlot && query.duration && query.date) {
}
if (query.duration) {
// If it's not reschedule but event Type has seats we should obtain
// the bookingUid regardless and use it to get the booking
const currentSeats = await prisma.booking.findFirst({
@@ -237,11 +241,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
let booking: GetBookingType | null = null;
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
booking = await getBooking(
prisma,
rescheduleUid || query.bookingUid || bookingUidWithSeats || "",
eventTypeObject.bookingFields
);
booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
}
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
@@ -285,12 +285,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: eventType.recurringEvent.count)) ||
null;
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
return {
props: {
away: user.away,
profile,
eventType: eventTypeObject,
booking,
currentSlotBooking: currentSlotBooking,
recurringEventCount,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
+37 -4
View File
@@ -1,4 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
@@ -11,10 +19,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
/**
* TODO:
* Remove this endpoint
*/
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(req.query);
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
},
select: {
id: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
for (const team of teams) {
await updateQuantitySubscriptionFromStripe(team.id);
await delay(100); // Adjust the delay as needed to avoid rate limiting
}
pageNumber++;
}
res.json({ ok: true });
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ResetPasswordRequest } from "@prisma/client";
import debounce from "lodash/debounce";
import { debounce } from "lodash";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
@@ -1,4 +1,4 @@
import debounce from "lodash/debounce";
import { debounce } from "lodash";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+10 -2
View File
@@ -94,6 +94,7 @@ export default function Availability() {
data: { schedule: scheduleId },
} = useTypedQuery(querySchema);
const { fromEventType } = router.query;
const { timeFormat } = me.data || { timeFormat: null };
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
@@ -153,7 +154,7 @@ export default function Availability() {
return (
<Shell
backPath="/availability"
backPath={fromEventType ? true : "/availability"}
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
heading={
<Controller
@@ -200,7 +201,14 @@ export default function Availability() {
<VerticalDivider />
<Dialog>
<DialogTrigger asChild>
<Button StartIcon={FiTrash} variant="icon" color="destructive" aria-label={t("delete")} />
<Button
StartIcon={FiTrash}
variant="icon"
color="destructive"
aria-label={t("delete")}
disabled={schedule?.isLastSchedule}
tooltip={t("requires_at_least_one_schedule")}
/>
</DialogTrigger>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
+7 -1
View File
@@ -93,7 +93,13 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
key={schedule.id}
schedule={schedule}
updateDefault={updateMutation.mutate}
deleteFunction={deleteMutation.mutate}
deleteFunction={() => {
if (schedules.length === 1) {
showToast(t("requires_at_least_one_schedule"), "error");
} else {
deleteMutation.mutate;
}
}}
/>
))}
</ul>
+9 -6
View File
@@ -180,7 +180,7 @@ const querySchema = z.object({
cancel: stringToBoolean,
changes: stringToBoolean,
reschedule: stringToBoolean,
isSuccessBookingPage: z.string().optional(),
isSuccessBookingPage: stringToBoolean,
formerTime: z.string().optional(),
email: z.string().optional(),
seatReferenceUid: z.string().optional(),
@@ -266,7 +266,11 @@ export default function Success(props: SuccessProps) {
const giphyImage = giphyAppData?.thankYouPage;
const eventName = getEventName(eventNameObject, true);
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
// Confirmation can be needed in two cases as of now
// - Event Type has require confirmation option enabled always
// - EventType has conditionally enabled confirmation option based on how far the booking is scheduled.
// - It's a paid event and payment is pending.
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING;
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
const isCancelled =
@@ -485,7 +489,7 @@ export default function Success(props: SuccessProps) {
</>
)}
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
<div className="col-span-2 mb-6 last:mb-0">{props.bookingInfo.title}</div>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6 last:mb-0">
{reschedule && !!formerTime && (
@@ -527,7 +531,7 @@ export default function Success(props: SuccessProps) {
</div>
)}
{bookingInfo?.attendees.map((attendee) => (
<div key={attendee.name} className="mb-3 last:mb-0">
<div key={attendee.name + attendee.email} className="mb-3 last:mb-0">
{attendee.name && <p>{attendee.name}</p>}
<p data-testid={`attendee-${attendee.email}`} className="text-bookinglight">
{attendee.email}
@@ -1087,8 +1091,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw);
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
+2
View File
@@ -5,6 +5,7 @@ import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -119,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: u.hideBranding,
timeZone: u.timeZone,
})),
descriptionAsSafeHTML: markdownToSafeHTML(hashedLink.eventType.description),
});
const [user] = users;
+3
View File
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { bookEventTypeSelect } from "@calcom/prisma/selects";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -93,6 +94,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
brandColor: u.brandColor,
darkBrandColor: u.darkBrandColor,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
@@ -120,6 +122,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile,
eventType: eventTypeObject,
booking: null,
currentSlotBooking: null,
trpcState: ssr.dehydrate(),
recurringEventCount,
isDynamicGroupBooking: false,
@@ -65,6 +65,7 @@ export type FormValues = {
hostPhoneNumber?: string;
displayLocationPublicly?: boolean;
phone?: string;
hostDefault?: string;
}[];
customInputs: CustomInputParsed[];
schedule: number | null;
+1 -1
View File
@@ -141,7 +141,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
</div>
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type}
eventType={{ ...type, descriptionAsSafeHTML: type.safeDescription }}
shortenDescription
/>
</Link>
+133
View File
@@ -0,0 +1,133 @@
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import {
AverageEventDurationChart,
BookingKPICards,
BookingStatusLineChart,
LeastBookedTeamMembersTable,
MostBookedTeamMembersTable,
PopularEventsTable,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { Filters } from "@calcom/features/insights/filters";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { FiRefreshCcw, FiUserPlus, FiUsers } from "@calcom/ui/components/icon";
const Heading = () => {
const { t } = useLocale();
const {
filter: { selectedTeamName },
} = useFilterContext();
return (
<div className="min-w-52">
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden truncate text-xl font-semibold tracking-wide text-black md:block xl:max-w-full">
{t("analytics_for_organisation", {
organisationName: selectedTeamName,
})}
</h3>
<p className="hidden text-sm text-gray-500 md:block">{t("subtitle_analytics")}</p>
</div>
);
};
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <FiUsers className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <FiRefreshCcw className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <FiUserPlus className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell>
<UpgradeTip
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/banners/insights.jpg"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="secondary" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? (
<></>
) : (
<FiltersProvider>
<div className="mb-4 mt-0 ml-auto flex w-full flex-wrap justify-between md:-mt-8">
<Heading />
<Filters />
</div>
<div className="mb-4 space-y-6">
<BookingKPICards />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<small className="block text-center text-gray-600">
{t("looking_for_more_analytics")}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</div>
);
}
// If feature flag is disabled, return not found on getServerSideProps
export const getServerSideProps = async () => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
if (flags.insights === false) {
return {
notFound: true,
};
}
return {
props: {},
};
};
+9
View File
@@ -0,0 +1,9 @@
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import { getLayout } from "@components/auth/layouts/AdminLayout";
const FlagsPage = () => <FlagListingView />;
FlagsPage.getLayout = getLayout;
export default FlagsPage;
@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { IdentityProvider } from "@prisma/client";
import crypto from "crypto";
import { signOut } from "next-auth/react";
import type { BaseSyntheticEvent } from "react";
import { useRef, useState } from "react";
@@ -332,11 +331,6 @@ const ProfileForm = ({
bio: z.string(),
});
const emailMd5 = crypto
.createHash("md5")
.update(defaultValues.email || "example@example.com")
.digest("hex");
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(profileFormSchema),
@@ -356,7 +350,7 @@ const ProfileForm = ({
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={emailMd5} size="lg" />
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
<div className="ltr:ml-4 rtl:mr-4">
<ImageUploader
target="avatar"
+10 -3
View File
@@ -10,7 +10,7 @@ import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
@@ -113,7 +113,7 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
<>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
dangerouslySetInnerHTML={{ __html: team.safeBio }}
/>
</>
)}
@@ -187,11 +187,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...user,
avatar: CAL_URL + "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
}));
const safeBio = markdownToSafeHTML(team.bio) || "";
const members = team.members.map((member) => {
return { ...member, safeBio: markdownToSafeHTML(member.bio || "") };
});
return {
props: {
team,
team: { ...team, safeBio, members },
trpcState: ssr.dehydrate(),
},
} as const;
+3 -3
View File
@@ -2,9 +2,9 @@ import type { GetServerSidePropsContext } from "next";
import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -64,7 +64,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: {
id: true,
name: true,
avatar: true,
username: true,
timeZone: true,
hideBranding: true,
@@ -171,13 +170,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding,
timeZone,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
});
eventTypeObject.availability = [];
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject));
booking = await getBooking(prisma, rescheduleUid);
}
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
+4 -1
View File
@@ -5,6 +5,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -121,13 +122,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: u.avatar,
slug: u.username,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
let booking: GetBookingType | null = null;
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
if (rescheduleUid || bookingUid) {
booking = await getBooking(prisma, rescheduleUid || bookingUid || "", eventTypeObject.bookingFields);
booking = await getBooking(prisma, rescheduleUid || bookingUid || "");
}
// Checking if number of recurring event ocurrances is valid against event type configuration
@@ -152,6 +154,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject,
recurringEventCount,
booking,
currentSlotBooking: null,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,
hashedLink: null,
+1 -1
View File
@@ -119,7 +119,7 @@ test.describe("Event Types tests", () => {
await page.locator("#location-select").click();
await page.locator("text=In Person (Organizer Address)").click();
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(250);
await page.waitForTimeout(1000);
await page.locator('input[name="locationAddress"]').fill(inputText);
await page.locator("[data-testid=display-location]").check();
await page.locator("[data-testid=update-location]").click();
+1 -1
View File
@@ -45,7 +45,7 @@ export const createEmbedsFixture = (page: Page) => {
}
api("on", {
action: "*",
callback: (e: any) => {
callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
import noop from "lodash/noop";
import { noop } from "lodash";
import { test } from "./fixtures";
+5 -1
View File
@@ -26,7 +26,11 @@ test.describe("Onboarding", () => {
await page.locator("input[name=username]").fill("new user onboarding");
await page.locator("input[name=name]").fill("new user 2");
await page.locator("input[role=combobox]").click();
await page.locator("text=Europe/London GMT +0:00").click();
await page
.locator("*")
.filter({ hasText: /^Europe\/London/ })
.first()
.click();
await page.locator("button[type=submit]").click();
+4 -6
View File
@@ -97,11 +97,12 @@ test.describe("Reschedule Tests", async () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// NOTE: remove if old booking should not be deleted
expect(await (await booking.self()).status).toBe(BookingStatus.CANCELLED);
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
const rescheduledBooking = await prisma.booking.findFirst({ where: { uid: booking.uid } });
expect(newBooking).not.toBeNull();
expect(rescheduledBooking).toBeNull();
await prisma.booking.delete({ where: { id: newBooking?.id } });
});
@@ -148,7 +149,6 @@ test.describe("Reschedule Tests", async () => {
});
await expect(page).toHaveURL(/.*payment/);
await payment.delete();
});
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
@@ -172,8 +172,6 @@ test.describe("Reschedule Tests", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
await payment.delete();
});
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

+41 -9
View File
@@ -111,7 +111,7 @@
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
"upgrade_to_per_seat": "Upgrade to Per-Seat",
"seat_options_doesnt_support_confirmation":"Seats option doesn't support confirmation requirement",
"seat_options_doesnt_support_confirmation": "Seats option doesn't support confirmation requirement",
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.",
"team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.",
"team_upgrade_banner_action": "Upgrade here",
@@ -433,7 +433,7 @@
"password_hint_min": "Minimum 7 characters long",
"password_hint_admin_min": "Minimum 15 characters long",
"password_hint_num": "Contain at least 1 number",
"max_limit_allowed_hint":"Must be {{limit}} or fewer characters long",
"max_limit_allowed_hint": "Must be {{limit}} or fewer characters long",
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
"incorrect_password": "Password is incorrect.",
"incorrect_username_password": "Username or password is incorrect.",
@@ -863,7 +863,7 @@
"add_new_calendar": "Add a new calendar",
"set_calendar": "Set where to add new events to when you're booked.",
"delete_schedule": "Delete schedule",
"delete_schedule_description":"Deleting a schedule will remove it from all event types. This action cannot be undone.",
"delete_schedule_description": "Deleting a schedule will remove it from all event types. This action cannot be undone.",
"schedule_created_successfully": "{{scheduleName}} schedule created successfully",
"availability_updated_successfully": "{{scheduleName}} schedule updated successfully",
"schedule_deleted_successfully": "Schedule deleted successfully",
@@ -1218,6 +1218,7 @@
"impersonation": "Impersonation",
"impersonation_description": "Settings to manage user impersonation",
"users": "Users",
"user": "User",
"profile_description": "Manage settings for your {{appName}} profile",
"users_description": "Here you can find a list of all users",
"users_listing": "User listing",
@@ -1608,6 +1609,7 @@
"default_app_link_title": "Set a default app link",
"default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.",
"change_default_conferencing_app": "Set as default",
"organizer_default_conferencing_app": "Organizer's default app",
"under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats",
@@ -1631,22 +1633,30 @@
"a_routing_form": "A Routing Form",
"form_description_placeholder": "Form Description",
"keep_me_connected_with_form": "Keep me connected with the form",
"fields_in_form_duplicated":"Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
"fields_in_form_duplicated": "Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
"form_deleted": "Form deleted",
"delete_form": "Are you sure you want to delete this form?",
"delete_form_action": "Yes, delete Form",
"delete_form_confirmation":"Anyone who you've shared the link with will no longer be able to access it.",
"delete_form_confirmation_2":"All associated responses will be deleted.",
"delete_form_confirmation": "Anyone who you've shared the link with will no longer be able to access it.",
"delete_form_confirmation_2": "All associated responses will be deleted.",
"typeform_redirect_url_copied": "Typeform Redirect URL copied! You can go and set the URL in Typeform form.",
"modifications_in_fields_warning": "Modifications in fields and routes of following forms will be reflected in this form.",
"connected_forms": "Connected Forms",
"form_modifications_warning": "Following forms would be affected when you modify fields or routes here.",
"responses_collection_waiting_description": "Wait for some time for responses to be collected. You can go and submit the form yourself as well.",
"this_is_what_your_users_would_see":"This is what your users would see",
"this_is_what_your_users_would_see": "This is what your users would see",
"identifies_name_field": "Identifies field by this name.",
"add_1_option_per_line": "Add 1 option per line",
"select_a_router": "Select a router",
"add_a_new_route": "Add a new Route",
"make_informed_decisions": "Make informed decisions with Insights",
"make_informed_decisions_description": "Our Insights dashboard surfaces all activity across your team and shows you trends that enable better team scheduling and decision making.",
"view_bookings_across": "View bookings across all members",
"view_bookings_across_description": "See who’s receiving the most bookings and ensure the best distribution across your team",
"identify_booking_trends": "Identify booking trends",
"identify_booking_trends_description": "See what times of the week and what times during the day are popular for your bookers",
"spot_popular_event_types": "Spot popular event types",
"spot_popular_event_types_description": "See which of your event types are receiving the most clicks and bookings",
"no_responses_yet": "No responses yet",
"this_will_be_the_placeholder": "This will be the placeholder",
"this_meeting_has_not_started_yet": "This meeting has not started yet",
@@ -1657,8 +1667,30 @@
"verification_code": "Verification code",
"can_you_try_again": "Can you try again with a different time?",
"verify": "Verify",
"invalid_event_name_variables":"There is an invalid variable in your event name",
"invalid_event_name_variables": "There is an invalid variable in your event name",
"select_all": "Select All",
"default_conferencing_bulk_title": "Bulk update existing event types",
"default_conferencing_bulk_description": "Update the locations for the selected event types"
"requires_at_least_one_schedule": "You are required to have at least one schedule",
"default_conferencing_bulk_description": "Update the locations for the selected event types",
"looking_for_more_analytics": "Looking for more analytics?",
"add_filter": "Add filter",
"select_user": "Select User",
"select_event_type": "Select Event Type",
"select_date_range": "Select Date Range",
"popular_events": "Popular Events",
"no_event_types_found": "No event types found",
"average_event_duration": "Average Event Duration",
"most_booked_members": "Most Booked Members",
"least_booked_members": "Least Booked Members",
"events_created": "Events Created",
"events_completed": "Events Completed",
"events_cancelled": "Events Cancelled",
"events_rescheduled": "Events Rescheduled",
"from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"analytics_for_organisation": "Insights for {{organisationName}}",
"subtitle_analytics": "Learn more about your team's activity",
"event_trends": "Event Trends",
"clear_filters": "Clear Filters",
"insights": "Insights"
}
@@ -1213,6 +1213,7 @@
"impersonation": "Suplantación",
"impersonation_description": "Configuración para administrar la suplantación del usuario",
"users": "Usuarios",
"user": "Usuario",
"profile_description": "Administra los ajustes para tu perfil de {{appName}}",
"users_description": "Aquí puede encontrar una lista de todos los usuarios",
"users_listing": "Lista de usuarios",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

-1
View File
@@ -29,7 +29,6 @@ export async function ssgInit<TParams extends { locale?: string }>(opts: GetStat
ctx: {
prisma,
session: null,
user: null,
locale,
i18n: _i18n,
},
+2
View File
@@ -497,3 +497,5 @@ hr {
::-webkit-search-cancel-button {
-webkit-appearance: none;
}
+1 -1
View File
@@ -2,5 +2,5 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [...base.content],
content: [...base.content, "../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}"],
};
+3 -1
View File
@@ -70,6 +70,7 @@
"test-playwright": "yarn playwright test --config=playwright.config.ts",
"test": "jest",
"type-check": "turbo run type-check",
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
"web": "yarn workspace @calcom/web"
},
"devDependencies": {
@@ -86,8 +87,9 @@
"jest-watch-select-projects": "^2.0.0",
"jest-watch-typeahead": "^2.0.0",
"lint-staged": "^12.5.0",
"prettier": "^2.8.4",
"prettier": "^2.8.6",
"ts-jest": "^28.0.8",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4"
},
"dependencies": {
+3 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import path from "path";
import seedAppStoreConfig from "@calcom/prisma/seed-app-store.config.json";
import type seedAppStoreConfig from "@calcom/prisma/seed-app-store.config.json";
import { APP_STORE_PATH, TEMPLATES_PATH } from "./constants";
import execSync from "./utils/execSync";
@@ -127,6 +127,8 @@ export const BaseAppFork = {
.replace(/_DESCRIPTION_/g, description)
.replace(/_APP_DIR_/g, slug)
);
// New monorepo package has been added, so we need to run yarn again
await execSync("yarn");
},
delete: async function ({ slug, isTemplate }: { slug: string; isTemplate: boolean }) {
@@ -3,7 +3,6 @@
Don't modify this file manually.
**/
import dynamic from "next/dynamic";
export const InstallAppButtonMap = {
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
around: dynamic(() => import("./around/components/InstallAppButton")),
@@ -24,7 +24,6 @@ import { appKeysSchema as vital_zod_ts } from "./vital/zod";
import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,
@@ -58,7 +58,6 @@ import wordpress_config_json from "./wordpress/config.json";
import { metadata as zapier__metadata_ts } from "./zapier/_metadata";
import zohocrm_config_json from "./zohocrm/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
amie: amie_config_json,
applecalendar: applecalendar__metadata_ts,
@@ -24,7 +24,6 @@ import { appDataSchema as vital_zod_ts } from "./vital/zod";
import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,
+1
View File
@@ -10,6 +10,7 @@ items:
{DESCRIPTION}
### Cron's most valuable features
<!-- From "FAQs: What are Cron’s most valuable features?"
https://cronhq.notion.site/Cron-Calendar-5625be54feac4e13a75b10271b65ddb7 -->
@@ -133,6 +133,7 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar,
requestBody: payload,
conferenceDataVersion: 1,
sendUpdates: "none",
},
function (error, event) {
if (error || !event?.data) {
@@ -234,14 +235,13 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar,
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
sendUpdates: "none",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, evt) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
@@ -293,7 +293,7 @@ export default class GoogleCalendarService implements Calendar {
calendarId: calendarId ? calendarId : defaultCalendarId,
eventId: uid,
sendNotifications: false,
sendUpdates: "all",
sendUpdates: "none",
},
function (err: GoogleCalError | null, event) {
if (err) {
+25 -4
View File
@@ -12,13 +12,20 @@ export type DefaultEventLocationType = {
type: DefaultEventLocationTypeEnum;
label: string;
messageForOrganizer: string;
category: "in person" | "other" | "phone";
category: "in person" | "conferencing" | "other" | "phone";
iconUrl: string;
urlRegExp?: string;
// HACK: `variable` and `defaultValueVariable` are required due to legacy reason where different locations were stored in different places.
variable: "locationType" | "locationAddress" | "address" | "locationLink" | "locationPhoneNumber" | "phone";
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone";
variable:
| "locationType"
| "locationAddress"
| "address"
| "locationLink"
| "locationPhoneNumber"
| "phone"
| "hostDefault";
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone";
} & (
| {
organizerInputType: "phone" | "text" | null;
@@ -60,6 +67,7 @@ export enum DefaultEventLocationTypeEnum {
*/
UserPhone = "userPhone",
Link = "link",
Conferencing = "conferencing",
}
export const defaultLocations: DefaultEventLocationType[] = [
@@ -88,6 +96,17 @@ export const defaultLocations: DefaultEventLocationType[] = [
iconUrl: "/map-pin.svg",
category: "in person",
},
{
default: true,
type: DefaultEventLocationTypeEnum.Conferencing,
iconUrl: "/link.svg",
organizerInputType: null,
label: "organizer_default_conferencing_app",
variable: "hostDefault",
defaultValueVariable: "hostDefault",
category: "conferencing",
messageForOrganizer: "",
},
{
default: true,
type: DefaultEventLocationTypeEnum.Link,
@@ -130,7 +149,9 @@ export const defaultLocations: DefaultEventLocationType[] = [
export type LocationObject = {
type: string;
displayLocationPublicly?: boolean;
} & Partial<Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone", string>>;
} & Partial<
Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone", string>
>;
// integrations:jitsi | 919999999999 | Delhi | https://manual.meeting.link | Around Video
export type BookingLocationValue = string;
@@ -219,7 +219,7 @@ export default class Office365CalendarService implements Calendar {
};
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = async (refreshToken: string) => {
const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => {
const { client_id, client_secret } = await getOfficeAppKeys();
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
@@ -227,12 +227,22 @@ export default class Office365CalendarService implements Calendar {
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id,
refresh_token: refreshToken,
refresh_token: o365AuthCredentials.refresh_token,
grant_type: "refresh_token",
client_secret,
}),
});
const o365AuthCredentials = refreshTokenResponseSchema.parse(await handleErrorsJson(response));
const responseJson = await handleErrorsJson(response);
const tokenResponse = refreshTokenResponseSchema.safeParse(responseJson);
o365AuthCredentials = { ...o365AuthCredentials, ...(tokenResponse.success && tokenResponse.data) };
if (!tokenResponse.success) {
console.error(
"Outlook error grabbing new tokens ~ zodError:",
tokenResponse.error,
"MS response:",
responseJson
);
}
await prisma.credential.update({
where: {
id: credential.id,
@@ -246,9 +256,10 @@ export default class Office365CalendarService implements Calendar {
return {
getToken: () =>
refreshTokenResponseSchema.safeParse(o365AuthCredentials).success &&
!isExpired(o365AuthCredentials.expires_in)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
: refreshAccessToken(o365AuthCredentials),
};
};
+1
View File
@@ -8,6 +8,7 @@
],
"scripts": {
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
@@ -99,23 +99,18 @@ export default function RoutingForms({
CTA={hasPaidPlan && <NewFormButton />}
subtitle={t("routing_forms_description")}>
<UpgradeTip
dark
title={t("teams_plan_required")}
description={t("routing_forms_are_a_great_way")}
features={features}
background="/routing-form-banner-background.jpg"
background="/banners/routing-forms.jpg"
isParentLoading={isLoading && <SkeletonLoaderTeamList />}
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("upgrade")}
</Button>
<Button
color="minimal"
className="!bg-transparent text-white opacity-50 hover:opacity-100"
href="https://go.cal.com/teams-video"
target="_blank">
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
+1 -1
View File
@@ -19,7 +19,7 @@
"type": "integrations:whereby_video",
"label": "Whereby Video",
"organizerInputPlaceholder": "https://www.whereby.com/cal",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?(team.)?whereby.com\\/[a-zA-Z0-9]*"
"urlRegExp": "^(?:https?://)?(?:(?!.*-\\.)(?:\\w+(-\\w+)*\\.))*whereby\\.com(/[\\w\\-._~:?#\\[\\]@!$&'()*+,;%=]+)*$"
}
}
}
-14
View File
@@ -1,14 +0,0 @@
/* @see https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues/31#issuecomment-1024722576 */
const tailwind = require("prettier-plugin-tailwindcss");
const sortImports = require("@trivago/prettier-plugin-sort-imports");
const combinedFormatter = {
parsers: {
typescript: {
...tailwind.parsers.typescript,
preprocess: sortImports.parsers.typescript.preprocess,
},
},
};
module.exports = combinedFormatter;
+3 -3
View File
@@ -20,13 +20,13 @@
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/line-clamp": "^0.4.0",
"@tailwindcss/typography": "^0.5.4",
"@trivago/prettier-plugin-sort-imports": "3.2.0",
"@trivago/prettier-plugin-sort-imports": "4.1.1",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"eslint": "^8.34.0",
"eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^2.8.4",
"prettier-plugin-tailwindcss": "^0.1.13",
"prettier": "^2.8.6",
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.2.1",
"typescript": "^4.9.4"
+8 -1
View File
@@ -16,7 +16,14 @@ module.exports = {
"^[./]",
],
importOrderSeparation: true,
plugins: [require("./merged-prettier-plugin")],
plugins: [
"@trivago/prettier-plugin-sort-imports",
/**
* **NOTE** tailwind plugin must come last!
* @see https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
*/
"prettier-plugin-tailwindcss",
],
overrides: [
{
files: ["apps/website/lib/utils/wordlist/wordlist.ts"],
+6 -6
View File
@@ -1,5 +1,5 @@
import type { SelectedCalendar } from "@prisma/client";
import _ from "lodash";
import { sortBy } from "lodash";
import * as process from "process";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
@@ -54,8 +54,8 @@ export const getConnectedCalendars = async (
};
}
const cals = await calendar.listCalendars();
const calendars = _(cals)
.map((cal) => {
const calendars = sortBy(
cals.map((cal) => {
if (cal.externalId === destinationCalendarExternalId) destinationCalendar = cal;
return {
...cal,
@@ -64,9 +64,9 @@ export const getConnectedCalendars = async (
isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
credentialId,
};
})
.sortBy(["primary"])
.value();
}),
["primary"]
);
const primary = calendars.find((item) => item.primary) ?? calendars.find((cal) => cal !== undefined);
if (!primary) {
return {
+1 -2
View File
@@ -1,6 +1,5 @@
import type { DestinationCalendar, Booking } from "@prisma/client";
import { cloneDeep } from "lodash";
import merge from "lodash/merge";
import { cloneDeep, merge } from "lodash";
import { v5 as uuidv5 } from "uuid";
import type { z } from "zod";
-25
View File
@@ -1,31 +1,6 @@
/* eslint-disable @calcom/eslint/deprecated-imports */
import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-days2";
import "dayjs/locale/ar";
import "dayjs/locale/bg";
import "dayjs/locale/cs";
import "dayjs/locale/de";
import "dayjs/locale/es";
import "dayjs/locale/es-mx";
import "dayjs/locale/fr";
import "dayjs/locale/he";
import "dayjs/locale/hu";
import "dayjs/locale/it";
import "dayjs/locale/ja";
import "dayjs/locale/ko";
import "dayjs/locale/nl";
import "dayjs/locale/pl";
import "dayjs/locale/pt";
import "dayjs/locale/pt-br";
import "dayjs/locale/ro";
import "dayjs/locale/ru";
import "dayjs/locale/sr";
import "dayjs/locale/sv";
import "dayjs/locale/tr";
import "dayjs/locale/uk";
import "dayjs/locale/vi";
import "dayjs/locale/zh-cn";
import "dayjs/locale/zh-tw";
import customParseFormat from "dayjs/plugin/customParseFormat";
import isBetween from "dayjs/plugin/isBetween";
import isToday from "dayjs/plugin/isToday";
+25
View File
@@ -0,0 +1,25 @@
import "dayjs/locale/ar";
import "dayjs/locale/bg";
import "dayjs/locale/cs";
import "dayjs/locale/de";
import "dayjs/locale/es";
import "dayjs/locale/es-mx";
import "dayjs/locale/fr";
import "dayjs/locale/he";
import "dayjs/locale/hu";
import "dayjs/locale/it";
import "dayjs/locale/ja";
import "dayjs/locale/ko";
import "dayjs/locale/nl";
import "dayjs/locale/pl";
import "dayjs/locale/pt";
import "dayjs/locale/pt-br";
import "dayjs/locale/ro";
import "dayjs/locale/ru";
import "dayjs/locale/sr";
import "dayjs/locale/sv";
import "dayjs/locale/tr";
import "dayjs/locale/uk";
import "dayjs/locale/vi";
import "dayjs/locale/zh-cn";
import "dayjs/locale/zh-tw";
+20 -1
View File
@@ -1,3 +1,5 @@
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
const Spacer = () => <p style={{ height: 6 }} />;
export const Info = (props: {
@@ -6,8 +8,14 @@ export const Info = (props: {
extraInfo?: React.ReactNode;
withSpacer?: boolean;
lineThrough?: boolean;
formatted?: boolean;
}) => {
if (!props.description || props.description === "") return null;
const descriptionCSS = "color: '#101010'; font-weight: 400; line-height: 24px; margin: 0;";
const safeDescription = markdownToSafeHTML(props.description.toString()) || "";
return (
<>
{props.withSpacer && <Spacer />}
@@ -21,7 +29,18 @@ export const Info = (props: {
whiteSpace: "pre-wrap",
textDecoration: props.lineThrough ? "line-through" : undefined,
}}>
{props.description}
{props.formatted ? (
<p
className="dark:text-darkgray-600 mt-2 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{
__html: safeDescription
.replaceAll("<p>", `<p style="${descriptionCSS}">`)
.replaceAll("<li>", `<li style="${descriptionCSS}">`),
}}
/>
) : (
props.description
)}
</p>
{props.extraInfo}
</div>
@@ -2,6 +2,8 @@ import type { TFunction } from "next-i18next";
import { RRule } from "rrule";
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -76,7 +76,7 @@ export const BaseScheduledEmail = (
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
<WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer />
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<UserFieldsResponses calEvent={props.calEvent} />
+9 -1
View File
@@ -4,8 +4,10 @@ import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import prisma from "@calcom/prisma";
declare let global: {
E2E_EMAILS?: Record<string, unknown>[];
@@ -29,7 +31,13 @@ export default class BaseEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {};
}
public sendEmail() {
public async sendEmail() {
const featureFlags = await getFeatureFlagMap(prisma);
/** If email kill switch exists and is active, we prevent emails being sent. */
if (featureFlags.emails) {
console.warn("Skipped Sending Email due to active Kill Switch");
return new Promise((r) => r("Skipped Sending Email due to active Kill Switch"));
}
if (process.env.NEXT_PUBLIC_IS_E2E) {
global.E2E_EMAILS = global.E2E_EMAILS || [];
global.E2E_EMAILS.push(this.getNodeMailerPayload());
+15 -316
View File
@@ -4,6 +4,16 @@
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> -->
<script>
function generateRandomHexColor() {
// Generate a random integer between 0 and 16777215 (FFFFFF in hex)
const randomInt = Math.floor(Math.random() * 16777216);
// Convert the integer to a hex string with 6 digits and add leading zeros if necessary
const hexString = randomInt.toString(16).padStart(6, '0');
// Return the hex string with a '#' prefix
return `#${hexString}`;
}
if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>');
}
@@ -90,6 +100,7 @@
</div>
<span style="display: block;" ><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span>
<div>
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples</h2>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">Book with Free User[Auto Theme]</button>
@@ -140,12 +151,12 @@
Align Center
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: {
backgroundColor: '#D3D3D3',
backgroundColor: generateRandomHexColor(),
},
disabledDateButton: {
backgroundColor: 'lightslategray',
backgroundColor: generateRandomHexColor(),
},}})})()">
Change Date Button Color
Change 'enabledDateButton` and `disabledDateButton` background Color
</button>
</div>
@@ -189,318 +200,6 @@
<div class="place">
</div>
</div>
<script>
const callback = function (e) {
const detail = e.detail;
const namespace = detail.namespace || "default";
if (detail.type === "linkReady") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
} else if (detail.type === "linkFailed") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
}
document.querySelector(`#cal-booking-place-${namespace} .last-action`).innerHTML = JSON.stringify(
e.detail
);
};
const searchParams = new URL(document.URL).searchParams;
let only = window.only
if (only === "all" || only === "ns:default") {
Cal("init", {
debug: 1,
origin: "http://localhost:3000",
});
Cal("inline", {
elementOrSelector: "#cal-booking-place-default .place",
calLink: "pro?case=1",
config: {
__autoScroll:true,
iframeAttrs: {
id: "cal-booking-place-default-iframe"
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
theme: "dark",
},
});
Cal("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:second") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "second", {
debug: 1,
origin: "http://localhost:3000",
});
// Bulk API is supported - Keep all configuration at one place.
Cal.ns.second(
[
"inline",
{
elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2",
config: {
iframeAttrs: {
id: "cal-booking-place-second-iframe"
},
}
},
]
);
Cal.ns.second("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:third") {
// Create a namespace "third". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "third", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.third(
[
"inline",
{
elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min",
config: {
iframeAttrs: {
id: "cal-booking-place-third-iframe"
},
}
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
enabledDateButton: {
backgroundColor: "red"
},
disabledDateButton: {
backgroundColor: "green"
}
},
},
]
);
Cal.ns.third("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fourth") {
Cal("init", "fourth", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.fourth(
[
"inline",
{
elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/seeded-team",
config: {
iframeAttrs: {
id: "cal-booking-place-fourth-iframe"
},
}
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
},
},
]
);
Cal.ns.fourth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fifth") {
Cal("init", "fifth", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.fifth(
[
"inline",
{
elementOrSelector: "#cal-booking-place-fifth .place",
calLink: "team/seeded-team/collective-seeded-team-event",
config: {
iframeAttrs: {
id: "cal-booking-place-fifth-iframe"
},
}
},
],
);
Cal.ns.fifth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "prerender-test") {
Cal('init', 'prerendertestLightTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns.prerendertestLightTheme("preload", {
calLink: "free",
});
}
if (only === "all" || only === "inline-routing-form") {
Cal('init', 'inline-routing-form', {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns['inline-routing-form'](
[
"inline",
{
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/948ae412-d995-4865-875a-48302588de03",
config: {
iframeAttrs: {
id: "cal-booking-place-inline-routing-form-iframe"
},
}
},
],
);
}
if (only === "all" || only === "hideEventTypeDetails") {
let identifier = "hideEventTypeDetails"
Cal('init', identifier, {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns.hideEventTypeDetails([
['inline', {
elementOrSelector: `#cal-booking-place-${identifier} .place`,
calLink: "free/30min",
config: {
iframeAttrs: {
id: `cal-booking-place-${identifier}-iframe`
},
}
}],
[
'ui', {
hideEventTypeDetails:true
}
]
]);
}
Cal('init', 'popupDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupHideEventTypeDetails', {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.popupHideEventTypeDetails('ui', {
hideEventTypeDetails:true
});
Cal('init', 'popupReschedule', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupAutoTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkLightTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinksList', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupPaidEvent', {
debug: 1,
origin: "http://localhost:3000",
})
Cal("init", "floatingButton", {
debug: 1,
origin: "http://localhost:3000",
});
Cal('init', 'routingFormAuto', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'routingFormDark', {
debug: 1,
origin: "http://localhost:3000",
})
if (only === "all" || only == "ns:floatingButton") {
Cal.ns.floatingButton("floatingButton", {
calLink: "pro"
})
}
</script>
<script></script>
<script src="./playground.ts"></script>
</body>
</html>
+1
View File
@@ -23,6 +23,7 @@
"dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'",
"dev-real": "vite dev --port 3100",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src",
"lint:fix": "eslint --ext .ts,.js src --fix",
"embed-tests": "yarn playwright test --config=playwright/config/playwright.config.ts",
+321
View File
@@ -0,0 +1,321 @@
import type { GlobalCal, GlobalCalWithoutNs } from "./src/embed";
type A = GlobalCalWithoutNs;
const Cal = window.Cal as GlobalCal;
const callback = function (e) {
const detail = e.detail;
const namespace = detail.namespace || "default";
if (detail.type === "linkReady") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
} else if (detail.type === "linkFailed") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
}
document.querySelector(`#cal-booking-place-${namespace} .last-action`).innerHTML = JSON.stringify(e.detail);
};
const searchParams = new URL(document.URL).searchParams;
const only = window.only;
if (only === "all" || only === "ns:default") {
Cal("init", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal("inline", {
elementOrSelector: "#cal-booking-place-default .place",
calLink: "pro?case=1",
config: {
iframeAttrs: {
id: "cal-booking-place-default-iframe",
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
theme: "dark",
},
});
Cal("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:second") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "second", {
debug: true,
origin: "http://localhost:3000",
});
// Bulk API is supported - Keep all configuration at one place.
// Not able to type Bulk Api correctly when the first argument itself is an array.
Cal.ns.second([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2",
config: {
iframeAttrs: {
id: "cal-booking-place-second-iframe",
},
},
},
]);
Cal.ns.second("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:third") {
// Create a namespace "third". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "third", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.third(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min",
config: {
iframeAttrs: {
id: "cal-booking-place-third-iframe",
},
},
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
enabledDateButton: {
backgroundColor: "red",
},
disabledDateButton: {
backgroundColor: "green",
},
},
},
]
);
Cal.ns.third("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fourth") {
Cal("init", "fourth", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.fourth(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/seeded-team",
config: {
iframeAttrs: {
id: "cal-booking-place-fourth-iframe",
},
},
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
},
},
]
);
Cal.ns.fourth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fifth") {
Cal("init", "fifth", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.fifth([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-fifth .place",
calLink: "team/seeded-team/collective-seeded-team-event",
config: {
iframeAttrs: {
id: "cal-booking-place-fifth-iframe",
},
},
},
]);
Cal.ns.fifth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "prerender-test") {
Cal("init", "prerendertestLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.prerendertestLightTheme("preload", {
calLink: "free",
});
}
if (only === "all" || only === "inline-routing-form") {
Cal("init", "inline-routing-form", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns["inline-routing-form"]([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/948ae412-d995-4865-875a-48302588de03",
config: {
iframeAttrs: {
id: "cal-booking-place-inline-routing-form-iframe",
},
},
},
]);
}
if (only === "all" || only === "hideEventTypeDetails") {
const identifier = "hideEventTypeDetails";
Cal("init", identifier, {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.hideEventTypeDetails(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: `#cal-booking-place-${identifier} .place`,
calLink: "free/30min",
config: {
iframeAttrs: {
id: `cal-booking-place-${identifier}-iframe`,
},
},
},
],
[
"ui",
{
hideEventTypeDetails: true,
},
]
);
}
Cal("init", "popupDarkTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupHideEventTypeDetails", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.popupHideEventTypeDetails("ui", {
hideEventTypeDetails: true,
});
Cal("init", "popupReschedule", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupAutoTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinksList", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupPaidEvent", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "floatingButton", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "routingFormAuto", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "routingFormDark", {
debug: true,
origin: "http://localhost:3000",
});
if (only === "all" || only == "ns:floatingButton") {
Cal.ns.floatingButton("floatingButton", {
calLink: "pro",
});
}
@@ -1,5 +1,36 @@
import getFloatingButtonHtml from "./FloatingButtonHtml";
type ModalTargetDatasetProps = {
calLink: string;
calNamespace: string;
calOrigin: string;
};
type CamelCase<T extends string> = T extends `${infer U}${infer V}` ? `${Uppercase<U>}${V}` : T;
type HyphenatedStringToCamelCase<S extends string> = S extends `${infer T}-${infer U}`
? `${T}${HyphenatedStringToCamelCase<CamelCase<U>>}`
: CamelCase<S>;
type HyphenatedDataStringToCamelCase<S extends string> = S extends `data-${infer U}`
? HyphenatedStringToCamelCase<U>
: S;
const dataAttributes = [
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
] as const;
type DataAttributes = (typeof dataAttributes)[number];
type DataAttributesCamelCase = HyphenatedDataStringToCamelCase<DataAttributes>;
export type FloatingButtonDataset = {
[key in DataAttributesCamelCase]: string;
};
export class FloatingButton extends HTMLElement {
static updatedClassString(position: string, classString: string) {
return [
@@ -8,66 +39,69 @@ export class FloatingButton extends HTMLElement {
].join(" ");
}
// Button added here triggers the modal on click. So, it has to have the same data attributes as the modal target as well
dataset!: DOMStringMap & FloatingButtonDataset & ModalTargetDatasetProps;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() {
return [
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
];
return dataAttributes;
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
attributeChangedCallback(name: DataAttributes, oldValue: string, newValue: string) {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
const buttonWrapperEl = this.shadowRoot?.querySelector<HTMLElement>("button");
const buttonIconEl = this.shadowRoot?.querySelector<HTMLElement>("#button-icon");
if (!buttonEl) {
throw new Error("#button not found");
}
if (!buttonWrapperEl) {
throw new Error("button element not found");
}
if (!buttonIconEl) {
throw new Error("#button-icon not found");
}
if (name === "data-button-text") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.textContent = newValue;
} else if (name === "data-hide-button-icon") {
const buttonIconEl = this.shadowRoot?.querySelector<HTMLElement>("#button-icon");
if (!buttonIconEl) {
throw new Error("Button not found");
}
buttonIconEl.style.display = newValue == "true" ? "none" : "block";
} else if (name === "data-button-position") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className);
buttonWrapperEl.className = FloatingButton.updatedClassString(newValue, buttonWrapperEl.className);
} else if (name === "data-button-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.backgroundColor = newValue;
buttonWrapperEl.style.backgroundColor = newValue;
} else if (name === "data-button-text-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.color = newValue;
buttonWrapperEl.style.color = newValue;
} else {
console.log("Unknown attribute changed", name, oldValue, newValue);
}
}
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
constructor() {
super();
const buttonText = this.dataset["buttonText"];
const buttonPosition = this.dataset["buttonPosition"];
const buttonColor = this.dataset["buttonColor"];
const buttonTextColor = this.dataset["buttonTextColor"];
const dataset = this.dataset as FloatingButtonDataset;
const buttonText = dataset["buttonText"];
const buttonPosition = dataset["buttonPosition"];
const buttonColor = dataset["buttonColor"];
const buttonTextColor = dataset["buttonTextColor"];
//TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place
const buttonHtml = `<style>${window.Cal!.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText!,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")],
buttonColor: buttonColor!,
buttonTextColor: buttonTextColor!,
const buttonHtml = `<style>${window.Cal.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition, "")],
buttonColor: buttonColor,
buttonTextColor: buttonTextColor,
})}`;
this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = buttonHtml;
this.assertHasShadowRoot();
this.shadowRoot.innerHTML = buttonHtml;
}
}
+22 -11
View File
@@ -1,31 +1,42 @@
import loaderCss from "../loader.css";
import loaderCss from "../loader.css?inline";
import { getErrorString } from "../utils";
import inlineHtml from "./inlineHtml";
export class Inline extends HTMLElement {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() {
return ["loading"];
}
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
this.assertHasShadowRoot();
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
const errorEl = this.shadowRoot.querySelector<HTMLElement>("#error");
const slotEl = this.shadowRoot.querySelector<HTMLElement>("slot");
if (!loaderEl || !slotEl || !errorEl) {
throw new Error("One of loaderEl, slotEl or errorEl is missing");
}
if (name === "loading") {
if (newValue == "done") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none";
loaderEl.style.display = "none";
} else if (newValue === "failed") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none";
this.shadowRoot!.querySelector<HTMLElement>("#error")!.style.display = "block";
this.shadowRoot!.querySelector<HTMLElement>("slot")!.style.visibility = "hidden";
loaderEl.style.display = "none";
slotEl.style.visibility = "hidden";
errorEl.style.display = "block";
const errorString = getErrorString(this.dataset.errorCode);
this.shadowRoot!.querySelector<HTMLElement>("#error")!.innerText = errorString;
errorEl.innerText = errorString;
}
}
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = `<style>${
window.Cal!.__css
}</style><style>${loaderCss}</style>${inlineHtml}`;
this.assertHasShadowRoot();
this.shadowRoot.innerHTML = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml}`;
}
}
@@ -101,7 +101,7 @@ export class ModalBox extends HTMLElement {
constructor() {
super();
const modalHtml = `<style>${window.Cal!.__css}</style><style>${loaderCss}</style>${modalBoxHtml}`;
const modalHtml = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml}`;
this.attachShadow({ mode: "open" });
ModalBox.htmlOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
+106 -84
View File
@@ -2,18 +2,40 @@ import { useRouter } from "next/router";
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event";
export interface UiConfig {
export type UiConfig = {
hideEventTypeDetails?: boolean;
theme?: "dark" | "light" | "auto";
styles?: EmbedStyles;
}
styles?: EmbedStyles & EmbedNonStylesConfig;
};
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {} as EmbedStyles | undefined,
nonStyles: {} as EmbedNonStylesConfig | undefined,
namespace: null as string | null,
embedType: undefined as undefined | null | string,
// Store all React State setters here.
reactStylesStateSetters: {} as Record<keyof EmbedStyles, SetStyles>,
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
setTheme: undefined as ((arg0: string) => void) | undefined,
theme: undefined as UiConfig["theme"],
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
setUiConfig: undefined as ((arg0: UiConfig) => void) | undefined,
};
declare global {
interface Window {
CalEmbed: {
__logQueue?: any[];
embedStore: any;
__logQueue?: unknown[];
embedStore: typeof embedStore;
};
CalComPageStatus: string;
isEmbed: () => boolean;
@@ -23,28 +45,6 @@ declare global {
}
}
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {},
namespace: null,
embedType: undefined,
// Store all React State setters here.
reactStylesStateSetters: {},
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
} as {
styles: UiConfig["styles"];
namespace: string | null;
embedType: undefined | null | string;
reactStylesStateSetters: any;
parentInformedAboutContentHeight: boolean;
windowLoadEventFired: boolean;
theme?: UiConfig["theme"];
uiConfig?: Omit<UiConfig, "styles" | "theme">;
setTheme?: (arg0: string) => void;
setUiConfig?: (arg0: UiConfig) => void;
};
let isSafariBrowser = false;
const isBrowser = typeof window !== "undefined";
@@ -58,7 +58,7 @@ if (isBrowser) {
}
}
function runAsap(fn: (...arg: any) => void) {
function runAsap(fn: (...arg: unknown[]) => void) {
if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50);
@@ -66,7 +66,7 @@ function runAsap(fn: (...arg: any) => void) {
return requestAnimationFrame(fn);
}
function log(...args: any[]) {
function log(...args: unknown[]) {
if (isBrowser) {
const namespace = getNamespace();
@@ -95,7 +95,7 @@ interface EmbedStyles {
}
interface EmbedNonStylesConfig {
/** Default would be center */
align: "left";
align?: "left";
branding?: {
brandColor?: string;
lightColor?: string;
@@ -108,10 +108,10 @@ interface EmbedNonStylesConfig {
};
}
const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
const setEmbedStyles = (stylesConfig: EmbedStyles) => {
embedStore.styles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
(setEmbedStyle as any)((styles: any) => {
setEmbedStyle((styles) => {
return {
...styles,
...stylesConfig,
@@ -120,15 +120,48 @@ const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
}
};
const registerNewSetter = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig, setStyles: any) => {
embedStore.reactStylesStateSetters[elementName] = setStyles;
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
setStyles(embedStore.styles);
const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
embedStore.nonStyles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
setEmbedStyle((styles) => {
return {
...styles,
...stylesConfig,
};
});
}
};
const removeFromEmbedStylesSetterMap = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig) => {
delete embedStore.reactStylesStateSetters[elementName];
const registerNewSetter = (
registration:
| {
elementName: keyof EmbedStyles;
setState: SetStyles;
styles: true;
}
| {
elementName: keyof EmbedNonStylesConfig;
setState: setNonStylesConfig;
styles: false;
}
) => {
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
if (registration.styles) {
embedStore.reactStylesStateSetters[registration.elementName as keyof EmbedStyles] = registration.setState;
registration.setState(embedStore.styles || {});
return () => {
delete embedStore.reactStylesStateSetters[registration.elementName];
};
} else {
embedStore.reactNonStylesStateSetters[registration.elementName as keyof EmbedNonStylesConfig] =
registration.setState;
registration.setState(embedStore.nonStyles || {});
return () => {
delete embedStore.reactNonStylesStateSetters[registration.elementName];
};
}
};
function isValidNamespace(ns: string | null | undefined) {
@@ -155,33 +188,28 @@ export const useEmbedUiConfig = () => {
// TODO: Make it usable as an attribute directly instead of styles value. It would allow us to go beyond styles e.g. for debugging we can add a special attribute indentifying the element on which UI config has been applied
export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [styles, setStyles] = useState({} as EmbedStyles);
const [, setStyles] = useState<EmbedStyles>({});
useEffect(() => {
registerNewSetter(elementName, setStyles);
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
return registerNewSetter({ elementName, setState: setStyles, styles: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used.
return styles[elementName] || {};
};
export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => {
const [styles, setStyles] = useState({} as EmbedNonStylesConfig);
const [, setNonStyles] = useState({} as EmbedNonStylesConfig);
useEffect(() => {
registerNewSetter(elementName, setStyles);
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
return registerNewSetter({ elementName, setState: setNonStyles, styles: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return styles[elementName] || {};
// Always read the data from global embedStore so that even across components, the same data is used.
const nonStyles = embedStore.nonStyles || {};
return nonStyles[elementName] || {};
};
export const useIsBackgroundTransparent = () => {
@@ -251,8 +279,8 @@ function unhideBody() {
document.body.style.visibility = "visible";
}
// If you add a method here, give type safety to parent manually by adding it to embed.ts. Look for "parentKnowsIframeReady" in it
export const methods = {
// It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
const methods = {
ui: function style(uiConfig: UiConfig) {
// TODO: Create automatic logger for all methods. Useful for debugging.
log("Method: ui called", uiConfig);
@@ -277,8 +305,10 @@ export const methods = {
}
setEmbedStyles(stylesConfig || {});
setEmbedNonStyles(stylesConfig || {});
},
parentKnowsIframeReady: () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parentKnowsIframeReady: (_unused: unknown) => {
log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight
@@ -293,7 +323,14 @@ export const methods = {
},
};
const messageParent = (data: any) => {
export type InterfaceWithParent = {
// Ensure that only one argument is read by the method
[key in keyof typeof methods]: (firstAndOnlyArg: Parameters<(typeof methods)[key]>[number]) => void;
};
export const interfaceWithParent: InterfaceWithParent = methods;
const messageParent = (data: CustomEvent["detail"]) => {
parent.postMessage(
{
originator: "CAL",
@@ -344,13 +381,15 @@ function keepParentInformedAboutDimensionChanges() {
// Use, .height as that gives more accurate value in floating point. Also, do a ceil on the total sum so that whatever happens there is enough iframe size to avoid scroll.
const contentHeight = Math.ceil(
parseFloat(mainElementStyles.height) +
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom));
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom)
);
const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight));
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)
);
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe get's bigger in height, it would never shrink
@@ -394,35 +433,18 @@ if (isBrowser) {
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
//console.log(detail.fullType, detail.type, detail.data);
log(detail);
messageParent(detail);
});
// This event should be fired whenever you want to let the content take automatic width which is available.
// Because on cal-iframe we set explicty width to make it look inline and part of page, there is never space available for content to automatically expand
// This is a HACK to quickly tell iframe to go full width and let iframe content adapt to that and set new width.
sdkActionManager?.on("__refreshWidth", () => {
// sdkActionManager?.fire("__dimensionChanged", {
// iframeWidth: 100,
// __unit: "%",
// });
// runAsap(() => {
// sdkActionManager?.fire("__dimensionChanged", {
// iframeWidth: 100,
// __unit: "%",
// });
// });
});
window.addEventListener("message", (e) => {
const data: Record<string, any> = e.data;
const data: Message = e.data;
if (!data) {
return;
}
const method: keyof typeof methods = data.method;
const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") {
methods[method]?.(data.arg);
interfaceWithParent[method]?.(data.arg as never);
}
});
+366 -287
View File
@@ -1,11 +1,20 @@
/// <reference types="../env" />
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import type { methods, UiConfig } from "./embed-iframe";
import type { InterfaceWithParent, interfaceWithParent, UiConfig } from "./embed-iframe";
import css from "./embed.css";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import { SdkActionManager } from "./sdk-action-manager";
import allCss from "./tailwind.generated.css";
import allCss from "./tailwind.generated.css?inline";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
export type Message = {
originator: string;
method: keyof InterfaceWithParent;
arg: InterfaceWithParent[keyof InterfaceWithParent];
};
// HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file.
// This is happening because we are not able to generate an App and a lib using single Vite Config.
const WEBAPP_URL =
@@ -18,10 +27,20 @@ customElements.define("cal-inline", Inline);
declare module "*.css";
type Namespace = string;
type Config = {
origin: string;
calOrigin: string;
debug?: boolean;
uiDebug?: boolean;
};
type InitArgConfig = Partial<Config> & {
origin?: string;
};
type DoInIframeArg = {
[K in keyof typeof interfaceWithParent]: {
method: K;
arg?: Parameters<(typeof interfaceWithParent)[K]>[0];
};
}[keyof typeof interfaceWithParent];
const globalCal = window.Cal;
if (!globalCal || !globalCal.q) {
@@ -35,9 +54,22 @@ globalCal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as strin
globalCal.__css = allCss;
document.head.appendChild(document.createElement("style")).innerHTML = css;
function log(...args: any[]) {
function log(...args: unknown[]) {
console.log(...args);
}
// eslint-disable-next-line @typescript-eslint/ban-types
type ValidationSchemaPropType = string | Function;
type ValidationSchema = {
required?: boolean;
props?: Record<
string,
ValidationSchema & {
type: ValidationSchemaPropType | ValidationSchemaPropType[];
}
>;
};
/**
* //TODO: Warn about extra properties not part of schema. Helps in fixing wrong expectations
* A very simple data validator written with intention of keeping payload size low.
@@ -45,8 +77,8 @@ function log(...args: any[]) {
* @param data
* @param schema
*/
function validate(data: any, schema: Record<"props" | "required", any>) {
function checkType(value: any, expectedType: any) {
function validate(data: Record<string, unknown>, schema: ValidationSchema) {
function checkType(value: unknown, expectedType: ValidationSchemaPropType) {
if (typeof expectedType === "string") {
return typeof value == expectedType;
} else {
@@ -54,7 +86,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
}
}
function isUndefined(data: any) {
function isUndefined(data: unknown) {
return typeof data === "undefined";
}
@@ -62,7 +94,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
throw new Error("Argument is required");
}
for (const [prop, propSchema] of Object.entries<Record<"type" | "required", any>>(schema.props)) {
for (const [prop, propSchema] of Object.entries(schema.props || {})) {
if (propSchema.required && isUndefined(data[prop])) {
throw new Error(`"${prop}" is required`);
}
@@ -82,9 +114,23 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
}
}
export type Instruction = [method: string, argument: any] | [method: string, argument: any][];
type SingleInstructionMap = {
// TODO: This makes api("on", {}) loose it's generic type. Find a way to fix it.
// e.g. api("on", { action: "__dimensionChanged", callback: (e) => { /* `e.detail.data` has all possible values for all events/actions */} });
[K in keyof CalApi]: CalApi[K] extends (...args: never[]) => void ? [K, ...Parameters<CalApi[K]>] : never;
};
type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
export type Instruction = SingleInstruction | SingleInstruction[];
export type InstructionQueue = Instruction[];
type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
iframeAttrs?: Record<string, string> & {
id?: string;
};
};
export class Cal {
iframe?: HTMLIFrameElement;
@@ -100,35 +146,42 @@ export class Cal {
iframeReady!: boolean;
iframeDoQueue: { method: keyof typeof methods; arg: any }[] = [];
iframeDoQueue: DoInIframeArg[] = [];
api: CalApi;
static actionsManagers: Record<Namespace, SdkActionManager>;
static getQueryObject(config: Record<string, string>) {
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
config = config || {};
return {
...config,
// guests is better for API but Booking Page accepts guest. So do the mapping
guest: config.guests ?? undefined,
};
} as PrefillAndIframeAttrsConfig & { guest?: string | string[] };
}
processInstruction(instruction: Instruction) {
instruction = [].slice.call(instruction, 0);
if (instruction[0] instanceof Array) {
processInstruction(instructionAsArgs: IArguments | Instruction) {
// The instruction is actually an array-like object(arguments). Make it an array.
const instruction: Instruction = [].slice.call(instructionAsArgs);
// If there are multiple instructions in the array, process them one by one
if (typeof instruction[0] !== "string") {
// It is an instruction
instruction.forEach((instruction) => {
this.processInstruction(instruction);
});
return;
}
const [method, ...args] = instruction;
if (!this[method]) {
if (!this.api[method]) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction ${method} not FOUND`);
}
try {
(this[method] as (...args: any[]) => void)(...args);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore There can be any method which can have any number of arguments.
this.api[method](...args);
} catch (e) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction couldn't be executed`, e);
@@ -136,7 +189,7 @@ export class Cal {
return instruction;
}
processQueue(queue: InstructionQueue) {
processQueue(queue: IArguments[]) {
queue.forEach((instruction) => {
this.processInstruction(instruction);
});
@@ -153,9 +206,11 @@ export class Cal {
createIframe({
calLink,
queryObject = {},
calOrigin,
}: {
calLink: string;
queryObject?: Record<string, string | string[] | Record<string, string>>;
queryObject?: PrefillAndIframeAttrsConfig & { guest?: string | string[] };
calOrigin?: string;
}) {
const iframe = (this.iframe = document.createElement("iframe"));
iframe.className = "cal-embed";
@@ -163,7 +218,7 @@ export class Cal {
const config = this.getConfig();
const { iframeAttrs, ...restQueryObject } = queryObject;
if (iframeAttrs && typeof iframeAttrs !== "string" && !(iframeAttrs instanceof Array)) {
if (iframeAttrs && iframeAttrs.id) {
iframe.setAttribute("id", iframeAttrs.id);
}
@@ -180,7 +235,7 @@ export class Cal {
}
}
const urlInstance = new URL(`${config.origin}/${calLink}`);
const urlInstance = new URL(`${calOrigin || config.calOrigin}/${calLink}`);
if (!urlInstance.pathname.endsWith("embed")) {
// TODO: Make a list of patterns that are embeddable. All except that should be allowed with a warning that "The page isn't optimized for embedding"
urlInstance.pathname = `${urlInstance.pathname}/embed`;
@@ -203,252 +258,34 @@ export class Cal {
return iframe;
}
init(namespaceOrConfig?: string | Config, config: Config = {} as Config) {
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as Config;
}
const { origin, ...restConfig } = config;
if (origin) {
this.__config.origin = origin;
}
this.__config = { ...this.__config, ...restConfig };
}
getConfig() {
return this.__config;
}
// TODO: Maintain exposed methods in a separate namespace, so that unexpected methods don't become instructions
/**
* It is an instruction that adds embed iframe inline as last child of the element
*/
inline({
calLink,
elementOrSelector,
config,
}: {
calLink: string;
elementOrSelector: string | HTMLElement;
config: Record<string, string>;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
// TODO: Add a special type calLink for it and validate that it doesn't start with / or https?://
required: true,
type: "string",
},
elementOrSelector: {
required: true,
type: ["string", HTMLElement],
},
config: {
required: false,
type: Object,
},
},
});
config = config || {};
// Keeping auto-scroll disabled for two reasons:
// - If user scrolls the content to an appropriate position, it again resets it to default position which might not be for the liking of the user
// - Sometimes, the position can be wrong(e.g. if there is a fixed position header on top coming above the iframe content).
// Best solution might be to autoscroll only if the iframe is not fully visible, detection of full visibility might be tough
// We need to keep in mind that autoscroll is meant to solve the problem when on a certain view(which is availability page right now), the height goes too high and then suddenly it becomes normal
(config as unknown as any).__autoScroll = !!(config as unknown as any).__autoScroll;
config.embedType = "inline";
const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
iframe.style.height = "100%";
iframe.style.width = "100%";
const element =
elementOrSelector instanceof HTMLElement
? elementOrSelector
: document.querySelector(elementOrSelector);
if (!element) {
throw new Error("Element not found");
}
element.classList.add("cal-inline-container");
const template = document.createElement("template");
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%"></cal-inline><style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
this.inlineEl = template.content.children[0];
(this.inlineEl as unknown as any).__CalAutoScroll = config.__autoScroll;
this.inlineEl.appendChild(iframe);
element.appendChild(template.content);
}
floatingButton({
calLink,
buttonText = "Book my Cal",
hideButtonIcon = false,
attributes,
buttonPosition = "bottom-right",
buttonColor = "rgb(0, 0, 0)",
buttonTextColor = "rgb(255, 255, 255)",
}: {
calLink: string;
buttonText?: string;
attributes?: Record<string, string>;
hideButtonIcon?: boolean;
buttonPosition?: "bottom-left" | "bottom-right";
buttonColor: string;
buttonTextColor: string;
}) {
// validate(arguments[0], {
// required: true,
// props: {
// calLink: {
// required: true,
// type: "string",
// },
// },
// });
let existingEl: HTMLElement | null = null;
if (attributes?.id) {
existingEl = document.getElementById(attributes.id);
}
let el: HTMLElement;
if (!existingEl) {
el = document.createElement("cal-floating-button");
el.dataset.calLink = calLink;
el.dataset.calNamespace = this.namespace;
if (attributes?.id) {
el.id = attributes.id;
}
document.body.appendChild(el);
} else {
el = existingEl;
}
if (buttonText) {
el.setAttribute("data-button-text", buttonText);
}
el.setAttribute("data-hide-button-icon", "" + hideButtonIcon);
el.setAttribute("data-button-position", "" + buttonPosition);
el.setAttribute("data-button-color", "" + buttonColor);
el.setAttribute("data-button-text-color", "" + buttonTextColor);
}
modal({ calLink, config = {}, uid }: { calLink: string; config?: Record<string, string>; uid: number }) {
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
existingModalEl.setAttribute("state", "started");
return;
}
config.embedType = "modal";
const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
iframe.style.borderRadius = "8px";
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
this.modalBox = template.content.children[0];
this.modalBox.appendChild(iframe);
this.actionManager.on("__closeIframe", () => {
this.modalBox.setAttribute("state", "closed");
});
document.body.appendChild(template.content);
}
on({
action,
callback,
}: {
action: Parameters<SdkActionManager["on"]>[0];
callback: Parameters<SdkActionManager["on"]>[1];
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
action: {
required: true,
type: "string",
},
callback: {
required: true,
type: Function,
},
},
});
this.actionManager.on(action, callback);
}
off({
action,
callback,
}: {
action: Parameters<SdkActionManager["on"]>[0];
callback: Parameters<SdkActionManager["on"]>[1];
}) {
this.actionManager.off(action, callback);
}
preload({ calLink }: { calLink: string }) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
type: "string",
required: true,
},
},
});
const iframe = document.body.appendChild(document.createElement("iframe"));
const config = this.getConfig();
const urlInstance = new URL(`${config.origin}/${calLink}`);
urlInstance.searchParams.set("prerender", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
}
ui(uiConfig: UiConfig) {
validate(uiConfig, {
required: true,
props: {
theme: {
required: false,
type: "string",
},
styles: {
required: false,
type: Object,
},
},
});
this.doInIframe({ method: "ui", arg: uiConfig });
}
doInIframe({
method,
arg,
}: // TODO: Need some TypeScript magic here to remove hardcoded types
| { method: "ui"; arg: Parameters<(typeof methods)["ui"]>[0] }
| { method: "parentKnowsIframeReady"; arg: undefined }) {
doInIframe(doInIframeArg: DoInIframeArg) {
if (!this.iframeReady) {
this.iframeDoQueue.push({ method, arg });
this.iframeDoQueue.push(doInIframeArg);
return;
}
// TODO: Ensure that origin is as defined by user. Generally it would be cal.com but in case of self hosting it can be anything.
this.iframe!.contentWindow!.postMessage({ originator: "CAL", method, arg }, "*");
if (!this.iframe) {
throw new Error("iframe doesn't exist. `createIframe` must be called before `doInIframe`");
}
if (this.iframe.contentWindow) {
// TODO: Ensure that targetOrigin is as defined by user(and not *). Generally it would be cal.com but in case of self hosting it can be anything.
// Maybe we can derive targetOrigin from __config.origin
this.iframe.contentWindow.postMessage(
{ originator: "CAL", method: doInIframeArg.method, arg: doInIframeArg.arg },
"*"
);
}
}
constructor(namespace: string, q: InstructionQueue) {
constructor(namespace: string, q: IArguments[]) {
this.__config = {
// Use WEBAPP_URL till full page reload problem with website URL is solved
origin: WEBAPP_URL,
calOrigin: WEBAPP_URL,
};
this.api = new CalApi(this);
this.namespace = namespace;
this.actionManager = new SdkActionManager(namespace);
@@ -462,24 +299,17 @@ export class Cal {
// 3. Parent window would check what iframe height can be set according to parent Element
this.actionManager.on("__dimensionChanged", (e) => {
const { data } = e.detail;
const iframe = this.iframe!;
const iframe = this.iframe;
if (!iframe) {
// Iframe might be pre-rendering
return;
}
let unit = "px";
if (data.__unit) {
unit = data.__unit;
}
const unit = "px";
if (data.iframeHeight) {
iframe.style.height = data.iframeHeight + unit;
}
// if (data.iframeWidth) {
// iframe.style.width = data.iframeWidth + unit;
// }
if (this.modalBox) {
// It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight
// This case is reproducible when viewing in ModalBox on Mobile.
@@ -490,9 +320,9 @@ export class Cal {
this.actionManager.on("__iframeReady", () => {
this.iframeReady = true;
this.doInIframe({ method: "parentKnowsIframeReady", arg: undefined });
this.iframeDoQueue.forEach(({ method, arg }) => {
this.doInIframe({ method, arg });
this.doInIframe({ method: "parentKnowsIframeReady" } as const);
this.iframeDoQueue.forEach((doInIframeArg) => {
this.doInIframe(doInIframeArg);
});
});
@@ -523,34 +353,276 @@ export class Cal {
}
}
export interface GlobalCal {
(methodName: string, arg?: any): void;
class CalApi {
cal: Cal;
constructor(cal: Cal) {
this.cal = cal;
}
init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) {
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as Config;
}
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
this.cal.__config = { ...this.cal.__config, ...restConfig };
}
/**
* It is an instruction that adds embed iframe inline as last child of the element
*/
inline({
calLink,
elementOrSelector,
config,
}: {
calLink: string;
elementOrSelector: string | HTMLElement;
config?: PrefillAndIframeAttrsConfig;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
// TODO: Add a special type calLink for it and validate that it doesn't start with / or https?://
required: true,
type: "string",
},
elementOrSelector: {
required: true,
type: ["string", HTMLElement],
},
config: {
required: false,
type: Object,
},
},
});
config = config || {};
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
config.embedType = "inline";
const iframe = this.cal.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
iframe.style.height = "100%";
iframe.style.width = "100%";
const element =
elementOrSelector instanceof HTMLElement
? elementOrSelector
: document.querySelector(elementOrSelector);
if (!element) {
throw new Error("Element not found");
}
element.classList.add("cal-inline-container");
const template = document.createElement("template");
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%"></cal-inline><style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
this.cal.inlineEl = template.content.children[0];
this.cal.inlineEl.appendChild(iframe);
element.appendChild(template.content);
}
floatingButton({
calLink,
buttonText = "Book my Cal",
hideButtonIcon = false,
attributes,
buttonPosition = "bottom-right",
buttonColor = "rgb(0, 0, 0)",
buttonTextColor = "rgb(255, 255, 255)",
calOrigin,
}: {
calLink: string;
buttonText?: string;
attributes?: Record<"id", string> & Record<string | "id", string>;
hideButtonIcon?: boolean;
buttonPosition?: "bottom-left" | "bottom-right";
buttonColor?: string;
buttonTextColor?: string;
calOrigin?: string;
}) {
// validate(arguments[0], {
// required: true,
// props: {
// calLink: {
// required: true,
// type: "string",
// },
// },
// });
let existingEl: HTMLElement | null = null;
if (attributes?.id) {
existingEl = document.getElementById(attributes.id);
}
let el: FloatingButton;
if (!existingEl) {
el = document.createElement("cal-floating-button") as FloatingButton;
// It makes it a target element that opens up embed modal on click
el.dataset.calLink = calLink;
el.dataset.calNamespace = this.cal.namespace;
el.dataset.calOrigin = calOrigin ?? "";
if (attributes?.id) {
el.id = attributes.id;
}
document.body.appendChild(el);
} else {
el = existingEl as FloatingButton;
}
const dataset = el.dataset;
dataset["buttonText"] = buttonText;
dataset["hideButtonIcon"] = "" + hideButtonIcon;
dataset["buttonPosition"] = "" + buttonPosition;
dataset["buttonColor"] = "" + buttonColor;
dataset["buttonTextColor"] = "" + buttonTextColor;
}
modal({
calLink,
calOrigin,
config = {},
uid,
}: {
calLink: string;
config?: PrefillAndIframeAttrsConfig;
uid?: string | number;
calOrigin?: string;
}) {
uid = uid || 0;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
existingModalEl.setAttribute("state", "started");
return;
}
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
config.embedType = "modal";
const iframe = this.cal.createIframe({ calLink, calOrigin, queryObject: Cal.getQueryObject(config) });
iframe.style.borderRadius = "8px";
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
this.cal.actionManager.on("__closeIframe", () => {
this.cal.modalBox.setAttribute("state", "closed");
});
document.body.appendChild(template.content);
}
on<T extends keyof EventDataMap>({
action,
callback,
}: {
action: T;
callback: (arg0: CustomEvent<EventData<T>>) => void;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
action: {
required: true,
type: "string",
},
callback: {
required: true,
type: Function,
},
},
});
this.cal.actionManager.on(action, callback);
}
off({ action, callback }: { action: never; callback: never }) {
this.cal.actionManager.off(action, callback);
}
preload({ calLink }: { calLink: string }) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
type: "string",
required: true,
},
},
});
const iframe = document.body.appendChild(document.createElement("iframe"));
const config = this.cal.getConfig();
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
urlInstance.searchParams.set("prerender", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
}
ui(uiConfig: UiConfig) {
validate(uiConfig, {
required: true,
props: {
theme: {
required: false,
type: "string",
},
styles: {
required: false,
type: Object,
},
},
});
this.cal.doInIframe({ method: "ui", arg: uiConfig });
}
}
// This is a full fledged Cal instance but doesn't have ns property because it would be nested inside an ns instance already
export interface GlobalCalWithoutNs {
<T extends keyof SingleInstructionMap>(methodName: T, ...arg: Rest<SingleInstructionMap[T]>): void;
/** Marks that the embed.js is loaded. Avoids re-downloading it. */
loaded?: boolean;
/** Maintains a queue till the time embed.js isn't loaded */
q?: InstructionQueue;
q: IArguments[];
/** If user registers multiple namespaces, those are available here */
ns?: Record<string, GlobalCal>;
instance?: Cal;
__css?: string;
fingerprint?: string;
__logQueue?: any[];
__logQueue?: unknown[];
}
// Well Omit removes the Function Signature from a type if used. So, instead construct the types like this.
type GlobalCalWithNs = GlobalCalWithoutNs & {
ns: Record<string, GlobalCalWithoutNs>;
};
export type GlobalCal = GlobalCalWithNs;
declare global {
interface Window {
Cal?: GlobalCal;
Cal: GlobalCal;
}
}
export interface CalWindow extends Window {
Cal?: GlobalCal;
Cal: GlobalCal;
}
globalCal.instance = new Cal("", globalCal.q!);
for (const [ns, api] of Object.entries(globalCal.ns!)) {
api.instance = new Cal(ns, api.q!);
globalCal.instance = new Cal("", globalCal.q);
for (const [ns, api] of Object.entries(globalCal.ns)) {
api.instance = new Cal(ns, api.q);
}
/**
@@ -571,38 +643,45 @@ window.addEventListener("message", (e) => {
if (!actionManager) {
throw new Error("Unhandled Action" + parsedAction);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
actionManager.fire(parsedAction.type, detail.data);
});
document.addEventListener("click", (e) => {
const htmlElement = e.target;
if (!(htmlElement instanceof HTMLElement)) {
const targetEl = e.target;
if (!(targetEl instanceof HTMLElement)) {
return;
}
const path = htmlElement.dataset.calLink;
const path = targetEl.dataset.calLink;
if (!path) {
return;
}
const modalUniqueId = ((htmlElement as unknown as any).uniqueId =
(htmlElement as unknown as any).uniqueId || Date.now());
const namespace = htmlElement.dataset.calNamespace;
const configString = htmlElement.dataset.calConfig || "";
const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
const namespace = targetEl.dataset.calNamespace;
const configString = targetEl.dataset.calConfig || "";
const calOrigin = targetEl.dataset.calOrigin || "";
let config;
try {
config = JSON.parse(configString);
} catch (e) {
config = {};
}
let api = globalCal;
let api: GlobalCalWithoutNs = globalCal;
if (namespace) {
api = globalCal.ns![namespace];
api = globalCal.ns[namespace];
}
if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`);
}
api("modal", {
calLink: path,
config,
uid: modalUniqueId,
calOrigin,
});
});
+16 -10
View File
@@ -2,10 +2,9 @@ const WEBAPP_URL =
import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_URL}`;
const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
(window as any).fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
// Install Cal Embed Code Snippet
(function (C, A, L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const p = function (a: any, ar: any) {
a.q.push(ar);
};
@@ -13,7 +12,7 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
C.Cal =
C.Cal ||
function () {
const cal = C.Cal!;
const cal = C.Cal;
// eslint-disable-next-line prefer-rest-params
const ar = arguments;
if (!cal.loaded) {
@@ -23,13 +22,16 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
cal.loaded = true;
}
if (ar[0] === L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api: { (): void; q?: any[] } = function () {
// eslint-disable-next-line prefer-rest-params
p(api, arguments);
};
const namespace = ar[1];
api!.q = api.q || [];
typeof namespace === "string" ? (cal!.ns![namespace] = api) && p(api, ar) : p(cal, ar);
api.q = api.q || [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
p(cal, ar);
@@ -37,20 +39,24 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
})(window, EMBED_LIB_URL, "init");
const previewWindow = window;
previewWindow.Cal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
previewWindow.Cal!("init", {
previewWindow.Cal("init", {
origin: WEBAPP_URL,
});
const searchParams = new URL(document.URL).searchParams;
const embedType = searchParams.get("embedType");
const calLink = searchParams.get("calLink");
if (embedType! === "inline") {
previewWindow.Cal!("inline", {
if (!calLink) {
throw new Error('Missing "calLink" query parameter');
}
if (embedType === "inline") {
previewWindow.Cal("inline", {
elementOrSelector: "#my-embed",
calLink: calLink,
});
} else if (embedType === "floating-popup") {
previewWindow.Cal!("floatingButton", {
previewWindow.Cal("floatingButton", {
calLink: calLink,
attributes: {
id: "my-floating-button",
@@ -58,7 +64,7 @@ if (embedType! === "inline") {
});
} else if (embedType === "element-click") {
const button = document.createElement("button");
button.setAttribute("data-cal-link", calLink!);
button.setAttribute("data-cal-link", calLink);
button.innerHTML = "I am a button that exists on your website";
document.body.appendChild(button);
}
@@ -1,5 +1,5 @@
type Namespace = string;
type CustomEventDetail = Record<string, any>;
type CustomEventDetail = Record<string, unknown>;
function _fireEvent(fullName: string, detail: CustomEventDetail) {
const event = new window.CustomEvent(fullName, {
@@ -9,6 +9,53 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
window.dispatchEvent(event);
}
export type EventDataMap = {
eventTypeSelected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventType: any;
};
linkFailed: {
code: string;
msg: string;
data: {
url: string;
};
};
linkReady: Record<string, never>;
bookingSuccessful: {
// TODO: Shouldn't send the entire booking and eventType objects, we should send specific fields from them.
booking: unknown;
eventType: unknown;
date: string;
duration: number | undefined;
organizer: {
name: string;
email: string;
timeZone: string;
};
confirmed: boolean;
};
"*": Record<string, unknown>;
__routeChanged: Record<string, never>;
__windowLoadComplete: Record<string, never>;
__closeIframe: Record<string, never>;
__iframeReady: Record<string, never>;
__dimensionChanged: {
iframeHeight: number;
iframeWidth: number;
isFirstTime: boolean;
};
};
export type EventData<T extends keyof EventDataMap> = {
[K in T]: {
type: string;
namespace: string;
fullType: string;
data: EventDataMap[K];
};
}[T];
export class SdkActionManager {
namespace: Namespace;
@@ -31,7 +78,7 @@ export class SdkActionManager {
return this.namespace ? `CAL:${this.namespace}:${name}` : `CAL::${name}`;
}
fire(name: string, data: CustomEventDetail) {
fire<T extends keyof EventDataMap>(name: T, data: EventDataMap[T]) {
const fullName = this.getFullActionName(name);
const detail = {
type: name,
@@ -46,12 +93,12 @@ export class SdkActionManager {
_fireEvent(this.getFullActionName("*"), detail);
}
on(name: string, callback: (arg0: CustomEvent<CustomEventDetail>) => void) {
on<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
const fullName = this.getFullActionName(name);
window.addEventListener(fullName, callback as EventListener);
}
off(name: string, callback: (arg0: CustomEvent<CustomEventDetail>) => void) {
off<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
const fullName = this.getFullActionName(name);
window.removeEventListener(fullName, callback as EventListener);
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"target": "ES2015",
"module": "esnext",
"paths": {
"@lib/*": ["../../../apps/web/lib/*"]
+10
View File
@@ -0,0 +1,10 @@
<html>
<head>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script type="module" src="./floating.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
/* First make sure that you have installed the package */
/* If you are using yarn */
// yarn add @calcom/embed-react
/* If you are using npm */
// npm install @calcom/embed-react
import { useEffect } from "react";
import * as React from "react";
import ReactDom from "react-dom";
import { getCalApi } from "./src/index";
function App() {
useEffect(() => {
(async function () {
const cal = await getCalApi("http://localhost:3000/embed/embed.js");
cal("floatingButton", { calLink: "teampro/abc", calOrigin: "http://localhost:3000" });
cal("ui", { styles: { branding: { brandColor: "#000000" } }, hideEventTypeDetails: false });
})();
}, []);
return null;
}
ReactDom.render(<App />, document.getElementById("root"));

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