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
197 changed files with 5213 additions and 1610 deletions

No files matched your search

+6 -6
View File
@@ -12,13 +12,13 @@ Fixes # (issue)
## Type of change ## 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) - Bug fix (non-breaking change which fixes an issue)
- [ ] Chore (refactoring code, technical debt, workflow improvements) - Chore (refactoring code, technical debt, workflow improvements)
- [ ] New feature (non-breaking change which adds functionality) - New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update - This change requires a documentation update
## How should this be tested? ## 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: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-install
- name: Show info - name: Show info
run: node -e "console.log(require('v8').getHeapStatistics())" run: node -e "console.log(require('v8').getHeapStatistics())"
- name: Configure TSC problem matcher
- run: yarn type-check 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 name: Cron - downgradeUsers
on: on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch." # "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 # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule: schedule:
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru) # Runs “Every month at 1st (see https://crontab.guru)
- cron: "0,15,30,45 * * * *" - cron: "0 0 1 * *"
jobs: jobs:
cron-downgradeUsers: cron-downgradeUsers:
env: env:
-1
View File
@@ -352,7 +352,6 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
</picture> </picture>
</a> </a>
<!-- CONTRIBUTORS --> <!-- CONTRIBUTORS -->
### Contributors ### Contributors
+2 -1
View File
@@ -11,7 +11,8 @@
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json", "lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
"start": "PORT=4000 next start", "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.", "author": "Cal.com, Inc.",
"license": "MIT", "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); const height = getDimension(previewState.inline.height);
return code` return code`
import Cal, { getCalApi } from "@calcom/embed-react"; import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() { function MyComponent() {
useEffect(()=>{ useEffect(()=>{
(async function () { (async function () {
@@ -187,12 +187,12 @@ function MyComponent() {
}) => { }) => {
return code` return code`
import Cal, { getCalApi } from "@calcom/embed-react"; import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() { function MyComponent() {
useEffect(()=>{ useEffect(()=>{
(async function () { (async function () {
const cal = await getCalApi(); const cal = await getCalApi();
Cal("floatingButton", ${floatingButtonArg}); cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode} ${uiInstructionCode}
})(); })();
}, []) }, [])
@@ -201,7 +201,7 @@ function MyComponent() {
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => { "element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
return code` return code`
import Cal, { getCalApi } from "@calcom/embed-react"; import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() { function MyComponent() {
useEffect(()=>{ useEffect(()=>{
(async function () { (async function () {
+1 -1
View File
@@ -1,5 +1,5 @@
import { AdminRequired } from "components/ui/AdminRequired"; import { AdminRequired } from "components/ui/AdminRequired";
import noop from "lodash/noop"; import { noop } from "lodash";
import type { LinkProps } from "next/link"; import type { LinkProps } from "next/link";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -8,17 +8,20 @@ import { FiLink } from "@calcom/ui/components/icon";
import type { Props } from "./pages/AvailabilityPage"; import type { Props } from "./pages/AvailabilityPage";
const excludeNullValues = (value: unknown) => !!value;
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) { export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
const { t } = useLocale(); const { t } = useLocale();
return locations.length ? ( const renderLocations = locations.map((location, index) => {
<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); const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) { if (!eventLocationType) {
// It's possible that the location app got uninstalled // It's possible that the location app got uninstalled
return null; return null;
} }
if (eventLocationType.variable === "hostDefault") {
return null;
}
const translateAbleKeys = [ const translateAbleKeys = [
"attendee_in_person", "attendee_in_person",
@@ -29,7 +32,6 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
]; ];
const locationKey = z.string().default("").parse(locationKeyToString(location)); const locationKey = z.string().default("").parse(locationKeyToString(location));
const translatedLocation = location.type.startsWith("integrations:") const translatedLocation = location.type.startsWith("integrations:")
? eventLocationType.label ? eventLocationType.label
: translateAbleKeys.includes(locationKey) : translateAbleKeys.includes(locationKey)
@@ -55,7 +57,12 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
</Tooltip> </Tooltip>
</div> </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> </div>
) : null; ) : null;
} }
@@ -5,6 +5,8 @@ import { useState } from "react";
import type { EventLocationType } from "@calcom/app-store/locations"; import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType } from "@calcom/app-store/locations"; import { getEventLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs"; 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 ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns"; import { formatTime } from "@calcom/lib/date-fns";
@@ -77,7 +77,7 @@ export default function CancelBooking(props: Props) {
{t("nevermind")} {t("nevermind")}
</Button> </Button>
<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" data-testid="cancel"
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
+1 -4
View File
@@ -133,10 +133,7 @@ export const SlotPicker = ({
eventTypeId: eventType.id, eventTypeId: eventType.id,
eventTypeSlug: eventType.slug, eventTypeSlug: eventType.slug,
usernameList: users, usernameList: users,
startTime: startTime: dayjs().startOf("day"),
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
: browsingDate?.startOf("month"),
endTime: browsingDate?.endOf("month"), endTime: browsingDate?.endOf("month"),
timeZone, timeZone,
duration, duration,
+5 -53
View File
@@ -1,12 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useTheme from "@calcom/lib/hooks/useTheme";
import type { ITimezoneOption } from "@calcom/ui"; import type { ITimezoneOption } from "@calcom/ui";
import { TimezoneSelect } from "@calcom/ui"; import { TimezoneSelect } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
import { timeZone } from "../../lib/clock"; import { timeZone } from "../../lib/clock";
type Props = { type Props = {
@@ -15,8 +12,6 @@ type Props = {
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => { const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
const [selectedTimeZone, setSelectedTimeZone] = useState(""); const [selectedTimeZone, setSelectedTimeZone] = useState("");
const query = useMeQuery();
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
useEffect(() => { useEffect(() => {
setSelectedTimeZone(timeZone()); setSelectedTimeZone(timeZone());
@@ -28,59 +23,16 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
} }
}, [selectedTimeZone, 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 ? ( return !!selectedTimeZone ? (
<TimezoneSelect <TimezoneSelect
id="timeZone" id="timeZone"
classNames={{
singleValue: () => "dark:text-darkgray-600 text-gray-600",
menu: () => "!w-64 max-w-[90vw]",
}}
variant="minimal"
value={selectedTimeZone} value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)} onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
className="flex h-6 text-sm font-medium"
styles={customStyles}
/> />
) : null; ) : null;
}; };
@@ -17,8 +17,8 @@ export function TimezoneDropdown({
return ( 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"> <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-[2px] rtl:ml-[2px]" /> <FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
<TimeOptions onSelectTimeZone={handleSelectTimeZone} /> <TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</div> </div>
</> </>
@@ -205,6 +205,7 @@ const routerQuerySchema = z
const BookingPage = ({ const BookingPage = ({
eventType, eventType,
booking, booking,
currentSlotBooking,
profile, profile,
isDynamicGroupBooking, isDynamicGroupBooking,
recurringEventCount, recurringEventCount,
@@ -287,6 +288,7 @@ const BookingPage = ({
return router.push({ return router.push({
pathname: `/booking/${uid}`, pathname: `/booking/${uid}`,
query: { query: {
isSuccessBookingPage: true,
allRemainingBookings: true, allRemainingBookings: true,
email: bookingForm.getValues("responses.email"), email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventType.slug, eventTypeSlug: eventType.slug,
@@ -583,23 +585,27 @@ const BookingPage = ({
<div className="text-bookinghighlight flex items-start text-sm"> <div className="text-bookinghighlight flex items-start text-sm">
<FiUser <FiUser
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${ 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" ? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33 : currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500" ? "text-yellow-500"
: "text-bookinghighlight" : "text-bookinghighlight"
}`} }`}
/> />
<p <p
className={`${ className={`${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5 currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600" ? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33 : currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500" ? "text-yellow-500"
: "text-bookinghighlight" : "text-bookinghighlight"
} mb-2 font-medium`}> } mb-2 font-medium`}>
{booking {currentSlotBooking
? eventType.seatsPerTimeSlot - booking.attendees.length ? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
: eventType.seatsPerTimeSlot}{" "} : eventType.seatsPerTimeSlot}{" "}
/ {eventType.seatsPerTimeSlot} {t("seats_available")} / {eventType.seatsPerTimeSlot} {t("seats_available")}
</p> </p>
@@ -631,6 +637,7 @@ const BookingPage = ({
</Button> </Button>
<Button <Button
type="submit" 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"} data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
loading={mutation.isLoading || recurringMutation.isLoading}> loading={mutation.isLoading || recurringMutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")} {rescheduleUid ? t("reschedule") : t("confirm")}
@@ -33,6 +33,7 @@ interface ISetLocationDialog {
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void; saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
selection?: LocationOption; selection?: LocationOption;
booking?: BookingItem; booking?: BookingItem;
isTeamEvent?: boolean;
defaultValues?: LocationObject[]; defaultValues?: LocationObject[];
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>; setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
isOpenDialog: boolean; isOpenDialog: boolean;
@@ -74,6 +75,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
saveLocation, saveLocation,
selection, selection,
booking, booking,
isTeamEvent,
setShowLocationModal, setShowLocationModal,
isOpenDialog, isOpenDialog,
defaultValues, defaultValues,
@@ -290,7 +292,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
query={locationsQuery} query={locationsQuery}
success={({ data }) => { success={({ data }) => {
if (!data.length) return null; if (!data.length) return null;
const locationOptions = [...data]; const locationOptions = [...data].filter((option) => {
return !isTeamEvent ? option.label !== "Conferencing" : true;
});
if (booking) { if (booking) {
locationOptions.map((location) => locationOptions.map((location) =>
location.options.filter((l) => !["phone", "attendeeInPerson"].includes(l.value)) 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 { components } from "react-select";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { NewScheduleButton } from "@calcom/features/schedules";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday"; import { weekdayNames } from "@calcom/lib/weekday";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui"; import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui";
import { FiExternalLink, FiGlobe } from "@calcom/ui/components/icon"; import { FiExternalLink, FiGlobe, FiClock } from "@calcom/ui/components/icon";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader"; import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
@@ -52,23 +54,24 @@ const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
const AvailabilitySelect = ({ const AvailabilitySelect = ({
className = "", className = "",
isLoading,
schedules,
...props ...props
}: { }: {
className?: string; className?: string;
name: string; name: string;
value: number; value: number;
isLoading: boolean;
schedules: RouterOutputs["viewer"]["availability"]["list"]["schedules"] | [];
onBlur: () => void; onBlur: () => void;
onChange: (value: AvailabilityOption | null) => void; onChange: (value: AvailabilityOption | null) => void;
}) => { }) => {
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
const { t } = useLocale(); const { t } = useLocale();
if (isLoading) { if (isLoading) {
return <SelectSkeletonLoader />; return <SelectSkeletonLoader />;
} }
const schedules = data?.schedules || [];
const options = schedules.map((schedule) => ({ const options = schedules.map((schedule) => ({
value: schedule.id, value: schedule.id,
label: schedule.name, label: schedule.name,
@@ -107,6 +110,7 @@ const EventTypeScheduleDetails = () => {
const { watch } = useFormContext<FormValues>(); const { watch } = useFormContext<FormValues>();
const scheduleId = watch("schedule"); const scheduleId = watch("schedule");
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery( const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined }, { scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId } { enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
@@ -174,6 +178,19 @@ const EventTypeScheduleDetails = () => {
const EventTypeSchedule = () => { const EventTypeSchedule = () => {
const { t } = useLocale(); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -183,14 +200,18 @@ const EventTypeSchedule = () => {
<Controller <Controller
name="schedule" name="schedule"
render={({ field }) => ( render={({ field }) => (
<>
<AvailabilitySelect <AvailabilitySelect
value={field.value} value={field.value}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
schedules={schedules?.schedules || []}
isLoading={isLoading}
onChange={(selected) => { onChange={(selected) => {
field.onChange(selected?.value || null); field.onChange(selected?.value || null);
}} }}
/> />
</>
)} )}
/> />
</div> </div>
@@ -88,6 +88,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
label={t("minimum_booking_notice")} label={t("minimum_booking_notice")}
type="number" type="number"
placeholder="0" placeholder="0"
min={0}
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2" className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
/> />
<input type="hidden" ref={ref} {...passThroughProps} /> <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 = ( export const EventSetupTab = (
props: Pick< props: Pick<
EventTypeSetupProps, EventTypeSetupProps,
@@ -53,12 +43,16 @@ export const EventSetupTab = (
) => { ) => {
const { t } = useLocale(); const { t } = useLocale();
const formMethods = useFormContext<FormValues>(); const formMethods = useFormContext<FormValues>();
const { eventType, locationOptions, team, destinationCalendar } = props; const { eventType, team, destinationCalendar } = props;
const [showLocationModal, setShowLocationModal] = useState(false); const [showLocationModal, setShowLocationModal] = useState(false);
const [editingLocationType, setEditingLocationType] = useState<string>(""); const [editingLocationType, setEditingLocationType] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined); const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration); 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) => ({ const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
value: mins, value: mins,
label: t("multiple_duration_mins", { count: 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 */} {/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog <EditLocationDialog
isTeamEvent={!!team}
isOpenDialog={showLocationModal} isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal} setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation} saveLocation={saveLocation}
@@ -1,11 +1,11 @@
export type EventTypeDescriptionSafeProps = { export type EventTypeDescriptionSafeProps = {
eventType: { description: string | null }; eventType: { description: string | null; descriptionAsSafeHTML: string | null };
}; };
export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => { export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => {
const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true }; const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true };
// @ts-expect-error: @see packages/prisma/middleware/eventTypeDescriptionParseAndSanitize.ts if (eventType.description)
if (eventType.description) props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML }; props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML || "" };
return <div {...props} />; return <div {...props} />;
}; };
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
tooltip={t("embed")} tooltip={t("embed")}
/> />
<Button <Button
color="secondary" color="destructive"
variant="icon" variant="icon"
StartIcon={FiTrash} StartIcon={FiTrash}
tooltip={t("delete")} tooltip={t("delete")}
@@ -75,7 +75,9 @@ export function CalendarSwitch(props: {
/> />
{props.defaultSelected && ( {props.defaultSelected && (
<Badge variant="gray"> <Badge variant="gray">
<div className="flex">
<FiArrowLeft className="mr-1" /> {t("adding_events_to")} <FiArrowLeft className="mr-1" /> {t("adding_events_to")}
</div>
</Badge> </Badge>
)} )}
</div> </div>
+6 -5
View File
@@ -2,13 +2,14 @@ import Link from "next/link";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams"; import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui"; import { Avatar } from "@calcom/ui";
type TeamType = NonNullable<TeamWithMembers>; type TeamType = NonNullable<TeamWithMembers>;
type MembersType = TeamType["members"]; 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 Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale(); const { t } = useLocale();
@@ -30,7 +31,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
<> <>
<div <div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600" 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) { if (!members || members.length === 0) {
return null; 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 ( return (
<div> <div>
<Members members={team.members} teamName={team.name} /> <Members members={team.members} teamName={team.name} />
+9
View File
@@ -10,6 +10,8 @@ import type { ComponentProps, ReactNode } from "react";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic"; import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/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 { trpc } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui"; import { MetaProvider } from "@calcom/ui";
@@ -58,6 +60,11 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
return <I18nextAdapter {...passedProps} />; return <I18nextAdapter {...passedProps} />;
}; };
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
}
const AppProviders = (props: AppPropsWithChildren) => { const AppProviders = (props: AppPropsWithChildren) => {
const session = trpc.viewer.public.session.useQuery().data; const session = trpc.viewer.public.session.useQuery().data;
// No need to have intercom on public pages - Good for Page Performance // No need to have intercom on public pages - Good for Page Performance
@@ -85,7 +92,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
storageKey={storageKey} storageKey={storageKey}
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
attribute="class"> attribute="class">
<FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider> <MetaProvider>{props.children}</MetaProvider>
</FeatureFlagsProvider>
</ThemeProvider> </ThemeProvider>
</TooltipProvider> </TooltipProvider>
</CustomI18nextProvider> </CustomI18nextProvider>
+6 -22
View File
@@ -1,9 +1,8 @@
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
import type { z } from "zod"; 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 slugify from "@calcom/lib/slugify";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
type BookingSelect = { type BookingSelect = {
description: true; description: true;
@@ -45,11 +44,7 @@ function getResponsesFromOldBooking(
}; };
} }
async function getBooking( async function getBooking(prisma: PrismaClient, uid: string) {
prisma: PrismaClient,
uid: string,
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">
) {
const rawBooking = await prisma.booking.findFirst({ const rawBooking = await prisma.booking.findFirst({
where: { where: {
uid, uid,
@@ -82,9 +77,7 @@ async function getBooking(
return rawBooking; return rawBooking;
} }
const booking = getBookingWithResponses(rawBooking, { const booking = getBookingWithResponses(rawBooking);
bookingFields,
});
if (booking) { if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects] // @NOTE: had to do this because Server side cant return [Object objects]
@@ -104,20 +97,11 @@ export const getBookingWithResponses = <
}; };
}> }>
>( >(
booking: T, booking: T
eventType: {
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
}
) => { ) => {
return { return {
...booking, ...booking,
responses: getBookingResponsesPartialSchema({ responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
eventType: { } as Omit<T, "responses"> & { responses: z.infer<typeof bookingResponsesDbSchema> };
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)),
};
}; };
export default getBooking; export default getBooking;
+8
View File
@@ -99,6 +99,14 @@ const nextConfig = {
skipDefaultConversion: true, skipDefaultConversion: true,
preventFullImport: 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 // TODO: We need to have all components in `@calcom/ui/components` in order to use this
// "@calcom/ui": { // "@calcom/ui": {
// transform: "@calcom/ui/components/{{member}}", // transform: "@calcom/ui/components/{{member}}",
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "2.7.3", "version": "2.7.6",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -11,6 +11,7 @@
"dx": "yarn dev", "dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000", "test-codegen": "yarn playwright codegen http://localhost:3000",
"type-check": "tsc --pretty --noEmit", "type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint . --ignore-path .gitignore", "lint": "eslint . --ignore-path .gitignore",
@@ -60,6 +61,7 @@
"@stripe/react-stripe-js": "^1.10.0", "@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0", "@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^4.3.9", "@tanstack/react-query": "^4.3.9",
"@tremor/react": "^2.0.0",
"@types/turndown": "^5.0.1", "@types/turndown": "^5.0.1",
"@vercel/edge-config": "^0.1.1", "@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1", "@vercel/edge-functions-ui": "^0.2.1",
@@ -118,6 +120,7 @@
"react-window": "^1.8.7", "react-window": "^1.8.7",
"remark": "^14.0.2", "remark": "^14.0.2",
"rrule": "^2.7.1", "rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0", "schema-dts": "^1.1.0",
"short-uuid": "^4.2.0", "short-uuid": "^4.2.0",
"strip-markdown": "^5.0.0", "strip-markdown": "^5.0.0",
@@ -155,6 +158,7 @@
"@types/react-phone-number-input": "^3.0.14", "@types/react-phone-number-input": "^3.0.14",
"@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",
"@types/sanitize-html": "^2.9.0",
"@types/stripe": "^8.0.417", "@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1", "@types/uuid": "8.3.1",
"autoprefixer": "^10.4.12", "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 { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui"; 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"; import { ssgInit } from "@server/lib/ssg";
@@ -40,6 +40,43 @@ export default function Custom404() {
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage; const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
const isSignup = router.asPath.startsWith("/signup"); const isSignup = router.asPath.startsWith("/signup");
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com"; 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 ( return (
<> <>
+6 -2
View File
@@ -24,7 +24,7 @@ import defaultEvents, {
} from "@calcom/lib/defaultEvents"; } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme"; import useTheme from "@calcom/lib/hooks/useTheme";
import { md } from "@calcom/lib/markdownIt"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects"; import { baseEventTypeSelect } from "@calcom/prisma/selects";
@@ -147,7 +147,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<> <>
<div <div
className=" dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600" 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) => ({ const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType, ...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}), metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
})); }));
const isSingleUser = users.length === 1; const isSingleUser = users.length === 1;
@@ -352,9 +353,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}) })
: []; : [];
const safeBio = markdownToSafeHTML(user.bio) || "";
return { return {
props: { props: {
users, users,
safeBio,
profile, profile,
user: { user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), 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 { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale"; 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 type { User } from "@calcom/prisma/client";
import { isBrandingHidden } from "@lib/isBrandingHidden"; import { isBrandingHidden } from "@lib/isBrandingHidden";
@@ -59,7 +59,6 @@ Type.isThemeSupported = true;
const paramsSchema = z.object({ type: z.string(), user: z.string() }); const paramsSchema = z.object({ type: z.string(), user: z.string() });
async function getUserPageProps(context: GetStaticPropsContext) { async function getUserPageProps(context: GetStaticPropsContext) {
// load server side dependencies // 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 prisma = await import("@calcom/prisma").then((mod) => mod.default);
const { privacyFilteredLocations } = await import("@calcom/app-store/locations"); const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent"); 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 }; if (!user || !user.eventTypes.length) return { notFound: true };
const [eventType]: ((typeof user.eventTypes)[number] & { const [eventType]: ((typeof user.eventTypes)[number] & {
@@ -153,7 +149,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}), metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent), recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations), 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 // Check if the user you are logging into has any active teams or premium user name
const hasActiveTeam = const hasActiveTeam =
+9 -6
View File
@@ -14,6 +14,7 @@ import {
getUsernameList, getUsernameList,
} from "@calcom/lib/defaultEvents"; } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma, { bookEventTypeSelect } from "@calcom/prisma"; import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import { import {
customInputSchema, customInputSchema,
@@ -189,6 +190,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: u.username, slug: u.username,
theme: u.theme, theme: u.theme,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}; };
})[0]; })[0];
@@ -217,7 +219,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
rescheduleUid = bookingSeat.booking.uid; rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email; 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 // If it's not reschedule but event Type has seats we should obtain
// the bookingUid regardless and use it to get the booking // the bookingUid regardless and use it to get the booking
const currentSeats = await prisma.booking.findFirst({ const currentSeats = await prisma.booking.findFirst({
@@ -237,11 +241,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
let booking: GetBookingType | null = null; let booking: GetBookingType | null = null;
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) { if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
booking = await getBooking( booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
prisma,
rescheduleUid || query.bookingUid || bookingUidWithSeats || "",
eventTypeObject.bookingFields
);
} }
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) { if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
@@ -285,12 +285,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: eventType.recurringEvent.count)) || : eventType.recurringEvent.count)) ||
null; null;
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
return { return {
props: { props: {
away: user.away, away: user.away,
profile, profile,
eventType: eventTypeObject, eventType: eventTypeObject,
booking, booking,
currentSlotBooking: currentSlotBooking,
recurringEventCount, recurringEventCount,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
isDynamicGroupBooking, isDynamicGroupBooking,
+37 -4
View File
@@ -1,4 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next"; 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey; const apiKey = req.headers.authorization || req.query.apiKey;
@@ -11,10 +19,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return; return;
} }
/** const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
* TODO: const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
* Remove this endpoint 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 }); res.json({ ok: true });
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ResetPasswordRequest } from "@prisma/client"; import type { ResetPasswordRequest } from "@prisma/client";
import debounce from "lodash/debounce"; import { debounce } from "lodash";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react"; import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations"; 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 type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react"; import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+10 -2
View File
@@ -94,6 +94,7 @@ export default function Availability() {
data: { schedule: scheduleId }, data: { schedule: scheduleId },
} = useTypedQuery(querySchema); } = useTypedQuery(querySchema);
const { fromEventType } = router.query;
const { timeFormat } = me.data || { timeFormat: null }; const { timeFormat } = me.data || { timeFormat: null };
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery( const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId }, { scheduleId },
@@ -153,7 +154,7 @@ export default function Availability() {
return ( return (
<Shell <Shell
backPath="/availability" backPath={fromEventType ? true : "/availability"}
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")} title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
heading={ heading={
<Controller <Controller
@@ -200,7 +201,14 @@ export default function Availability() {
<VerticalDivider /> <VerticalDivider />
<Dialog> <Dialog>
<DialogTrigger asChild> <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> </DialogTrigger>
<ConfirmationDialogContent <ConfirmationDialogContent
isLoading={deleteMutation.isLoading} isLoading={deleteMutation.isLoading}
+7 -1
View File
@@ -93,7 +93,13 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
key={schedule.id} key={schedule.id}
schedule={schedule} schedule={schedule}
updateDefault={updateMutation.mutate} updateDefault={updateMutation.mutate}
deleteFunction={deleteMutation.mutate} deleteFunction={() => {
if (schedules.length === 1) {
showToast(t("requires_at_least_one_schedule"), "error");
} else {
deleteMutation.mutate;
}
}}
/> />
))} ))}
</ul> </ul>
+9 -6
View File
@@ -180,7 +180,7 @@ const querySchema = z.object({
cancel: stringToBoolean, cancel: stringToBoolean,
changes: stringToBoolean, changes: stringToBoolean,
reschedule: stringToBoolean, reschedule: stringToBoolean,
isSuccessBookingPage: z.string().optional(), isSuccessBookingPage: stringToBoolean,
formerTime: z.string().optional(), formerTime: z.string().optional(),
email: z.string().optional(), email: z.string().optional(),
seatReferenceUid: z.string().optional(), seatReferenceUid: z.string().optional(),
@@ -266,7 +266,11 @@ export default function Success(props: SuccessProps) {
const giphyImage = giphyAppData?.thankYouPage; const giphyImage = giphyAppData?.thankYouPage;
const eventName = getEventName(eventNameObject, true); 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 userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
const isCancelled = const isCancelled =
@@ -485,7 +489,7 @@ export default function Success(props: SuccessProps) {
</> </>
)} )}
<div className="font-medium">{t("what")}</div> <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="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6 last:mb-0"> <div className="col-span-2 mb-6 last:mb-0">
{reschedule && !!formerTime && ( {reschedule && !!formerTime && (
@@ -527,7 +531,7 @@ export default function Success(props: SuccessProps) {
</div> </div>
)} )}
{bookingInfo?.attendees.map((attendee) => ( {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>} {attendee.name && <p>{attendee.name}</p>}
<p data-testid={`attendee-${attendee.email}`} className="text-bookinglight"> <p data-testid={`attendee-${attendee.email}`} className="text-bookinglight">
{attendee.email} {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] // @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse // probably fixable with json.stringify -> json.parse
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date; 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 { privacyFilteredLocations } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability"; import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { availiblityPageEventTypeSelect } from "@calcom/prisma"; import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -119,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: u.hideBranding, hideBranding: u.hideBranding,
timeZone: u.timeZone, timeZone: u.timeZone,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(hashedLink.eventType.description),
}); });
const [user] = users; const [user] = users;
+3
View File
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { bookEventTypeSelect } from "@calcom/prisma/selects"; import { bookEventTypeSelect } from "@calcom/prisma/selects";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -93,6 +94,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
brandColor: u.brandColor, brandColor: u.brandColor,
darkBrandColor: u.darkBrandColor, darkBrandColor: u.darkBrandColor,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}; };
})[0]; })[0];
@@ -120,6 +122,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile, profile,
eventType: eventTypeObject, eventType: eventTypeObject,
booking: null, booking: null,
currentSlotBooking: null,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
recurringEventCount, recurringEventCount,
isDynamicGroupBooking: false, isDynamicGroupBooking: false,
@@ -65,6 +65,7 @@ export type FormValues = {
hostPhoneNumber?: string; hostPhoneNumber?: string;
displayLocationPublicly?: boolean; displayLocationPublicly?: boolean;
phone?: string; phone?: string;
hostDefault?: string;
}[]; }[];
customInputs: CustomInputParsed[]; customInputs: CustomInputParsed[];
schedule: number | null; schedule: number | null;
+1 -1
View File
@@ -141,7 +141,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
</div> </div>
<EventTypeDescription <EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon // @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type} eventType={{ ...type, descriptionAsSafeHTML: type.safeDescription }}
shortenDescription shortenDescription
/> />
</Link> </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 { zodResolver } from "@hookform/resolvers/zod";
import { IdentityProvider } from "@prisma/client"; import { IdentityProvider } from "@prisma/client";
import crypto from "crypto";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import type { BaseSyntheticEvent } from "react"; import type { BaseSyntheticEvent } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -332,11 +331,6 @@ const ProfileForm = ({
bio: z.string(), bio: z.string(),
}); });
const emailMd5 = crypto
.createHash("md5")
.update(defaultValues.email || "example@example.com")
.digest("hex");
const formMethods = useForm<FormValues>({ const formMethods = useForm<FormValues>({
defaultValues, defaultValues,
resolver: zodResolver(profileFormSchema), resolver: zodResolver(profileFormSchema),
@@ -356,7 +350,7 @@ const ProfileForm = ({
name="avatar" name="avatar"
render={({ field: { value } }) => ( 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"> <div className="ltr:ml-4 rtl:mr-4">
<ImageUploader <ImageUploader
target="avatar" target="avatar"
+10 -3
View File
@@ -10,7 +10,7 @@ import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme"; import useTheme from "@calcom/lib/hooks/useTheme";
import { md } from "@calcom/lib/markdownIt"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -113,7 +113,7 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
<> <>
<div <div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600" 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, ...user,
avatar: CAL_URL + "/" + user.username + "/avatar.png", 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 { return {
props: { props: {
team, team: { ...team, safeBio, members },
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
}, },
} as const; } as const;
+3 -3
View File
@@ -2,9 +2,9 @@ import type { GetServerSidePropsContext } from "next";
import type { LocationObject } from "@calcom/core/location"; import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location"; import { privacyFilteredLocations } from "@calcom/core/location";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability"; import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -64,7 +64,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: { select: {
id: true, id: true,
name: true, name: true,
avatar: true,
username: true, username: true,
timeZone: true, timeZone: true,
hideBranding: true, hideBranding: true,
@@ -171,13 +170,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding, hideBranding,
timeZone, timeZone,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}); });
eventTypeObject.availability = []; eventTypeObject.availability = [];
let booking: GetBookingType | null = null; let booking: GetBookingType | null = null;
if (rescheduleUid) { if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject)); booking = await getBooking(prisma, rescheduleUid);
} }
const weekStart = eventType.team?.members?.[0]?.user?.weekStart; 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 { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -121,13 +122,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: u.avatar, image: u.avatar,
slug: u.username, slug: u.username,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}; };
})[0]; })[0];
let booking: GetBookingType | null = null; let booking: GetBookingType | null = null;
const { rescheduleUid, bookingUid } = querySchema.parse(context.query); const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
if (rescheduleUid || bookingUid) { 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 // 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, eventType: eventTypeObject,
recurringEventCount, recurringEventCount,
booking, booking,
currentSlotBooking: null,
isDynamicGroupBooking: false, isDynamicGroupBooking: false,
hasHashedBookingLink: false, hasHashedBookingLink: false,
hashedLink: null, hashedLink: null,
+1 -1
View File
@@ -119,7 +119,7 @@ test.describe("Event Types tests", () => {
await page.locator("#location-select").click(); await page.locator("#location-select").click();
await page.locator("text=In Person (Organizer Address)").click(); await page.locator("text=In Person (Organizer Address)").click();
// eslint-disable-next-line playwright/no-wait-for-timeout // 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('input[name="locationAddress"]').fill(inputText);
await page.locator("[data-testid=display-location]").check(); await page.locator("[data-testid=display-location]").check();
await page.locator("[data-testid=update-location]").click(); await page.locator("[data-testid=update-location]").click();
+1 -1
View File
@@ -45,7 +45,7 @@ export const createEmbedsFixture = (page: Page) => {
} }
api("on", { api("on", {
action: "*", action: "*",
callback: (e: any) => { callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @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 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 { expect } from "@playwright/test";
import type { IncomingMessage, ServerResponse } from "http"; import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http"; import { createServer } from "http";
import noop from "lodash/noop"; import { noop } from "lodash";
import { test } from "./fixtures"; 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=username]").fill("new user onboarding");
await page.locator("input[name=name]").fill("new user 2"); await page.locator("input[name=name]").fill("new user 2");
await page.locator("input[role=combobox]").click(); 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(); 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(); 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 newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
const rescheduledBooking = await prisma.booking.findFirst({ where: { uid: booking.uid } });
expect(newBooking).not.toBeNull(); expect(newBooking).not.toBeNull();
expect(rescheduledBooking).toBeNull();
await prisma.booking.delete({ where: { id: newBooking?.id } }); await prisma.booking.delete({ where: { id: newBooking?.id } });
}); });
@@ -148,7 +149,6 @@ test.describe("Reschedule Tests", async () => {
}); });
await expect(page).toHaveURL(/.*payment/); await expect(page).toHaveURL(/.*payment/);
await payment.delete();
}); });
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => { 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 page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/); await expect(page).toHaveURL(/.*booking/);
await payment.delete();
}); });
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => { 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.", "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.", "link_expires": "p.s. It expires in {{expiresIn}} hours.",
"upgrade_to_per_seat": "Upgrade to Per-Seat", "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_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_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.",
"team_upgrade_banner_action": "Upgrade here", "team_upgrade_banner_action": "Upgrade here",
@@ -433,7 +433,7 @@
"password_hint_min": "Minimum 7 characters long", "password_hint_min": "Minimum 7 characters long",
"password_hint_admin_min": "Minimum 15 characters long", "password_hint_admin_min": "Minimum 15 characters long",
"password_hint_num": "Contain at least 1 number", "password_hint_num": "Contain at least 1 number",
"max_limit_allowed_hint":"Must be {{limit}} or fewer characters long", "max_limit_allowed_hint": "Must be {{limit}} or fewer characters long",
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters", "invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
"incorrect_password": "Password is incorrect.", "incorrect_password": "Password is incorrect.",
"incorrect_username_password": "Username or password is incorrect.", "incorrect_username_password": "Username or password is incorrect.",
@@ -863,7 +863,7 @@
"add_new_calendar": "Add a new calendar", "add_new_calendar": "Add a new calendar",
"set_calendar": "Set where to add new events to when you're booked.", "set_calendar": "Set where to add new events to when you're booked.",
"delete_schedule": "Delete schedule", "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", "schedule_created_successfully": "{{scheduleName}} schedule created successfully",
"availability_updated_successfully": "{{scheduleName}} schedule updated successfully", "availability_updated_successfully": "{{scheduleName}} schedule updated successfully",
"schedule_deleted_successfully": "Schedule deleted successfully", "schedule_deleted_successfully": "Schedule deleted successfully",
@@ -1218,6 +1218,7 @@
"impersonation": "Impersonation", "impersonation": "Impersonation",
"impersonation_description": "Settings to manage user impersonation", "impersonation_description": "Settings to manage user impersonation",
"users": "Users", "users": "Users",
"user": "User",
"profile_description": "Manage settings for your {{appName}} profile", "profile_description": "Manage settings for your {{appName}} profile",
"users_description": "Here you can find a list of all users", "users_description": "Here you can find a list of all users",
"users_listing": "User listing", "users_listing": "User listing",
@@ -1608,6 +1609,7 @@
"default_app_link_title": "Set a default app link", "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.", "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", "change_default_conferencing_app": "Set as default",
"organizer_default_conferencing_app": "Organizer's default app",
"under_maintenance": "Down for maintenance", "under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.", "under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats", "event_type_seats": "{{numberOfSeats}} seats",
@@ -1631,22 +1633,30 @@
"a_routing_form": "A Routing Form", "a_routing_form": "A Routing Form",
"form_description_placeholder": "Form Description", "form_description_placeholder": "Form Description",
"keep_me_connected_with_form": "Keep me connected with the form", "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", "form_deleted": "Form deleted",
"delete_form": "Are you sure you want to delete this form?", "delete_form": "Are you sure you want to delete this form?",
"delete_form_action": "Yes, delete 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": "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_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.", "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.", "modifications_in_fields_warning": "Modifications in fields and routes of following forms will be reflected in this form.",
"connected_forms": "Connected Forms", "connected_forms": "Connected Forms",
"form_modifications_warning": "Following forms would be affected when you modify fields or routes here.", "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.", "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.", "identifies_name_field": "Identifies field by this name.",
"add_1_option_per_line": "Add 1 option per line", "add_1_option_per_line": "Add 1 option per line",
"select_a_router": "Select a router", "select_a_router": "Select a router",
"add_a_new_route": "Add a new Route", "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", "no_responses_yet": "No responses yet",
"this_will_be_the_placeholder": "This will be the placeholder", "this_will_be_the_placeholder": "This will be the placeholder",
"this_meeting_has_not_started_yet": "This meeting has not started yet", "this_meeting_has_not_started_yet": "This meeting has not started yet",
@@ -1657,8 +1667,30 @@
"verification_code": "Verification code", "verification_code": "Verification code",
"can_you_try_again": "Can you try again with a different time?", "can_you_try_again": "Can you try again with a different time?",
"verify": "Verify", "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", "select_all": "Select All",
"default_conferencing_bulk_title": "Bulk update existing event types", "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": "Suplantación",
"impersonation_description": "Configuración para administrar la suplantación del usuario", "impersonation_description": "Configuración para administrar la suplantación del usuario",
"users": "Usuarios", "users": "Usuarios",
"user": "Usuario",
"profile_description": "Administra los ajustes para tu perfil de {{appName}}", "profile_description": "Administra los ajustes para tu perfil de {{appName}}",
"users_description": "Aquí puede encontrar una lista de todos los usuarios", "users_description": "Aquí puede encontrar una lista de todos los usuarios",
"users_listing": "Lista de 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: { ctx: {
prisma, prisma,
session: null, session: null,
user: null,
locale, locale,
i18n: _i18n, i18n: _i18n,
}, },
+2
View File
@@ -497,3 +497,5 @@ hr {
::-webkit-search-cancel-button { ::-webkit-search-cancel-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
+1 -1
View File
@@ -2,5 +2,5 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
...base, ...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-playwright": "yarn playwright test --config=playwright.config.ts",
"test": "jest", "test": "jest",
"type-check": "turbo run type-check", "type-check": "turbo run type-check",
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
"web": "yarn workspace @calcom/web" "web": "yarn workspace @calcom/web"
}, },
"devDependencies": { "devDependencies": {
@@ -86,8 +87,9 @@
"jest-watch-select-projects": "^2.0.0", "jest-watch-select-projects": "^2.0.0",
"jest-watch-typeahead": "^2.0.0", "jest-watch-typeahead": "^2.0.0",
"lint-staged": "^12.5.0", "lint-staged": "^12.5.0",
"prettier": "^2.8.4", "prettier": "^2.8.6",
"ts-jest": "^28.0.8", "ts-jest": "^28.0.8",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
"dependencies": { "dependencies": {
+3 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs"; import fs from "fs";
import path from "path"; 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 { APP_STORE_PATH, TEMPLATES_PATH } from "./constants";
import execSync from "./utils/execSync"; import execSync from "./utils/execSync";
@@ -127,6 +127,8 @@ export const BaseAppFork = {
.replace(/_DESCRIPTION_/g, description) .replace(/_DESCRIPTION_/g, description)
.replace(/_APP_DIR_/g, slug) .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 }) { delete: async function ({ slug, isTemplate }: { slug: string; isTemplate: boolean }) {
@@ -3,7 +3,6 @@
Don't modify this file manually. Don't modify this file manually.
**/ **/
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
export const InstallAppButtonMap = { export const InstallAppButtonMap = {
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")), applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
around: dynamic(() => import("./around/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 wordpress_zod_ts } from "./wordpress/zod";
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod"; import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = { export const appKeysSchemas = {
dailyvideo: dailyvideo_zod_ts, dailyvideo: dailyvideo_zod_ts,
fathom: fathom_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 { metadata as zapier__metadata_ts } from "./zapier/_metadata";
import zohocrm_config_json from "./zohocrm/config.json"; import zohocrm_config_json from "./zohocrm/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata"; import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = { export const appStoreMetadata = {
amie: amie_config_json, amie: amie_config_json,
applecalendar: applecalendar__metadata_ts, 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 wordpress_zod_ts } from "./wordpress/zod";
import { appDataSchema as zapier_zod_ts } from "./zapier/zod"; import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = { export const appDataSchemas = {
dailyvideo: dailyvideo_zod_ts, dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts, fathom: fathom_zod_ts,
+1
View File
@@ -10,6 +10,7 @@ items:
{DESCRIPTION} {DESCRIPTION}
### Cron's most valuable features ### Cron's most valuable features
<!-- From "FAQs: What are Cron’s most valuable features?" <!-- From "FAQs: What are Cron’s most valuable features?"
https://cronhq.notion.site/Cron-Calendar-5625be54feac4e13a75b10271b65ddb7 --> https://cronhq.notion.site/Cron-Calendar-5625be54feac4e13a75b10271b65ddb7 -->
@@ -133,6 +133,7 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar, calendarId: selectedCalendar,
requestBody: payload, requestBody: payload,
conferenceDataVersion: 1, conferenceDataVersion: 1,
sendUpdates: "none",
}, },
function (error, event) { function (error, event) {
if (error || !event?.data) { if (error || !event?.data) {
@@ -234,14 +235,13 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar, calendarId: selectedCalendar,
eventId: uid, eventId: uid,
sendNotifications: true, sendNotifications: true,
sendUpdates: "all", sendUpdates: "none",
requestBody: payload, requestBody: payload,
conferenceDataVersion: 1, conferenceDataVersion: 1,
}, },
function (err, evt) { function (err, evt) {
if (err) { if (err) {
console.error("There was an error contacting google calendar service: ", err); console.error("There was an error contacting google calendar service: ", err);
return reject(err); return reject(err);
} }
@@ -293,7 +293,7 @@ export default class GoogleCalendarService implements Calendar {
calendarId: calendarId ? calendarId : defaultCalendarId, calendarId: calendarId ? calendarId : defaultCalendarId,
eventId: uid, eventId: uid,
sendNotifications: false, sendNotifications: false,
sendUpdates: "all", sendUpdates: "none",
}, },
function (err: GoogleCalError | null, event) { function (err: GoogleCalError | null, event) {
if (err) { if (err) {
+25 -4
View File
@@ -12,13 +12,20 @@ export type DefaultEventLocationType = {
type: DefaultEventLocationTypeEnum; type: DefaultEventLocationTypeEnum;
label: string; label: string;
messageForOrganizer: string; messageForOrganizer: string;
category: "in person" | "other" | "phone"; category: "in person" | "conferencing" | "other" | "phone";
iconUrl: string; iconUrl: string;
urlRegExp?: string; urlRegExp?: string;
// HACK: `variable` and `defaultValueVariable` are required due to legacy reason where different locations were stored in different places. // 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"; variable:
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone"; | "locationType"
| "locationAddress"
| "address"
| "locationLink"
| "locationPhoneNumber"
| "phone"
| "hostDefault";
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone";
} & ( } & (
| { | {
organizerInputType: "phone" | "text" | null; organizerInputType: "phone" | "text" | null;
@@ -60,6 +67,7 @@ export enum DefaultEventLocationTypeEnum {
*/ */
UserPhone = "userPhone", UserPhone = "userPhone",
Link = "link", Link = "link",
Conferencing = "conferencing",
} }
export const defaultLocations: DefaultEventLocationType[] = [ export const defaultLocations: DefaultEventLocationType[] = [
@@ -88,6 +96,17 @@ export const defaultLocations: DefaultEventLocationType[] = [
iconUrl: "/map-pin.svg", iconUrl: "/map-pin.svg",
category: "in person", 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, default: true,
type: DefaultEventLocationTypeEnum.Link, type: DefaultEventLocationTypeEnum.Link,
@@ -130,7 +149,9 @@ export const defaultLocations: DefaultEventLocationType[] = [
export type LocationObject = { export type LocationObject = {
type: string; type: string;
displayLocationPublicly?: boolean; 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 // integrations:jitsi | 919999999999 | Delhi | https://manual.meeting.link | Around Video
export type BookingLocationValue = string; export type BookingLocationValue = string;
@@ -219,7 +219,7 @@ export default class Office365CalendarService implements Calendar {
}; };
const o365AuthCredentials = credential.key as O365AuthCredentials; const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = async (refreshToken: string) => { const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => {
const { client_id, client_secret } = await getOfficeAppKeys(); const { client_id, client_secret } = await getOfficeAppKeys();
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST", method: "POST",
@@ -227,12 +227,22 @@ export default class Office365CalendarService implements Calendar {
body: new URLSearchParams({ body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite", scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id, client_id,
refresh_token: refreshToken, refresh_token: o365AuthCredentials.refresh_token,
grant_type: "refresh_token", grant_type: "refresh_token",
client_secret, 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({ await prisma.credential.update({
where: { where: {
id: credential.id, id: credential.id,
@@ -246,9 +256,10 @@ export default class Office365CalendarService implements Calendar {
return { return {
getToken: () => getToken: () =>
refreshTokenResponseSchema.safeParse(o365AuthCredentials).success &&
!isExpired(o365AuthCredentials.expires_in) !isExpired(o365AuthCredentials.expires_in)
? Promise.resolve(o365AuthCredentials.access_token) ? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token), : refreshAccessToken(o365AuthCredentials),
}; };
}; };
+1
View File
@@ -8,6 +8,7 @@
], ],
"scripts": { "scripts": {
"type-check": "tsc --pretty --noEmit", "type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx", "lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json" "lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
@@ -99,23 +99,18 @@ export default function RoutingForms({
CTA={hasPaidPlan && <NewFormButton />} CTA={hasPaidPlan && <NewFormButton />}
subtitle={t("routing_forms_description")}> subtitle={t("routing_forms_description")}>
<UpgradeTip <UpgradeTip
dark
title={t("teams_plan_required")} title={t("teams_plan_required")}
description={t("routing_forms_are_a_great_way")} description={t("routing_forms_are_a_great_way")}
features={features} features={features}
background="/routing-form-banner-background.jpg" background="/banners/routing-forms.jpg"
isParentLoading={isLoading && <SkeletonLoaderTeamList />} isParentLoading={isLoading && <SkeletonLoaderTeamList />}
buttons={ buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2"> <div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup> <ButtonGroup>
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}> <Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("upgrade")} {t("upgrade")}
</Button> </Button>
<Button <Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
color="minimal"
className="!bg-transparent text-white opacity-50 hover:opacity-100"
href="https://go.cal.com/teams-video"
target="_blank">
{t("learn_more")} {t("learn_more")}
</Button> </Button>
</ButtonGroup> </ButtonGroup>
Whitespace-only changes.
+1 -1
View File
@@ -19,7 +19,7 @@
"type": "integrations:whereby_video", "type": "integrations:whereby_video",
"label": "Whereby Video", "label": "Whereby Video",
"organizerInputPlaceholder": "https://www.whereby.com/cal", "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/forms": "^0.5.2",
"@tailwindcss/line-clamp": "^0.4.0", "@tailwindcss/line-clamp": "^0.4.0",
"@tailwindcss/typography": "^0.5.4", "@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/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0", "@typescript-eslint/parser": "^5.52.0",
"eslint": "^8.34.0", "eslint": "^8.34.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^2.8.4", "prettier": "^2.8.6",
"prettier-plugin-tailwindcss": "^0.1.13", "prettier-plugin-tailwindcss": "^0.2.5",
"tailwind-scrollbar": "^2.0.1", "tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.2.1", "tailwindcss": "^3.2.1",
"typescript": "^4.9.4" "typescript": "^4.9.4"
+8 -1
View File
@@ -16,7 +16,14 @@ module.exports = {
"^[./]", "^[./]",
], ],
importOrderSeparation: true, 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: [ overrides: [
{ {
files: ["apps/website/lib/utils/wordlist/wordlist.ts"], files: ["apps/website/lib/utils/wordlist/wordlist.ts"],
+6 -6
View File
@@ -1,5 +1,5 @@
import type { SelectedCalendar } from "@prisma/client"; import type { SelectedCalendar } from "@prisma/client";
import _ from "lodash"; import { sortBy } from "lodash";
import * as process from "process"; import * as process from "process";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
@@ -54,8 +54,8 @@ export const getConnectedCalendars = async (
}; };
} }
const cals = await calendar.listCalendars(); const cals = await calendar.listCalendars();
const calendars = _(cals) const calendars = sortBy(
.map((cal) => { cals.map((cal) => {
if (cal.externalId === destinationCalendarExternalId) destinationCalendar = cal; if (cal.externalId === destinationCalendarExternalId) destinationCalendar = cal;
return { return {
...cal, ...cal,
@@ -64,9 +64,9 @@ export const getConnectedCalendars = async (
isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId), isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
credentialId, credentialId,
}; };
}) }),
.sortBy(["primary"]) ["primary"]
.value(); );
const primary = calendars.find((item) => item.primary) ?? calendars.find((cal) => cal !== undefined); const primary = calendars.find((item) => item.primary) ?? calendars.find((cal) => cal !== undefined);
if (!primary) { if (!primary) {
return { return {
+1 -2
View File
@@ -1,6 +1,5 @@
import type { DestinationCalendar, Booking } from "@prisma/client"; import type { DestinationCalendar, Booking } from "@prisma/client";
import { cloneDeep } from "lodash"; import { cloneDeep, merge } from "lodash";
import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import type { z } from "zod"; import type { z } from "zod";
-25
View File
@@ -1,31 +1,6 @@
/* eslint-disable @calcom/eslint/deprecated-imports */ /* eslint-disable @calcom/eslint/deprecated-imports */
import dayjs from "dayjs"; import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-days2"; 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 customParseFormat from "dayjs/plugin/customParseFormat";
import isBetween from "dayjs/plugin/isBetween"; import isBetween from "dayjs/plugin/isBetween";
import isToday from "dayjs/plugin/isToday"; 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 }} />; const Spacer = () => <p style={{ height: 6 }} />;
export const Info = (props: { export const Info = (props: {
@@ -6,8 +8,14 @@ export const Info = (props: {
extraInfo?: React.ReactNode; extraInfo?: React.ReactNode;
withSpacer?: boolean; withSpacer?: boolean;
lineThrough?: boolean; lineThrough?: boolean;
formatted?: boolean;
}) => { }) => {
if (!props.description || props.description === "") return null; 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 ( return (
<> <>
{props.withSpacer && <Spacer />} {props.withSpacer && <Spacer />}
@@ -21,7 +29,18 @@ export const Info = (props: {
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
textDecoration: props.lineThrough ? "line-through" : undefined, 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> </p>
{props.extraInfo} {props.extraInfo}
</div> </div>
@@ -2,6 +2,8 @@ import type { TFunction } from "next-i18next";
import { RRule } from "rrule"; import { RRule } from "rrule";
import dayjs from "@calcom/dayjs"; 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 { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { TimeFormat } from "@calcom/lib/timeFormat"; import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -76,7 +76,7 @@ export const BaseScheduledEmail = (
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} /> <WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
<WhoInfo calEvent={props.calEvent} t={t} /> <WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo 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 /> <Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />} {props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<UserFieldsResponses calEvent={props.calEvent} /> <UserFieldsResponses calEvent={props.calEvent} />
+9 -1
View File
@@ -4,8 +4,10 @@ import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig"; import { serverConfig } from "@calcom/lib/serverConfig";
import prisma from "@calcom/prisma";
declare let global: { declare let global: {
E2E_EMAILS?: Record<string, unknown>[]; E2E_EMAILS?: Record<string, unknown>[];
@@ -29,7 +31,13 @@ export default class BaseEmail {
protected getNodeMailerPayload(): Record<string, unknown> { protected getNodeMailerPayload(): Record<string, unknown> {
return {}; 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) { if (process.env.NEXT_PUBLIC_IS_E2E) {
global.E2E_EMAILS = global.E2E_EMAILS || []; global.E2E_EMAILS = global.E2E_EMAILS || [];
global.E2E_EMAILS.push(this.getNodeMailerPayload()); global.E2E_EMAILS.push(this.getNodeMailerPayload());
+15 -316
View File
@@ -4,6 +4,16 @@
<!-- <link rel="prerender" href="http://localhost:3000/free"> --> <!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> --> <!-- <script src="./src/embed.ts" type="module"></script> -->
<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")) { if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>'); document.write('<meta name="viewport" content="width=device-width"/>');
} }
@@ -90,6 +100,7 @@
</div> </div>
<span style="display: block;" ><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span> <span style="display: block;" ><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span>
<div> <div>
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples</h2> <h2>Popup Examples</h2>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">Book with Free User[Auto Theme]</button> <button data-cal-namespace="popupAutoTheme" data-cal-link="free">Book with Free User[Auto Theme]</button>
@@ -140,12 +151,12 @@
Align Center Align Center
</button> </button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: { <button onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: {
backgroundColor: '#D3D3D3', backgroundColor: generateRandomHexColor(),
}, },
disabledDateButton: { disabledDateButton: {
backgroundColor: 'lightslategray', backgroundColor: generateRandomHexColor(),
},}})})()"> },}})})()">
Change Date Button Color Change 'enabledDateButton` and `disabledDateButton` background Color
</button> </button>
</div> </div>
@@ -189,318 +200,6 @@
<div class="place"> <div class="place">
</div> </div>
</div> </div>
<script src="./playground.ts"></script>
<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>
</body> </body>
</html> </html>
+1
View File
@@ -23,6 +23,7 @@
"dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'", "dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'",
"dev-real": "vite dev --port 3100", "dev-real": "vite dev --port 3100",
"type-check": "tsc --pretty --noEmit", "type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src", "lint": "eslint --ext .ts,.js src",
"lint:fix": "eslint --ext .ts,.js src --fix", "lint:fix": "eslint --ext .ts,.js src --fix",
"embed-tests": "yarn playwright test --config=playwright/config/playwright.config.ts", "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"; 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 { export class FloatingButton extends HTMLElement {
static updatedClassString(position: string, classString: string) { static updatedClassString(position: string, classString: string) {
return [ return [
@@ -8,66 +39,69 @@ export class FloatingButton extends HTMLElement {
].join(" "); ].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 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
static get observedAttributes() { static get observedAttributes() {
return [ return dataAttributes;
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
];
} }
attributeChangedCallback(name: string, oldValue: string, newValue: string) { attributeChangedCallback(name: DataAttributes, oldValue: string, newValue: string) {
if (name === "data-button-text") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button"); const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
const buttonWrapperEl = this.shadowRoot?.querySelector<HTMLElement>("button");
const buttonIconEl = this.shadowRoot?.querySelector<HTMLElement>("#button-icon");
if (!buttonEl) { if (!buttonEl) {
throw new Error("Button not found"); 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") {
buttonEl.textContent = newValue; buttonEl.textContent = newValue;
} else if (name === "data-hide-button-icon") { } 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"; buttonIconEl.style.display = newValue == "true" ? "none" : "block";
} else if (name === "data-button-position") { } else if (name === "data-button-position") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button"); buttonWrapperEl.className = FloatingButton.updatedClassString(newValue, buttonWrapperEl.className);
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className);
} else if (name === "data-button-color") { } else if (name === "data-button-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button"); buttonWrapperEl.style.backgroundColor = newValue;
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.backgroundColor = newValue;
} else if (name === "data-button-text-color") { } else if (name === "data-button-text-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button"); buttonWrapperEl.style.color = newValue;
if (!buttonEl) { } else {
throw new Error("Button not found"); console.log("Unknown attribute changed", name, oldValue, newValue);
}
buttonEl.style.color = newValue;
} }
} }
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
constructor() { constructor() {
super(); super();
const buttonText = this.dataset["buttonText"]; const dataset = this.dataset as FloatingButtonDataset;
const buttonPosition = this.dataset["buttonPosition"]; const buttonText = dataset["buttonText"];
const buttonColor = this.dataset["buttonColor"]; const buttonPosition = dataset["buttonPosition"];
const buttonTextColor = this.dataset["buttonTextColor"]; const buttonColor = dataset["buttonColor"];
const buttonTextColor = dataset["buttonTextColor"];
//TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place //TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place
const buttonHtml = `<style>${window.Cal!.__css}</style> ${getFloatingButtonHtml({ const buttonHtml = `<style>${window.Cal.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText!, buttonText: buttonText,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")], buttonClasses: [FloatingButton.updatedClassString(buttonPosition, "")],
buttonColor: buttonColor!, buttonColor: buttonColor,
buttonTextColor: buttonTextColor!, buttonTextColor: buttonTextColor,
})}`; })}`;
this.attachShadow({ mode: "open" }); 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 { getErrorString } from "../utils";
import inlineHtml from "./inlineHtml"; import inlineHtml from "./inlineHtml";
export class Inline extends HTMLElement { export class Inline extends HTMLElement {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() { static get observedAttributes() {
return ["loading"]; 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) { 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 (name === "loading") {
if (newValue == "done") { if (newValue == "done") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none"; loaderEl.style.display = "none";
} else if (newValue === "failed") { } else if (newValue === "failed") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none"; loaderEl.style.display = "none";
this.shadowRoot!.querySelector<HTMLElement>("#error")!.style.display = "block"; slotEl.style.visibility = "hidden";
this.shadowRoot!.querySelector<HTMLElement>("slot")!.style.visibility = "hidden"; errorEl.style.display = "block";
const errorString = getErrorString(this.dataset.errorCode); const errorString = getErrorString(this.dataset.errorCode);
this.shadowRoot!.querySelector<HTMLElement>("#error")!.innerText = errorString; errorEl.innerText = errorString;
} }
} }
} }
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = `<style>${ this.assertHasShadowRoot();
window.Cal!.__css this.shadowRoot.innerHTML = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml}`;
}</style><style>${loaderCss}</style>${inlineHtml}`;
} }
} }
@@ -101,7 +101,7 @@ export class ModalBox extends HTMLElement {
constructor() { constructor() {
super(); 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" }); this.attachShadow({ mode: "open" });
ModalBox.htmlOverflow = document.body.style.overflow; ModalBox.htmlOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
+103 -81
View File
@@ -2,18 +2,40 @@ import { useRouter } from "next/router";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event"; import { sdkActionManager } from "./sdk-event";
export interface UiConfig { export type UiConfig = {
hideEventTypeDetails?: boolean; hideEventTypeDetails?: boolean;
theme?: "dark" | "light" | "auto"; 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 { declare global {
interface Window { interface Window {
CalEmbed: { CalEmbed: {
__logQueue?: any[]; __logQueue?: unknown[];
embedStore: any; embedStore: typeof embedStore;
}; };
CalComPageStatus: string; CalComPageStatus: string;
isEmbed: () => boolean; 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; let isSafariBrowser = false;
const isBrowser = typeof window !== "undefined"; 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) { if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/ // https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50); return setTimeout(fn, 50);
@@ -66,7 +66,7 @@ function runAsap(fn: (...arg: any) => void) {
return requestAnimationFrame(fn); return requestAnimationFrame(fn);
} }
function log(...args: any[]) { function log(...args: unknown[]) {
if (isBrowser) { if (isBrowser) {
const namespace = getNamespace(); const namespace = getNamespace();
@@ -95,7 +95,7 @@ interface EmbedStyles {
} }
interface EmbedNonStylesConfig { interface EmbedNonStylesConfig {
/** Default would be center */ /** Default would be center */
align: "left"; align?: "left";
branding?: { branding?: {
brandColor?: string; brandColor?: string;
lightColor?: string; lightColor?: string;
@@ -108,10 +108,10 @@ interface EmbedNonStylesConfig {
}; };
} }
const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => { const setEmbedStyles = (stylesConfig: EmbedStyles) => {
embedStore.styles = stylesConfig; embedStore.styles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
(setEmbedStyle as any)((styles: any) => { setEmbedStyle((styles) => {
return { return {
...styles, ...styles,
...stylesConfig, ...stylesConfig,
@@ -120,15 +120,48 @@ const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
} }
}; };
const registerNewSetter = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig, setStyles: any) => { const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
embedStore.reactStylesStateSetters[elementName] = setStyles; embedStore.nonStyles = stylesConfig;
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe. for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
// So, we should call the setter immediately with available embedStyles setEmbedStyle((styles) => {
setStyles(embedStore.styles); return {
...styles,
...stylesConfig,
};
});
}
}; };
const removeFromEmbedStylesSetterMap = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig) => { const registerNewSetter = (
delete embedStore.reactStylesStateSetters[elementName]; 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) { 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 // 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) => { export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [styles, setStyles] = useState({} as EmbedStyles); const [, setStyles] = useState<EmbedStyles>({});
useEffect(() => { useEffect(() => {
registerNewSetter(elementName, setStyles); return registerNewSetter({ elementName, setState: setStyles, styles: true });
// 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. // eslint-disable-next-line react-hooks/exhaustive-deps
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
}, []); }, []);
const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used.
return styles[elementName] || {}; return styles[elementName] || {};
}; };
export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => { export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => {
const [styles, setStyles] = useState({} as EmbedNonStylesConfig); const [, setNonStyles] = useState({} as EmbedNonStylesConfig);
useEffect(() => { useEffect(() => {
registerNewSetter(elementName, setStyles); return registerNewSetter({ elementName, setState: setNonStyles, styles: false });
// 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. // eslint-disable-next-line react-hooks/exhaustive-deps
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
}, []); }, []);
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 = () => { export const useIsBackgroundTransparent = () => {
@@ -251,8 +279,8 @@ function unhideBody() {
document.body.style.visibility = "visible"; 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 // It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
export const methods = { const methods = {
ui: function style(uiConfig: UiConfig) { ui: function style(uiConfig: UiConfig) {
// TODO: Create automatic logger for all methods. Useful for debugging. // TODO: Create automatic logger for all methods. Useful for debugging.
log("Method: ui called", uiConfig); log("Method: ui called", uiConfig);
@@ -277,8 +305,10 @@ export const methods = {
} }
setEmbedStyles(stylesConfig || {}); setEmbedStyles(stylesConfig || {});
setEmbedNonStyles(stylesConfig || {});
}, },
parentKnowsIframeReady: () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars
parentKnowsIframeReady: (_unused: unknown) => {
log("Method: `parentKnowsIframeReady` called"); log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() { runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight // 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( parent.postMessage(
{ {
originator: "CAL", originator: "CAL",
@@ -345,11 +382,13 @@ function keepParentInformedAboutDimensionChanges() {
const contentHeight = Math.ceil( const contentHeight = Math.ceil(
parseFloat(mainElementStyles.height) + parseFloat(mainElementStyles.height) +
parseFloat(mainElementStyles.marginTop) + parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom)); parseFloat(mainElementStyles.marginBottom)
);
const contentWidth = Math.ceil( const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) + parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) + parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)); parseFloat(mainElementStyles.marginRight)
);
// During first render let iframe tell parent that how much is the expected height to avoid scroll. // 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. // Parent would set the same value as the height of iframe which would prevent scroll.
@@ -394,35 +433,18 @@ if (isBrowser) {
sdkActionManager?.on("*", (e) => { sdkActionManager?.on("*", (e) => {
const detail = e.detail; const detail = e.detail;
//console.log(detail.fullType, detail.type, detail.data);
log(detail); log(detail);
messageParent(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) => { window.addEventListener("message", (e) => {
const data: Record<string, any> = e.data; const data: Message = e.data;
if (!data) { if (!data) {
return; return;
} }
const method: keyof typeof methods = data.method; const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") { 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 { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline"; import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox"; 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 css from "./embed.css";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import { SdkActionManager } 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. // 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. // This is happening because we are not able to generate an App and a lib using single Vite Config.
const WEBAPP_URL = const WEBAPP_URL =
@@ -18,10 +27,20 @@ customElements.define("cal-inline", Inline);
declare module "*.css"; declare module "*.css";
type Namespace = string; type Namespace = string;
type Config = { type Config = {
origin: string; calOrigin: string;
debug?: boolean; debug?: boolean;
uiDebug?: 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; const globalCal = window.Cal;
if (!globalCal || !globalCal.q) { if (!globalCal || !globalCal.q) {
@@ -35,9 +54,22 @@ globalCal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as strin
globalCal.__css = allCss; globalCal.__css = allCss;
document.head.appendChild(document.createElement("style")).innerHTML = css; document.head.appendChild(document.createElement("style")).innerHTML = css;
function log(...args: any[]) { function log(...args: unknown[]) {
console.log(...args); 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 * //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. * A very simple data validator written with intention of keeping payload size low.
@@ -45,8 +77,8 @@ function log(...args: any[]) {
* @param data * @param data
* @param schema * @param schema
*/ */
function validate(data: any, schema: Record<"props" | "required", any>) { function validate(data: Record<string, unknown>, schema: ValidationSchema) {
function checkType(value: any, expectedType: any) { function checkType(value: unknown, expectedType: ValidationSchemaPropType) {
if (typeof expectedType === "string") { if (typeof expectedType === "string") {
return typeof value == expectedType; return typeof value == expectedType;
} else { } 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"; return typeof data === "undefined";
} }
@@ -62,7 +94,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
throw new Error("Argument is required"); 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])) { if (propSchema.required && isUndefined(data[prop])) {
throw new Error(`"${prop}" is required`); 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[]; export type InstructionQueue = Instruction[];
type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
iframeAttrs?: Record<string, string> & {
id?: string;
};
};
export class Cal { export class Cal {
iframe?: HTMLIFrameElement; iframe?: HTMLIFrameElement;
@@ -100,35 +146,42 @@ export class Cal {
iframeReady!: boolean; iframeReady!: boolean;
iframeDoQueue: { method: keyof typeof methods; arg: any }[] = []; iframeDoQueue: DoInIframeArg[] = [];
api: CalApi;
static actionsManagers: Record<Namespace, SdkActionManager>; static actionsManagers: Record<Namespace, SdkActionManager>;
static getQueryObject(config: Record<string, string>) { static getQueryObject(config: PrefillAndIframeAttrsConfig) {
config = config || {}; config = config || {};
return { return {
...config, ...config,
// guests is better for API but Booking Page accepts guest. So do the mapping // guests is better for API but Booking Page accepts guest. So do the mapping
guest: config.guests ?? undefined, guest: config.guests ?? undefined,
}; } as PrefillAndIframeAttrsConfig & { guest?: string | string[] };
} }
processInstruction(instruction: Instruction) { processInstruction(instructionAsArgs: IArguments | Instruction) {
instruction = [].slice.call(instruction, 0); // The instruction is actually an array-like object(arguments). Make it an array.
if (instruction[0] instanceof 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 // It is an instruction
instruction.forEach((instruction) => { instruction.forEach((instruction) => {
this.processInstruction(instruction); this.processInstruction(instruction);
}); });
return; return;
} }
const [method, ...args] = instruction; const [method, ...args] = instruction;
if (!this[method]) { if (!this.api[method]) {
// Instead of throwing error, log and move forward in the queue // Instead of throwing error, log and move forward in the queue
log(`Instruction ${method} not FOUND`); log(`Instruction ${method} not FOUND`);
} }
try { 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) { } catch (e) {
// Instead of throwing error, log and move forward in the queue // Instead of throwing error, log and move forward in the queue
log(`Instruction couldn't be executed`, e); log(`Instruction couldn't be executed`, e);
@@ -136,7 +189,7 @@ export class Cal {
return instruction; return instruction;
} }
processQueue(queue: InstructionQueue) { processQueue(queue: IArguments[]) {
queue.forEach((instruction) => { queue.forEach((instruction) => {
this.processInstruction(instruction); this.processInstruction(instruction);
}); });
@@ -153,9 +206,11 @@ export class Cal {
createIframe({ createIframe({
calLink, calLink,
queryObject = {}, queryObject = {},
calOrigin,
}: { }: {
calLink: string; calLink: string;
queryObject?: Record<string, string | string[] | Record<string, string>>; queryObject?: PrefillAndIframeAttrsConfig & { guest?: string | string[] };
calOrigin?: string;
}) { }) {
const iframe = (this.iframe = document.createElement("iframe")); const iframe = (this.iframe = document.createElement("iframe"));
iframe.className = "cal-embed"; iframe.className = "cal-embed";
@@ -163,7 +218,7 @@ export class Cal {
const config = this.getConfig(); const config = this.getConfig();
const { iframeAttrs, ...restQueryObject } = queryObject; const { iframeAttrs, ...restQueryObject } = queryObject;
if (iframeAttrs && typeof iframeAttrs !== "string" && !(iframeAttrs instanceof Array)) { if (iframeAttrs && iframeAttrs.id) {
iframe.setAttribute("id", 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")) { 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" // 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`; urlInstance.pathname = `${urlInstance.pathname}/embed`;
@@ -203,252 +258,34 @@ export class Cal {
return iframe; 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() { getConfig() {
return this.__config; return this.__config;
} }
// TODO: Maintain exposed methods in a separate namespace, so that unexpected methods don't become instructions doInIframe(doInIframeArg: DoInIframeArg) {
/**
* 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 }) {
if (!this.iframeReady) { if (!this.iframeReady) {
this.iframeDoQueue.push({ method, arg }); this.iframeDoQueue.push(doInIframeArg);
return; 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. if (!this.iframe) {
this.iframe!.contentWindow!.postMessage({ originator: "CAL", method, arg }, "*"); 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 = { this.__config = {
// Use WEBAPP_URL till full page reload problem with website URL is solved // 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.namespace = namespace;
this.actionManager = new SdkActionManager(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 // 3. Parent window would check what iframe height can be set according to parent Element
this.actionManager.on("__dimensionChanged", (e) => { this.actionManager.on("__dimensionChanged", (e) => {
const { data } = e.detail; const { data } = e.detail;
const iframe = this.iframe!; const iframe = this.iframe;
if (!iframe) { if (!iframe) {
// Iframe might be pre-rendering // Iframe might be pre-rendering
return; return;
} }
let unit = "px"; const unit = "px";
if (data.__unit) {
unit = data.__unit;
}
if (data.iframeHeight) { if (data.iframeHeight) {
iframe.style.height = data.iframeHeight + unit; iframe.style.height = data.iframeHeight + unit;
} }
// if (data.iframeWidth) {
// iframe.style.width = data.iframeWidth + unit;
// }
if (this.modalBox) { 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 // 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. // This case is reproducible when viewing in ModalBox on Mobile.
@@ -490,9 +320,9 @@ export class Cal {
this.actionManager.on("__iframeReady", () => { this.actionManager.on("__iframeReady", () => {
this.iframeReady = true; this.iframeReady = true;
this.doInIframe({ method: "parentKnowsIframeReady", arg: undefined }); this.doInIframe({ method: "parentKnowsIframeReady" } as const);
this.iframeDoQueue.forEach(({ method, arg }) => { this.iframeDoQueue.forEach((doInIframeArg) => {
this.doInIframe({ method, arg }); this.doInIframe(doInIframeArg);
}); });
}); });
@@ -523,34 +353,276 @@ export class Cal {
} }
} }
export interface GlobalCal { class CalApi {
(methodName: string, arg?: any): void; 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. */ /** Marks that the embed.js is loaded. Avoids re-downloading it. */
loaded?: boolean; loaded?: boolean;
/** Maintains a queue till the time embed.js isn't loaded */ /** Maintains a queue till the time embed.js isn't loaded */
q?: InstructionQueue; q: IArguments[];
/** If user registers multiple namespaces, those are available here */ /** If user registers multiple namespaces, those are available here */
ns?: Record<string, GlobalCal>;
instance?: Cal; instance?: Cal;
__css?: string; __css?: string;
fingerprint?: 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 { declare global {
interface Window { interface Window {
Cal?: GlobalCal; Cal: GlobalCal;
} }
} }
export interface CalWindow extends Window { export interface CalWindow extends Window {
Cal?: GlobalCal; Cal: GlobalCal;
} }
globalCal.instance = new Cal("", globalCal.q!); globalCal.instance = new Cal("", globalCal.q);
for (const [ns, api] of Object.entries(globalCal.ns)) {
for (const [ns, api] of Object.entries(globalCal.ns!)) { api.instance = new Cal(ns, api.q);
api.instance = new Cal(ns, api.q!);
} }
/** /**
@@ -571,38 +643,45 @@ window.addEventListener("message", (e) => {
if (!actionManager) { if (!actionManager) {
throw new Error("Unhandled Action" + parsedAction); throw new Error("Unhandled Action" + parsedAction);
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
actionManager.fire(parsedAction.type, detail.data); actionManager.fire(parsedAction.type, detail.data);
}); });
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const htmlElement = e.target; const targetEl = e.target;
if (!(htmlElement instanceof HTMLElement)) { if (!(targetEl instanceof HTMLElement)) {
return; return;
} }
const path = htmlElement.dataset.calLink; const path = targetEl.dataset.calLink;
if (!path) { if (!path) {
return; return;
} }
const modalUniqueId = ((htmlElement as unknown as any).uniqueId = const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
(htmlElement as unknown as any).uniqueId || Date.now()); const namespace = targetEl.dataset.calNamespace;
const namespace = htmlElement.dataset.calNamespace; const configString = targetEl.dataset.calConfig || "";
const configString = htmlElement.dataset.calConfig || ""; const calOrigin = targetEl.dataset.calOrigin || "";
let config; let config;
try { try {
config = JSON.parse(configString); config = JSON.parse(configString);
} catch (e) { } catch (e) {
config = {}; config = {};
} }
let api = globalCal;
let api: GlobalCalWithoutNs = globalCal;
if (namespace) { if (namespace) {
api = globalCal.ns![namespace]; api = globalCal.ns[namespace];
} }
if (!api) { if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`); throw new Error(`Namespace ${namespace} isn't defined`);
} }
api("modal", { api("modal", {
calLink: path, calLink: path,
config, config,
uid: modalUniqueId, 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}`; 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`; 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 // Install Cal Embed Code Snippet
(function (C, A, L) { (function (C, A, L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const p = function (a: any, ar: any) { const p = function (a: any, ar: any) {
a.q.push(ar); 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 =
C.Cal || C.Cal ||
function () { function () {
const cal = C.Cal!; const cal = C.Cal;
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
const ar = arguments; const ar = arguments;
if (!cal.loaded) { if (!cal.loaded) {
@@ -23,13 +22,16 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
cal.loaded = true; cal.loaded = true;
} }
if (ar[0] === L) { if (ar[0] === L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api: { (): void; q?: any[] } = function () { const api: { (): void; q?: any[] } = function () {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
p(api, arguments); p(api, arguments);
}; };
const namespace = ar[1]; const namespace = ar[1];
api!.q = api.q || []; api.q = api.q || [];
typeof namespace === "string" ? (cal!.ns![namespace] = api) && p(api, ar) : p(cal, ar); // 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; return;
} }
p(cal, ar); 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"); })(window, EMBED_LIB_URL, "init");
const previewWindow = window; 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, origin: WEBAPP_URL,
}); });
const searchParams = new URL(document.URL).searchParams; const searchParams = new URL(document.URL).searchParams;
const embedType = searchParams.get("embedType"); const embedType = searchParams.get("embedType");
const calLink = searchParams.get("calLink"); const calLink = searchParams.get("calLink");
if (embedType! === "inline") { if (!calLink) {
previewWindow.Cal!("inline", { throw new Error('Missing "calLink" query parameter');
}
if (embedType === "inline") {
previewWindow.Cal("inline", {
elementOrSelector: "#my-embed", elementOrSelector: "#my-embed",
calLink: calLink, calLink: calLink,
}); });
} else if (embedType === "floating-popup") { } else if (embedType === "floating-popup") {
previewWindow.Cal!("floatingButton", { previewWindow.Cal("floatingButton", {
calLink: calLink, calLink: calLink,
attributes: { attributes: {
id: "my-floating-button", id: "my-floating-button",
@@ -58,7 +64,7 @@ if (embedType! === "inline") {
}); });
} else if (embedType === "element-click") { } else if (embedType === "element-click") {
const button = document.createElement("button"); 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"; button.innerHTML = "I am a button that exists on your website";
document.body.appendChild(button); document.body.appendChild(button);
} }
@@ -1,5 +1,5 @@
type Namespace = string; type Namespace = string;
type CustomEventDetail = Record<string, any>; type CustomEventDetail = Record<string, unknown>;
function _fireEvent(fullName: string, detail: CustomEventDetail) { function _fireEvent(fullName: string, detail: CustomEventDetail) {
const event = new window.CustomEvent(fullName, { const event = new window.CustomEvent(fullName, {
@@ -9,6 +9,53 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
window.dispatchEvent(event); 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 { export class SdkActionManager {
namespace: Namespace; namespace: Namespace;
@@ -31,7 +78,7 @@ export class SdkActionManager {
return this.namespace ? `CAL:${this.namespace}:${name}` : `CAL::${name}`; 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 fullName = this.getFullActionName(name);
const detail = { const detail = {
type: name, type: name,
@@ -46,12 +93,12 @@ export class SdkActionManager {
_fireEvent(this.getFullActionName("*"), detail); _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); const fullName = this.getFullActionName(name);
window.addEventListener(fullName, callback as EventListener); 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); const fullName = this.getFullActionName(name);
window.removeEventListener(fullName, callback as EventListener); window.removeEventListener(fullName, callback as EventListener);
} }
+1
View File
@@ -1,6 +1,7 @@
{ {
"extends": "@calcom/tsconfig/base.json", "extends": "@calcom/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2015",
"module": "esnext", "module": "esnext",
"paths": { "paths": {
"@lib/*": ["../../../apps/web/lib/*"] "@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"));
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more