Compare commits

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

* Fix tests

* Clean up

* Update packages/core/EventManager.ts

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

* Commit fix

---------

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

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

* fix: add zod schema, fix type eroor

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

---------

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

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

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

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

* chore: change name of variable

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

---------

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

* iCal & Apple add team members

* Exchange add team members

* iCal & App cal update include attendees

* Exchange reschedule

* GCal add team members on reschedule

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

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

* remove $ prefix

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

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

* pass responses in same format

---------

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

* figma 1-1

* Style X

* Fixes timezone select

* Add location select styles

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

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

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

---------

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

* figma 1-1

* Style X

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

* fix: add dangerously set inner html

* fix: use markdown

* fix: add remove html regex

* fix: revert dangerouslySetInnerHTML

* fix: title

* added yarn.lock

* yarn

* fix: yarn lock

* Update yarn.lock

* Update yarn lock

* Revert yarn lock changes

---------

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

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

* code clean up

---------

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

* send start and end date in UTC

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

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

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

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

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

---------

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

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

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

* Fixed linting

* Add seats logic to reschedule

---------

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

* fix: revert padding top and bottom

* fix: remove large prop

* fix: remove large prop

* feat: make md default

---------

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

* chore : wrap actions inside useMemo

---------

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

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

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

---------

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

* fix: remove aditional check
2023-03-16 03:21:59 +00:00
Alex van Andel 0f6a678b74 Disable PRISMA_GENERATE_DATAPROXY 2023-03-16 01:07:02 +00:00
Omar López 96308cd607 v2.7.0
release.yml / release (push) Canceled after 0s
2023-03-15 15:39:45 -07:00
Joe Au-YeungandAlex van Andel 91464fb5e5 Revert - create new bookings - writing team members to DB (#7756)
* Bring back creating team members

* Clean up console.logs

* Push into attendeesData

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-15 15:38:57 -07:00
zomars 49d6dd9802 Updates yarn version 2023-03-15 15:31:22 -07:00
54cefcb16e Upgrades yarn v1 to v3. Improves CI times by 2x (#7738)
* 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

---------

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-15 15:01:04 -07:00
Omar LópezandPeer Richelsen f20d78bec1 Adds prisma data proxy (#7706)
* Adds prisma data proxy

* Fix for pipeline

* Update gh.env

* Update env-create-file.yml

* Reverts some changes

* Update turbo.json

* Update .env.example

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-15 20:33:54 +00:00
zomars 089f52b425 Update yarn.lock 2023-03-15 13:17:43 -07:00
47bf6d27d9 chore: bump node version to v18 (#7310)
* chore: bump node version to v18

* fix(web): support node 19 as accepted

* fix(web): update boxyhq/saml-jackson to 1.8.1

* Drop support for Node 16.x

* Removed n19 pending @azure/msal-node

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: zomars <zomars@me.com>
2023-03-15 20:15:16 +00:00
Richard Poelderl 2cc937ba87 chore: Deactivate automatic link prefetching for /blog links (#7751)
* disable prefetch on the `Tips` cards
2023-03-15 21:08:29 +01:00
Nafees Nazik a57519040e Fix: hydration error /booking/[uid] (#7732) 2023-03-15 21:07:35 +01:00
Nafees Nazik 3924731266 fix: undefined reason (#7745) 2023-03-15 20:07:14 +00:00
Keith Williams 74986bb6a3 Events not being added to outlook (#7726) 2023-03-15 21:04:35 +01:00
Joe Au-Yeung 9568e16ef6 Improvements to teams for self hosters (#7753)
* Write slug when no team billing

* Remove recommended actions for invites
2023-03-15 15:09:43 -04:00
Alex van Andelandzomars e88716758e Remove event type dupes, assign ownership based on existing _user_eventtype.'B' (#7284)
Co-authored-by: zomars <zomars@me.com>
2023-03-15 15:35:54 +00:00
Alex van Andel fcb06a131b Bugfix/allow impersonation non cal id p (#7689)
* Only require a password when IdP is CAL

* Also require twoFactorEnabled
2023-03-15 14:52:32 +00:00
Alex van Andel d778fe6a1b Does case insensitive select, then overwrites email with IdP email (#7673) 2023-03-15 14:52:18 +00:00
Peer Richelsen 877f72d11a fixed leading for labels 2023-03-15 14:04:55 +01:00
f728272c7f Removed email & name placeholder from booking page (#7708)
Co-authored-by: Nafees Nazik <84864519+G3root@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-15 15:04:45 +05:30
Omar López 84fd277a97 Adds new responses to zapier payload (#7736) 2023-03-15 14:28:50 +05:30
ae0ed4176b fixes #7548 - Scroll bar because auto height adjustment doesn't take <0.5 values into account (#7549)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-03-15 14:25:11 +05:30
ec4228ab6a feat(app-store): add zohocrm app to app-store (#7182)
* Add zoho-app to the app-store

* Update packages/app-store/zohocrm/api/_getAdd.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* 1. Remove redundant check for `defaultHandler` 2. Restore yarn.lock`

* update images

* update README with zoho integration

* Fix dirname

* Fix types

* Fix lastname bug

* Fix timezone issue

* Fix eslint warning for unused args

* Revert yarn.lock

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-15 07:50:03 +00:00
zomars 19b1f8a05f Removes Snaplet actions for now 2023-03-14 16:24:42 -07:00
Peer Richelsen 1ba6b08edf cal video: show meeting info in a hideable box (#7295) 2023-03-14 19:43:45 +00:00
Peer Richelsen f027f018ff added screenshot for wipe my cal (#7723) 2023-03-14 13:33:16 +00:00
Udit Takkar 9fd7ca65b5 fix: use title instead of eventtype (#7720) 2023-03-14 13:52:22 +01:00
Nafees NazikandKeith Williams c0f4c8e035 fix: bookings request reschedule UI badge (#7436)
* feat: update badge component

* fix: use the camel cased startIcon prop

* fix: reschedule request badge

* fix: use the camel cased startIcon prop

* fix: remove bold prop

* Removed extra whitespace in HTML

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-03-14 08:34:41 -04:00
ed750c8df1 Seated booking rescheduling. (#5427)
* WIP-already-reschedule-success-emails-missing

* WIP now saving bookingSeatsReferences and identifyin on reschedule/book page

* Remove logs and created test

* WIP saving progress

* Select second slot to pass test

* Delete attendee from event

* Clean up

* Update with main changes

* Fix emails not being sent

* Changed test end url from success to booking

* Remove unused pkg

* Fix new booking reschedule

* remove log

* Renable test

* remove unused pkg

* rename table name

* review changes

* Fix and and other test to reschedule with seats

* Fix api for cancel booking

* Typings

* Update [uid].tsx

* Abstracted common pattern

into maybeGetBookingUidFromSeat

* Reverts

* Nitpicks

* Update handleCancelBooking.ts

* Adds missing cascades

* Improve booking seats changes (#6858)

* Create sendCancelledSeatEmails

* Draft attendee cancelled seat email

* Send no longer attendee email to attendee

* Send email to organizer when attendee cancels

* Pass cloned event data to emails

* Send booked email for first seat

* Add seat reference uid from email link

* Query for seatReferenceUId and add to cancel & reschedule links

* WIP

* Display proper attendee when rescheduling seats

* Remove console.logs

* Only check for already invited when not rescheduling

* WIP sending reschedule email to just single attendee and owner

* Merge branch 'main' into send-email-on-seats-attendee-changes

* Remove console.logs

* Add cloned event to seat emails

* Do not show manage link for calendar event

* First seat, have both attendees on calendar

* WIP refactor booking seats reschedule logic

* WIP Refactor handleSeats

* Change relation of attendee & seat reference to a one-to-one

* Migration with relationship change

* Booking page handling unique seat references

* Abstract to handleSeats

* Remove console.logs and clean up

* New migration file, delete on cascade

* Check if attendee is already a part of the booking

* Move deleting booking logic to `handleSeats`

* When owner reschedule, move whole booking

* Prevent owner from rescheduling if not enough seats

* Add owner reschedule

* Send reschedule email when moving to new timeslot

* Add event data to reschedule email for seats

* Remove DB changes from event manager

* When a booking has no attendees then delete

* Update calendar when merging bookings

* Move both attendees and seat references when merging

* Remove guest list from seats booking page

* Update original booking when moving an attendee

* Delete calendar and video events if no more attendees

* Update or delete integrations when attendees cancel

* Show no longer attendee if a single attendee cancels

* Change booking to accepted if an attendee books on an empty booking

* If booking in same slot then just return the booking

* Clean up

* Clean up

* Remove booking select

* Typos

---------

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

* Fix migration table name

* Add missing trpc import

* Rename bookingSeatReferences to bookingSeat

* Change relationship between Attendee & BookingSeat to one to one

* Fix some merge conflicts

* Minor merge artifact fixup

* Add the right 'Person' type

* Check on email, less (although still) editable than name

* Removed calEvent.attendeeUniqueId

* rename referenceUId -> referenceUid

* Squashes migrations

* Run cached installs

Should still be faster. Ensures prisma client is up to date.

* Solve attendee form on booking page

* Remove unused code

* Some type fixes

* Squash migrations

* Type fixes

* Fix for reschedule/[uid] redirect

* Fix e2e test

* Solve double declaration of host

* Solve lint errors

* Drop constraint only if exists

* Renamed UId to Uid

* Explicit vs. implicit

* Attempt to work around text flakiness by adding a little break between animations

* Various bugfixes

* Persistently apply seatReferenceUid (#7545)

* Persistently apply seatReferenceUid

* Small ts fix

* Setup guards correctly

* Type fixes

* Fix render 0 in conditional

* Test refactoring

* Fix type on handleSeats

* Fix handle seats conditional

* Fix type inference

* Update packages/features/bookings/lib/handleNewBooking.ts

* Update apps/web/components/booking/pages/BookingPage.tsx

* Fix type and missing logic for reschedule

* Fix delete of calendar event and booking

* Add handleSeats return type

* Fix seats booking creation

* Fall through normal booking for initial booking, handleSeats for secondary/reschedule

* Simplification of fetching booking

* Enable seats for round-robin events

* A lot harder than I expected

* ignore-owner-if-seat-reference-given

* Return seatReferenceUid when second seat

* negate userIsOwner

* Fix booking seats with a link without bookingUid

* Needed a time check otherwise the attendee will be in the older booking

* Can't open dialog twice in test..

* Allow passing the booking ID from the server

* Fixed isCancelled check, fixed test

* Delete through cascade instead of multiple deletes

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-14 04:19:05 +00:00
d545a9e127 Fix save booking title unescaped (#7591)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-03-13 22:03:06 +00:00
zomars 6c55138589 Update crowdin.yml 2023-03-13 11:35:36 -07:00
LucasandPeer Richelsen 18414e857a Fix user time format not respected and booking dates not localized on emails (#7682)
* Localized booking date

* re add space

* Add locale per format call due to lambda dayjs instance share between users

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-13 16:49:48 +00:00
github-actions[bot]andCrowdin Bot 1c8f9cb3fd New Crowdin translations by Github Action (#7675)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2023-03-13 11:55:43 +01:00
Efraín Rochín 0871314d53 Amend logic to figure out if selected slot is available (#7684) 2023-03-13 09:59:19 +00:00
Peer Richelsenandzomars 02d70c63a5 empty screen for unpublished teams (#7206)
* empty screen for unpublished teams

* i18n for unpublished team, removed contact button, added avatar to EmptyScreen

* Merge branch 'main' into 7202-cal-1114-teamnull-better-404-screen

# Conflicts:
#	apps/web/public/static/locales/en/common.json

* Adds backend logic

* Type fixes

---------

Co-authored-by: zomars <zomars@me.com>
2023-03-12 23:31:55 +00:00
Udit TakkarandPeer Richelsen 71a3374930 fix: add confirm and reject icon in email (#7457)
* fix: add confirm and reject icon in email

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

* fix: remove is_production

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

* wip

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

* style: calltoAction

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

* fix: brightness

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

* fix: styling of button

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-12 17:39:46 +00:00
sean-brydon a22e4eed32 Fixes weird background color (#7678) 2023-03-12 15:54:03 +00:00
Keith Williams 1625f462c2 Fixed linting issues in Playwright config (#7662) 2023-03-12 09:39:08 +01:00
347 changed files with 49919 additions and 31630 deletions
+13 -1
View File
@@ -15,16 +15,22 @@
# - LARK
# - WEB3
# - SALESFORCE
# - ZOHOCRM
# - APP STORE **********************************************************************************************
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
# - DAILY.CO VIDEO
# Enables Cal Video. to get your key
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
# 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
# @see https://github.com/calcom/cal.com#obtaining-daily-api-credentials
DAILY_API_KEY=
DAILY_SCALE_PLAN=''
# - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS=
# To enable Login with Google you need to:
@@ -109,4 +115,10 @@ INFURA_API_KEY=""
# Used for the Salesforce (Sales Cloud) app
SALESFORCE_CONSUMER_KEY=""
SALESFORCE_CONSUMER_SECRET=""
# - ZOHOCRM
# Used for the Zoho CRM integration
ZOHOCRM_CLIENT_ID=""
ZOHOCRM_CLIENT_SECRET=""
# *********************************************************************************************************
+7
View File
@@ -22,6 +22,13 @@ CALCOM_LICENSE_KEY=
# - DATABASE ************************************************************************************************
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
# PRISMA_GENERATE_DATAPROXY=true
PRISMA_GENERATE_DATAPROXY=
# ***********************************************************************************************************
# - SHARED **************************************************************************************************
+6
View File
@@ -0,0 +1,6 @@
node_modules
**/**/node_modules
**/**/.next
**/**/public
packages/prisma/zod
apps/web/public/embed
+2 -1
View File
@@ -3,7 +3,7 @@ description: "Cache or restore if necessary"
inputs:
node_version:
required: false
default: v16.x
default: v18.x
runs:
using: "composite"
steps:
@@ -20,6 +20,7 @@ runs:
with:
path: |
${{ github.workspace }}/apps/web/.next
${{ github.workspace }}/apps/web/public/embed
**/.turbo/**
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
+49 -6
View File
@@ -1,9 +1,21 @@
name: Yarn install
description: "Install all NPM dependencies, caches them and restores if necessary"
########################################################################################
# "yarn install" composite action for yarn 2/3/4+ and "nodeLinker: node-modules" #
#--------------------------------------------------------------------------------------#
# Cache: #
# - Downloaded zip archive (multi-arch, preserved across yarn.lock changes) #
# - Yarn install state (discarded on yarn.lock changes) #
# References: #
# - bench: https://gist.github.com/belgattitude/0ecd26155b47e7be1be6163ecfbb0f0b #
# - vs @setup/node: https://github.com/actions/setup-node/issues/325 #
########################################################################################
name: "Yarn install"
description: "Run yarn install with node_modules linker and cache enabled"
inputs:
node_version:
required: false
default: v16.x
default: v18.x
runs:
using: "composite"
steps:
@@ -11,9 +23,40 @@ runs:
uses: actions/setup-node@v3
with:
node-version: ${{ inputs.node_version }}
cache: "yarn"
- name: Yarn install
- name: Expose yarn config as "$GITHUB_OUTPUT"
id: yarn-config
shell: bash
run: |
yarn install --prefer-offline --frozen-lockfile
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
# Yarn cache is also reusable between arch and os.
- name: Restore yarn cache
uses: actions/cache@v3
id: yarn-download-cache
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-download-cache-
# Invalidated on yarn.lock changes
- name: Restore yarn install state
id: yarn-install-state-cache
uses: actions/cache@v3
with:
path: .yarn/ci-cache/
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
- name: Install dependencies
shell: bash
run: |
yarn install --inline-builds
yarn prisma generate
env:
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
YARN_ENABLE_IMMUTABLE_INSTALLS: "false" # So it doesn't try to remove our private submodule deps
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
# Other environment variables
HUSKY: "0" # By default do not run HUSKY install
@@ -1,20 +0,0 @@
name: Cleanup Preview Databases
env:
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
SNAPLET_PROJECT_ID: cl4u26bwz7962859ply7ibuo43t
on:
pull_request:
types: [closed]
branches:
- main
jobs:
delete:
runs-on: ubuntu-latest
steps:
- uses: snaplet/action@v1
if: ${{ env.SNAPLET_ACCESS_TOKEN && env.SNAPLET_PROJECT_ID }}
with:
delete: true
-2
View File
@@ -18,5 +18,3 @@ jobs:
days-before-stale: 60
include-only-assigned: true
days-before-close: -1
+6 -3
View File
@@ -17,11 +17,14 @@ jobs:
uses: actions/checkout@v3
- name: crowdin action
uses: crowdin/github-action@1.4.9
uses: crowdin/github-action@1.5.1
with:
upload_translations: true
upload_sources: true
download_translations: true
pull_request_labels: "♻️ autoupdate, automerge"
push_translations: true
commit_message: "New Crowdin translations by Github Action"
localization_branch_name: main
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
-37
View File
@@ -1,37 +0,0 @@
# .github/workflows/preview.yml
name: Preview Environment
env:
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
SNAPLET_PROJECT_ID: ${{ secrets.SNAPLET_PROJECT_ID }}
VERCEL_ACCESS_TOKEN: ${{ secrets.VERCEL_ACCESS_TOKEN }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
pull_request:
types: [opened, synchronize, closed]
branches:
- main
jobs:
db-preview-deploy:
if: ${{ github.event.action == 'opened' || github.event.action == 'synchronize' }}
runs-on: ubuntu-latest
steps:
- id: snaplet
uses: snaplet/action@v1
- uses: snaplet/vercel-action@v1
with:
env: |
DATABASE_URL=${{ steps.snaplet.outputs.database-url }}
db-preview-delete:
if: ${{ github.event.action == 'closed' }}
runs-on: ubuntu-latest
steps:
- uses: snaplet/action@v1
with:
delete: true
- uses: snaplet/vercel-action@v1
with:
delete: true
-18
View File
@@ -1,18 +0,0 @@
name: Staging DB Snapshot
on:
schedule:
- cron: "0 4 * * 1-5" # At 04:00 on every day-of-week from Monday through Friday.
workflow_dispatch:
jobs:
db-staging-snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Snaplet CLI
run: curl -sL https://app.snaplet.dev/get-cli/ | bash
- name: Restore Snapshot
run: snaplet snapshot restore -y
env:
SNAPLET_DATABASE_URL: ${{ secrets.SNAPLET_DATA_TARGET_DB_URL }}
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
SNAPLET_DATABASE_ID: ${{ secrets.SNAPLET_DATABASE_ID }}
+36
View File
@@ -0,0 +1,36 @@
name: E2E Embed tests and booking flow(for non-embed as well)
on:
workflow_call:
jobs:
e2e-embed:
timeout-minutes: 20
runs-on: ubuntu-latest
services:
postgres:
image: postgres:12.1
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/env-read-file
- uses: ./.github/actions/cache-db
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn test-e2e:embed-react
env:
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v2
with:
name: test-results
path: test-results
+4 -11
View File
@@ -25,19 +25,12 @@ jobs:
- uses: ./.github/actions/cache-db
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn turbo run embed-tests-update-snapshots:ci --scope=@calcom/embed-react --concurrency=1
run: yarn test-e2e:embed
env:
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
- name: Upload embed-core results
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v2
with:
name: test-results-embed-core
path: packages/embeds/embed-core/playwright/results
- name: Upload embed-react results
if: ${{ always() }}
uses: actions/upload-artifact@v2
with:
name: test-results-embed-react
path: packages/embeds/embed-react/playwright/results
name: test-results
path: test-results
+2 -1
View File
@@ -20,7 +20,7 @@ env:
INPUT_ENV_PAYMENT_FEE_FIXED: 10
INPUT_ENV_SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
INPUT_ENV_SAML_ADMINS: pro@example.com
INPUT_ENV_NEXTAUTH_URL: http://localhost:3000/api/auth
INPUT_ENV_NEXTAUTH_URL: http://127.0.0.1:3000/api/auth
INPUT_ENV_NEXT_PUBLIC_IS_E2E: 1
# INPUT_ENV_EMAIL_FROM: e2e@cal.com
# INPUT_ENV_EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
@@ -39,6 +39,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ozaytsev86/create-env-file@v1
with:
file-name: ${{ github.workspace }}/gh.env
+11
View File
@@ -21,6 +21,7 @@ jobs:
outputs:
app-store: ${{ steps.filter.outputs.app-store }}
embed: ${{ steps.filter.outputs.embed }}
embed-react: ${{ steps.filter.outputs.embed-react }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
@@ -34,6 +35,9 @@ jobs:
embed:
- 'apps/web/**'
- 'packages/embeds/**'
embed-react:
- 'apps/web/**'
- 'packages/embeds/**'
env:
name: Create env file
@@ -81,6 +85,13 @@ jobs:
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: E2E React embeds tests
if: ${{ needs.changes.outputs.embed-react == 'true' }}
needs: [changes, lint, build]
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
needs: build
uses: ./.github/workflows/nextjs-bundle-analysis.yml
+9
View File
@@ -87,3 +87,12 @@ apps/api
apps/website
apps/console
apps/auth
# Yarn Modern
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+1 -1
View File
@@ -1 +1 @@
v16
v18
+1
View File
@@ -14,3 +14,4 @@ public
.DS_Store
.eslintignore
packages/prisma/zod
apps/web/public/embed
File diff suppressed because one or more lines are too long
+873
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-3.4.1.cjs
+15 -1
View File
@@ -88,6 +88,7 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
- [React](https://reactjs.org/?ref=cal.com)
- [Tailwind](https://tailwindcss.com/?ref=cal.com)
- [Prisma](https://prisma.io/?ref=cal.com)
- [Daily.co](https://go.cal.com/daily)
## Stay Up-to-Date
@@ -432,7 +433,7 @@ following
### Obtaining Daily API Credentials
1. Open [Daily](https://www.daily.co/) and sign into your account.
1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
3. Copy your API key.
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
@@ -451,6 +452,19 @@ following
9. Click the "Save" button at the bottom footer.
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
### Obtaining ZohoCRM Client ID and Secret
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
2. From within the API console page, go to "Applications".
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
4. Fill in any information you want in the "Client Details" tab
5. Go to tab "Client Secret" tab.
6. Now copy the Client ID and Client Secret to your .env file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
9. Click the "Save"/ "UPDATE" button at the bottom footer.
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
## Workflows
### Setting up SendGrid for Email reminders
+1 -1
View File
@@ -1 +1 @@
14.17
v18
+1 -2
View File
@@ -24,7 +24,6 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@calcom/config": "*",
"eslint": "^8.34.0"
"@calcom/config": "*"
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
node_modules
prisma/zod
public/embed
+1 -1
View File
@@ -1 +1 @@
v16
v18
+1
View File
@@ -0,0 +1 @@
public/embed
@@ -94,16 +94,6 @@ const DestinationCalendarSelector = ({
styles={{
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
option: (defaultStyles, state) => ({
...defaultStyles,
backgroundColor: state.isSelected
? state.isFocused
? "var(--brand-color)"
: "var(--brand-color)"
: state.isFocused
? "var(--brand-color-dark-mode)"
: "var(--brand-text-color)",
}),
control: (defaultStyles) => {
return {
...defaultStyles,
+78 -75
View File
@@ -327,10 +327,10 @@ const embeds: {
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white"
/>
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="2" fill="#E1E1E1" />
<rect x="48" y="86.5" width="48" height="4" rx="1" fill="#E1E1E1" />
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="6" fill="#F3F4F6" />
<rect x="48" y="86.5" width="48" height="4" rx="6" fill="#F3F4F6" />
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
@@ -378,9 +378,9 @@ const embeds: {
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" />
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="24.5" y="51" width="139" height="163" rx="6" stroke="#292929" />
<rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
</svg>
),
},
@@ -400,12 +400,12 @@ const embeds: {
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white"
/>
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" />
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
<rect x="24" y="50.5" width="120" height="76" rx="6" fill="#F3F4F6" />
<rect x="24" y="138.5" width="120" height="76" rx="6" fill="#F3F4F6" />
<rect x="156" y="50.5" width="128" height="164" rx="6" fill="#F3F4F6" />
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
<rect x="226" y="223.5" width="66" height="26" rx="6" fill="#292929" />
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
</svg>
),
@@ -426,62 +426,65 @@ const embeds: {
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white"
/>
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" fill="#F8F8F8" />
<rect opacity="0.8" x="108" y="85" width="80" height="8" rx="2" fill="#E1E1E1" />
<rect x="108" y="97" width="48" height="4" rx="1" fill="#E1E1E1" />
<rect x="109" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="110" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="133" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="169" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="181" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="169" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="181" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="136" width="6" height="6" rx="1" fill="#3E3E3E" />
<path
d="M121 135H127V133H121V135ZM128 136V142H130V136H128ZM127 143H121V145H127V143ZM120 142V136H118V142H120ZM121 143C120.448 143 120 142.552 120 142H118C118 143.657 119.343 145 121 145V143ZM128 142C128 142.552 127.552 143 127 143V145C128.657 145 130 143.657 130 142H128ZM127 135C127.552 135 128 135.448 128 136H130C130 134.343 128.657 133 127 133V135ZM121 133C119.343 133 118 134.343 118 136H120C120 135.448 120.448 135 121 135V133Z"
fill="#3E3E3E"
/>
<rect x="133" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="169" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="181" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="169" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="181" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="169" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="181" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="193" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="109" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="121" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="133" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="145" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" />
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" />
<rect x="24" y="0.50293" width="260" height="24" rx="6" fill="#F3F4F6" />
<rect x="24" y="35" width="259" height="192" rx="5.5" fill="#F9FAFB" />
<g filter="url(#filter0_i_3223_14162)">
<rect opacity="0.8" x="40" y="99" width="24" height="24" rx="2" fill="#E5E7EB" />
<rect x="40" y="127" width="48" height="8" rx="1" fill="#E5E7EB" />
<rect x="40" y="139" width="82" height="8" rx="1" fill="#E5E7EB" />
<rect x="40" y="151" width="34" height="4" rx="1" fill="#E5E7EB" />
<rect x="40" y="159" width="34" height="4" rx="1" fill="#E5E7EB" />
</g>
<rect x="152" y="48" width="2" height="169" rx="2" fill="#E5E7EB" />
<rect opacity="0.8" x="176" y="84" width="80" height="8" rx="2" fill="#E5E7EB" />
<rect x="176" y="96" width="48" height="4" rx="1" fill="#E5E7EB" />
<rect x="177" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="189" y="109" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="201" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="213" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="225" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="237" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="249" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="261" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="213" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="225" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="237" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="249" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="261" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="177" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="189" y="135" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="187.3" y="133.4" width="9" height="9" rx="1.5" stroke="#0D121D" />
<rect x="201" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="213" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="225" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="237" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="249" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="261" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="177" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="189" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="201" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="213" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="225" y="147" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="237" y="147" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="249" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="261" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="177" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="189" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="201" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="213" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="225" y="159" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="237" y="159" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="249" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="261" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="177" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="189" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="201" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="213" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="225" y="171" width="6" height="6" rx="1" fill="#0D121D" />
<rect x="237" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
<rect x="24" y="35" width="259" height="192" rx="5.5" stroke="#101010" />
<rect x="24" y="241.503" width="260" height="24" rx="6" fill="#F3F4F6" />
</svg>
),
},
@@ -629,9 +632,9 @@ const ChooseEmbedTypesDialogContent = () => {
const { t } = useLocale();
const router = useRouter();
return (
<DialogContent type="creation" size="lg">
<div className="mb-4">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
<DialogContent className="rounded-lg p-10" type="creation" size="lg">
<div className="mb-2">
<h3 className="mb-2 text-2xl font-bold leading-none text-gray-900" id="modal-title">
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
</h3>
<div>
@@ -641,7 +644,7 @@ const ChooseEmbedTypesDialogContent = () => {
<div className="flex items-start">
{embeds.map((embed, index) => (
<button
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-gray-100 ltr:mr-2 rtl:ml-2"
className="hover:border-primary-900 w-1/3 rounded-md border border-gray-200 bg-gray-50 p-6 text-left hover:rounded-md hover:bg-gray-100 ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0"
key={index}
data-testid={embed.type}
onClick={() => {
@@ -649,11 +652,11 @@ const ChooseEmbedTypesDialogContent = () => {
embedType: embed.type,
});
}}>
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
<div className="order-none box-border flex-none rounded-md border border-solid bg-white">
{embed.illustration}
</div>
<div className="mt-2 font-medium text-gray-900">{embed.title}</div>
<p className="text-sm text-gray-500">{embed.subtitle}</p>
<div className="mt-4 font-semibold leading-none text-gray-900">{embed.title}</div>
<p className="mt-2 text-sm text-gray-500">{embed.subtitle}</p>
</button>
))}
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
import { AdminRequired } from "components/ui/AdminRequired";
import noop from "lodash/noop";
import { noop } from "lodash";
import type { LinkProps } from "next/link";
import Link from "next/link";
import { useRouter } from "next/router";
+23 -14
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { useRouter } from "next/router";
import type { IframeHTMLAttributes } from "react";
import React, { useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
@@ -45,12 +46,12 @@ const Component = ({
tos,
privacy,
isProOnly,
images,
descriptionItems,
isTemplate,
dependencies,
}: Parameters<typeof App>[0]) => {
const { t } = useLocale();
const hasImages = images && images.length > 0;
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
const router = useRouter();
const mutation = useAddAppMutation(null, {
@@ -94,17 +95,25 @@ const Component = ({
return (
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
{hasImages && (
{hasDescriptionItems && (
<div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
{images ? (
images.map((img) => (
<img
key={img}
src={img}
alt={`Screenshot of app ${name}`}
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
/>
))
{descriptionItems ? (
descriptionItems.map((descriptionItem, index) =>
typeof descriptionItem === "object" ? (
<div
key={`iframe-${index}`}
className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
<iframe allowFullScreen {...descriptionItem.iframe} />
</div>
) : (
<img
key={descriptionItem}
src={descriptionItem}
alt={`Screenshot of app ${name}`}
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
/>
)
)
) : (
<SkeletonText />
)}
@@ -113,7 +122,7 @@ const Component = ({
<div
className={classNames(
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
hasImages && "lg:ml-8"
hasDescriptionItems && "lg:ml-8"
)}>
<div className="mb-8 flex pt-4">
<header>
@@ -354,7 +363,7 @@ export default function App(props: {
privacy?: string;
licenseRequired: AppType["licenseRequired"];
isProOnly: AppType["isProOnly"];
images?: string[];
descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
isTemplate?: boolean;
disableInstall?: boolean;
dependencies?: string[];
@@ -96,16 +96,6 @@ const DestinationCalendarSelector = ({
styles={{
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
option: (defaultStyles, state) => ({
...defaultStyles,
backgroundColor: state.isSelected
? state.isFocused
? "var(--brand-color)"
: "var(--brand-color)"
: state.isFocused
? "var(--brand-color-dark-mode)"
: "var(--brand-text-color)",
}),
control: (defaultStyles) => {
return {
...defaultStyles,
+16 -3
View File
@@ -24,6 +24,7 @@ type AvailableTimesProps = {
eventTypeSlug: string;
date?: Dayjs;
seatsPerTimeSlot?: number | null;
bookingAttendees?: number | null;
slots?: Slot[];
isLoading: boolean;
ethSignature?: string;
@@ -39,6 +40,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
timeFormat,
onTimeFormatChange,
seatsPerTimeSlot,
bookingAttendees,
ethSignature,
}) => {
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
@@ -92,7 +94,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
slots.map((slot) => {
type BookingURL = {
pathname: string;
query: Record<string, string | number | string[] | undefined>;
query: Record<string, string | number | string[] | undefined | TimeFormat>;
};
const bookingUrl: BookingURL = {
pathname: router.pathname.endsWith("/embed") ? "../book" : "book",
@@ -101,6 +103,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
date: dayjs.utc(slot.time).tz(timeZone()).format(),
type: eventTypeId,
slug: eventTypeSlug,
timeFormat,
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
...(ethSignature ? { ethSignature } : {}),
@@ -116,18 +119,28 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
bookingUrl.query.bookingUid = slot.bookingUid;
}
let slotFull, notEnoughSeats;
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
return (
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */}
{/* Current there is no way to disable Next.js Links */}
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
{seatsPerTimeSlot && slot.attendees && (slotFull || notEnoughSeats) ? (
<div
className={classNames(
"text-primary-500 dark:bg-darkgray-200 dark:text-darkgray-900 mb-2 block rounded-sm border bg-white py-2 font-medium opacity-25 dark:border-transparent ",
brand === "#fff" || brand === "#ffffff" ? "" : ""
)}>
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
{notEnoughSeats ? (
<p className="text-sm">{t("not_enough_seats")}</p>
) : slots ? (
<p className="text-sm">{t("booking_full")}</p>
) : null}
</div>
) : (
<Link
@@ -135,7 +135,6 @@ const BookingDescription: FC<Props> = (props) => {
<li key={idx}>
<Badge
variant="gray"
size="lg"
className={classNames(
duration === dur.toString()
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
+32 -13
View File
@@ -5,6 +5,8 @@ import { useState } from "react";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns";
@@ -46,7 +48,10 @@ function BookingListItem(booking: BookingItemProps) {
// Get user so we can determine 12/24 hour format preferences
const query = useMeQuery();
const user = query.data;
const { t } = useLocale();
const {
t,
i18n: { language },
} = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const [rejectionReason, setRejectionReason] = useState<string>("");
@@ -94,6 +99,13 @@ function BookingListItem(booking: BookingItemProps) {
mutation.mutate(body);
};
const getSeatReferenceUid = () => {
if (!booking.seatsReferences[0]) {
return undefined;
}
return booking.seatsReferences[0].referenceUid;
};
const pendingActions: ActionType[] = [
{
id: "reject",
@@ -135,7 +147,8 @@ function BookingListItem(booking: BookingItemProps) {
cancel all remaining bookings or just that booking instance. */
href: `/booking/${booking.uid}?cancel=true${
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
}`,
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
`,
icon: FiX,
},
{
@@ -146,7 +159,9 @@ function BookingListItem(booking: BookingItemProps) {
id: "reschedule",
icon: FiClock,
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}`,
href: `/reschedule/${booking.uid}${
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
}`,
},
{
id: "reschedule_request",
@@ -179,14 +194,15 @@ function BookingListItem(booking: BookingItemProps) {
const RequestSentMessage = () => {
return (
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
<FiSend className="-mt-[1px] w-4 rotate-45" />
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
</div>
<Badge startIcon={FiSend} size="md" variant="gray" data-testid="request_reschedule_sent">
{t("reschedule_request_sent")}
</Badge>
);
};
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const startTime = dayjs(booking.startTime)
.locale(language)
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
@@ -224,7 +240,7 @@ function BookingListItem(booking: BookingItemProps) {
const showRecordingsButtons =
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
const title = decodeURIComponent(booking.title);
const title = booking.title;
return (
<>
<RescheduleDialog
@@ -386,13 +402,13 @@ function BookingListItem(booking: BookingItemProps) {
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block text-left text-sm md:hidden">
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
)}
</div>
</td>
<td className="py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
{isUpcoming && !isCancelled ? (
<>
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
@@ -422,7 +438,10 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
// Get user so we can determine 12/24 hour format preferences
const query = useMeQuery();
const user = query.data;
const { t } = useLocale();
const {
t,
i18n: { language },
} = useLocale();
const now = new Date();
const recurringCount = recurringDates.filter((date) => {
return (
@@ -452,7 +471,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
{formatTime(aDate, user?.timeFormat, user?.timeZone)}
{" - "}
{dayjs(aDate).format("D MMMM YYYY")}
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
</p>
);
})}>
+19 -10
View File
@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
@@ -23,18 +23,26 @@ type Props = {
setIsCancellationMode: (value: boolean) => void;
theme: string | null;
allRemainingBookings: boolean;
seatReferenceUid?: string;
};
export default function CancelBooking(props: Props) {
const [cancellationReason, setCancellationReason] = useState<string>("");
const { t } = useLocale();
const router = useRouter();
const { booking, allRemainingBookings } = props;
const { booking, allRemainingBookings, seatReferenceUid } = props;
const [loading, setLoading] = useState(false);
const telemetry = useTelemetry();
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
useTheme(props.theme);
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
node.scrollIntoView({ behavior: "smooth" });
node.focus();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
{error && (
@@ -53,6 +61,7 @@ export default function CancelBooking(props: Props) {
<div className="mt-5 sm:mt-6">
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
<TextArea
ref={cancelBookingRef}
placeholder={t("cancellation_reason_placeholder")}
value={cancellationReason}
onChange={(e) => setCancellationReason(e.target.value)}
@@ -68,21 +77,21 @@ export default function CancelBooking(props: Props) {
{t("nevermind")}
</Button>
<Button
className="flex justify-center"
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast flex justify-center"
data-testid="cancel"
onClick={async () => {
setLoading(true);
const payload = {
id: booking?.id,
cancellationReason: cancellationReason,
allRemainingBookings,
};
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
const res = await fetch("/api/cancel", {
body: JSON.stringify(payload),
body: JSON.stringify({
uid: booking?.uid,
cancellationReason: cancellationReason,
allRemainingBookings,
// @NOTE: very important this shouldn't cancel with number ID use uid instead
seatReferenceUid,
}),
headers: {
"Content-Type": "application/json",
},
+4 -4
View File
@@ -78,6 +78,7 @@ export const SlotPicker = ({
recurringEventCount,
users,
seatsPerTimeSlot,
bookingAttendees,
weekStart = 0,
ethSignature,
}: {
@@ -89,6 +90,7 @@ export const SlotPicker = ({
onTimeFormatChange: (is24Hour: boolean) => void;
timeZone?: string;
seatsPerTimeSlot?: number;
bookingAttendees?: number;
recurringEventCount?: number;
users: string[];
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
@@ -131,10 +133,7 @@ export const SlotPicker = ({
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
usernameList: users,
startTime:
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
: browsingDate?.startOf("month"),
startTime: dayjs().startOf("day"),
endTime: browsingDate?.endOf("month"),
timeZone,
duration,
@@ -192,6 +191,7 @@ export const SlotPicker = ({
eventTypeId={eventType.id}
eventTypeSlug={eventType.slug}
seatsPerTimeSlot={seatsPerTimeSlot}
bookingAttendees={bookingAttendees}
recurringCount={recurringEventCount}
ethSignature={ethSignature}
/>
+5 -53
View File
@@ -1,12 +1,9 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import useTheme from "@calcom/lib/hooks/useTheme";
import type { ITimezoneOption } from "@calcom/ui";
import { TimezoneSelect } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
import { timeZone } from "../../lib/clock";
type Props = {
@@ -15,8 +12,6 @@ type Props = {
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const query = useMeQuery();
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
useEffect(() => {
setSelectedTimeZone(timeZone());
@@ -28,59 +23,16 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
}
}, [selectedTimeZone, onSelectTimeZone]);
const customStyles = {
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
padding: "10px 12px !important",
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
...(userTheme === "dark" && {
color: state.isSelected || state.isFocused ? "white" : "#80868B",
}),
}),
control: () => ({
display: "flex",
cursor: "pointer",
backgroundColor: "transparent !important",
minWidth: "5rem",
height: "24px",
minHeight: "24px !important",
boxShadow: "none !important",
}),
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
...(userTheme === "dark" && {
color: "#a5a5a5 !important",
}),
}),
menu: (provided: object) => ({
...provided,
minWidth: "20rem",
margin: "8px 0 0 -20px",
}),
valueContainer: (provided: object) => ({
...provided,
padding: "0 0 0 6px",
}),
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
marginLeft: 4,
padding: 0,
...(userTheme === "dark" && {
color: "#80868B",
}),
}),
};
return !!selectedTimeZone ? (
<TimezoneSelect
id="timeZone"
classNames={{
singleValue: () => "dark:text-darkgray-600 text-gray-600",
menu: () => "!w-64 max-w-[90vw]",
}}
variant="minimal"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
className="flex h-6 text-sm font-medium"
styles={customStyles}
/>
) : null;
};
@@ -17,8 +17,8 @@ export function TimezoneDropdown({
return (
<>
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</div>
</>
@@ -23,8 +23,9 @@ import notEmpty from "@calcom/lib/notEmpty";
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
import { trpc } from "@calcom/trpc";
import { HeadSeo } from "@calcom/ui";
import { FiCreditCard, FiRefreshCcw } from "@calcom/ui/components/icon";
import { FiCreditCard, FiUser, FiRefreshCcw } from "@calcom/ui/components/icon";
import { timeZone as localStorageTimeZone } from "@lib/clock";
@@ -52,6 +53,7 @@ const dateQuerySchema = z.object({
rescheduleUid: z.string().optional().default(""),
date: z.string().optional().default(""),
timeZone: z.string().optional().default(""),
seatReferenceUid: z.string().optional(),
});
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
@@ -124,6 +126,15 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
: undefined,
];
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
{
seatReferenceUid: rescheduleUid,
},
{
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
}
);
return (
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
<HeadSeo
@@ -143,6 +154,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
nofollow: eventType.hidden,
noindex: eventType.hidden,
}}
isBrandingHidden={restProps.isBrandingHidden}
/>
<BookingPageTagManager eventType={eventType} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
@@ -172,6 +184,22 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
)}>
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
{rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && (
<div
className={classNames(
"flex flex-nowrap items-center text-sm font-medium",
"dark:text-darkgray-600 text-gray-600",
"ltr:mr-[10px] rtl:ml-[10px]"
)}>
<FiUser
className={classNames(
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
"mt-[2px]"
)}
/>{" "}
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
</div>
)}
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-start text-sm font-medium">
<FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
@@ -247,6 +275,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
timeZone={timeZone}
users={userList}
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
bookingAttendees={bookingAttendees || undefined}
recurringEventCount={recurringEventCount}
ethSignature={gateState.rainbowToken}
/>
@@ -4,7 +4,7 @@ import { useSession } from "next-auth/react";
import dynamic from "next/dynamic";
import Head from "next/head";
import { useRouter } from "next/router";
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { useEffect, useMemo, useReducer, useState } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
@@ -15,7 +15,6 @@ import { getEventLocationType, locationKeyToString } from "@calcom/app-store/loc
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import {
useEmbedNonStylesConfig,
@@ -36,19 +35,19 @@ import classNames from "@calcom/lib/classNames";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { Button, Form, Tooltip } from "@calcom/ui";
import { FiAlertTriangle, FiCalendar, FiRefreshCw, FiUser } from "@calcom/ui/components/icon";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import createBooking from "@lib/mutations/bookings/create-booking";
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
import { parseDate, parseRecurringDates } from "@lib/parseDate";
import { parseRecurringDates, parseDate } from "@lib/parseDate";
import type { Gate, GateState } from "@components/Gates";
import Gates from "@components/Gates";
@@ -58,6 +57,8 @@ import type { BookPageProps } from "../../../pages/[user]/book";
import type { HashLinkPageProps } from "../../../pages/d/[link]/book";
import type { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
/** These are like 40kb that not every user needs */
const BookingDescriptionPayment = dynamic(
() => import("@components/booking/BookingDescriptionPayment")
@@ -78,6 +79,7 @@ const BookingFields = ({
const { t } = useLocale();
const { watch, setValue } = useFormContext();
const locationResponse = watch("responses.location");
const currentView = rescheduleUid ? "reschedule" : "";
return (
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
@@ -87,12 +89,16 @@ const BookingFields = ({
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
let readOnly =
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
let noLabel = false;
let hidden = !!field.hidden;
const fieldViews = field.views;
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
return null;
}
if (field.name === SystemField.Enum.rescheduleReason) {
if (!rescheduleUid) {
return null;
}
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
readOnly = false;
}
@@ -115,7 +121,10 @@ const BookingFields = ({
}
// We don't show `notes` field during reschedule
if (field.name === SystemField.Enum.notes && !!rescheduleUid) {
if (
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
!!rescheduleUid
) {
return null;
}
@@ -177,9 +186,26 @@ const BookingFields = ({
);
};
const routerQuerySchema = z
.object({
timeFormat: z.nativeEnum(TimeFormat),
rescheduleUid: z.string().optional(),
date: z
.string()
.optional()
.transform((date) => {
if (date === undefined) {
return null;
}
return date;
}),
})
.passthrough();
const BookingPage = ({
eventType,
booking,
currentSlotBooking,
profile,
isDynamicGroupBooking,
recurringEventCount,
@@ -215,20 +241,6 @@ const BookingPage = ({
duration = Number(queryDuration);
}
// This is a workaround for forcing the same time format for both server side rendering and client side rendering
// At initial render, we use the default time format which is 12H
const [withDefaultTimeFormat, setWithDefaultTimeFormat] = useState(true);
const parseDateFunc = useCallback(
(date: string | null | Dayjs) => {
return parseDate(date, i18n, withDefaultTimeFormat);
},
[withDefaultTimeFormat]
);
// After intial render on client side, we let parseDateFunc to use the time format from the localStorage
useEffect(() => {
setWithDefaultTimeFormat(false);
}, []);
useEffect(() => {
if (top !== window) {
//page_view will be collected automatically by _middleware.ts
@@ -242,12 +254,12 @@ const BookingPage = ({
const mutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
const { uid, paymentUid } = responseData;
const { uid } = responseData;
if (paymentUid) {
if ("paymentUid" in responseData && !!responseData.paymentUid) {
return await router.push(
createPaymentLink({
paymentUid,
paymentUid: responseData.paymentUid,
date,
name: bookingForm.getValues("responses.name"),
email: bookingForm.getValues("responses.email"),
@@ -262,6 +274,7 @@ const BookingPage = ({
isSuccessBookingPage: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventType.slug,
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
},
});
@@ -275,6 +288,7 @@ const BookingPage = ({
return router.push({
pathname: `/booking/${uid}`,
query: {
isSuccessBookingPage: true,
allRemainingBookings: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventType.slug,
@@ -284,11 +298,17 @@ const BookingPage = ({
},
});
const rescheduleUid = router.query.rescheduleUid as string;
const {
data: { timeFormat, rescheduleUid, date },
} = useTypedQuery(routerQuerySchema);
useTheme(profile.theme);
const date = asStringOrNull(router.query.date);
const querySchema = getBookingResponsesPartialSchema({
bookingFields: getBookingFieldsWithSystemFields(eventType),
eventType: {
bookingFields: getBookingFieldsWithSystemFields(eventType),
},
view: rescheduleUid ? "reschedule" : "booking",
});
const parsedQuery = querySchema.parse({
@@ -368,7 +388,8 @@ const BookingPage = ({
const bookingFormSchema = z
.object({
responses: getBookingResponsesSchema({
bookingFields: getBookingFieldsWithSystemFields(eventType),
eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
view: rescheduleUid ? "reschedule" : "booking",
}),
})
.passthrough();
@@ -386,30 +407,21 @@ const BookingPage = ({
// Calculate the booking date(s)
let recurringStrings: string[] = [],
recurringDates: Date[] = [];
const parseRecurringDatesFunc = useCallback(
(date: string | null | Dayjs, recurringEvent: RecurringEvent, recurringCount: number) => {
return parseRecurringDates(
{
startDate: date,
timeZone: timeZone(),
recurringEvent: recurringEvent,
recurringCount: recurringCount,
withDefaultTimeFormat: withDefaultTimeFormat,
},
i18n
);
},
[withDefaultTimeFormat, date, eventType.recurringEvent, recurringEventCount]
);
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
[recurringStrings, recurringDates] = parseRecurringDatesFunc(
date,
eventType.recurringEvent,
parseInt(recurringEventCount.toString())
[recurringStrings, recurringDates] = parseRecurringDates(
{
startDate: date,
timeZone: timeZone(),
recurringEvent: eventType.recurringEvent,
recurringCount: parseInt(recurringEventCount.toString()),
selectedTimeFormat: timeFormat,
},
i18n
);
}
const bookEvent = (booking: BookingFormValues) => {
const bookEvent = (bookingValues: BookingFormValues) => {
telemetry.event(
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
{ isTeamBooking: document.URL.includes("team/") }
@@ -432,9 +444,9 @@ const BookingPage = ({
// Identify set of bookings to one intance of recurring event to support batch changes
const recurringEventId = uuidv4();
const recurringBookings = recurringDates.map((recurringDate) => ({
...booking,
start: dayjs(recurringDate).format(),
end: dayjs(recurringDate).add(duration, "minute").format(),
...bookingValues,
start: dayjs(recurringDate).utc().format(),
end: dayjs(recurringDate).utc().add(duration, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
recurringEventId,
@@ -452,20 +464,21 @@ const BookingPage = ({
recurringMutation.mutate(recurringBookings);
} else {
mutation.mutate({
...booking,
start: dayjs(date).tz(timeZone()).format(),
end: dayjs(date).tz(timeZone()).add(duration, "minute").format(),
...bookingValues,
start: dayjs(date).utc().format(),
end: dayjs(date).utc().add(duration, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
bookingUid: router.query.bookingUid as string,
bookingUid: (router.query.bookingUid as string) || booking?.uid,
user: router.query.user,
metadata,
hasHashedBookingLink,
hashedLink,
ethSignature: gateState.rainbowToken,
seatReferenceUid: router.query.seatReferenceUid as string,
});
}
};
@@ -534,7 +547,7 @@ const BookingPage = ({
<div className="text-sm font-medium">
{isClientTimezoneAvailable &&
(rescheduleUid || !eventType.recurringEvent?.freq) &&
`${parseDateFunc(date)}`}
`${parseDate(date, i18n, timeFormat)}`}
{isClientTimezoneAvailable &&
!rescheduleUid &&
eventType.recurringEvent?.freq &&
@@ -564,7 +577,7 @@ const BookingPage = ({
<FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
{isClientTimezoneAvailable &&
typeof booking.startTime === "string" &&
parseDateFunc(dayjs(booking.startTime))}
parseDate(dayjs(booking.startTime), i18n, timeFormat)}
</p>
</div>
)}
@@ -572,23 +585,27 @@ const BookingPage = ({
<div className="text-bookinghighlight flex items-start text-sm">
<FiUser
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
}`}
/>
<p
className={`${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
} mb-2 font-medium`}>
{booking
? eventType.seatsPerTimeSlot - booking.attendees.length
{currentSlotBooking
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
: eventType.seatsPerTimeSlot}{" "}
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
</p>
@@ -620,6 +637,7 @@ const BookingPage = ({
</Button>
<Button
type="submit"
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast"
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
loading={mutation.isLoading || recurringMutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
@@ -633,6 +651,7 @@ const BookingPage = ({
</div>
</div>
</main>
<Toaster position="bottom-right" />
</Gates>
);
};
@@ -17,6 +17,7 @@ import {
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Input } from "@calcom/ui";
import { Button, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui";
import { FiMapPin } from "@calcom/ui/components/icon";
@@ -52,7 +53,7 @@ const LocationInput = (props: {
const { control } = useFormContext() as typeof locationFormMethods;
if (eventLocationType?.organizerInputType === "text") {
return (
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
<Input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
);
} else if (eventLocationType?.organizerInputType === "phone") {
return (
@@ -171,7 +172,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
id="locationInput"
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
required
className="block w-full rounded-sm border-gray-300 text-sm"
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
defaultValue={
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
@@ -4,13 +4,15 @@ import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
import dayjs from "@calcom/dayjs";
import { NewScheduleButton } from "@calcom/features/schedules";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
import { FiExternalLink, FiGlobe } from "@calcom/ui/components/icon";
import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui";
import { FiExternalLink, FiGlobe, FiClock } from "@calcom/ui/components/icon";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
@@ -52,23 +54,24 @@ const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
const AvailabilitySelect = ({
className = "",
isLoading,
schedules,
...props
}: {
className?: string;
name: string;
value: number;
isLoading: boolean;
schedules: RouterOutputs["viewer"]["availability"]["list"]["schedules"] | [];
onBlur: () => void;
onChange: (value: AvailabilityOption | null) => void;
}) => {
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
const { t } = useLocale();
if (isLoading) {
return <SelectSkeletonLoader />;
}
const schedules = data?.schedules || [];
const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
@@ -107,6 +110,7 @@ const EventTypeScheduleDetails = () => {
const { watch } = useFormContext<FormValues>();
const scheduleId = watch("schedule");
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
@@ -174,6 +178,19 @@ const EventTypeScheduleDetails = () => {
const EventTypeSchedule = () => {
const { t } = useLocale();
const { data: schedules, isLoading } = trpc.viewer.availability.list.useQuery();
if (!schedules?.schedules.length && !isLoading)
return (
<EmptyScreen
Icon={FiClock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
buttonRaw={<NewScheduleButton fromEventType />}
border={false}
/>
);
return (
<div className="space-y-4">
<div>
@@ -183,14 +200,18 @@ const EventTypeSchedule = () => {
<Controller
name="schedule"
render={({ field }) => (
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
<>
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
schedules={schedules?.schedules || []}
isLoading={isLoading}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
</>
)}
/>
</div>
@@ -264,6 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
defaultValue={!!eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<SettingsToggle
data-testid="offer-seats-toggle"
title={t("offer_seats")}
description={t("offer_seats_description")}
checked={value}
@@ -88,6 +88,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
label={t("minimum_booking_notice")}
type="number"
placeholder="0"
min={0}
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
/>
<input type="hidden" ref={ref} {...passThroughProps} />
@@ -424,7 +425,7 @@ const IntervalLimitItem = ({
<TextField
required
type="number"
containerClassName={`${textFieldSuffix ? "w-36" : "w-16"} -mb-1`}
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
placeholder={`${value}`}
min={step}
step={step}
@@ -198,8 +198,16 @@ export const EventSetupTab = (
return (
<li
onClick={() => {
locationFormMethods.setValue("locationType", location.type);
locationFormMethods.unregister("locationLink");
locationFormMethods.unregister("locationAddress");
locationFormMethods.unregister("locationPhoneNumber");
setEditingLocationType(location.type);
openLocationModal(location.type);
}}
key={`${location.type}${index}`}
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2 hover:cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
tooltip={t("embed")}
/>
<Button
color="secondary"
color="destructive"
variant="icon"
StartIcon={FiTrash}
tooltip={t("delete")}
@@ -1,8 +1,11 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
@@ -22,23 +25,30 @@ const UserSettings = (props: IUserSettingsProps) => {
const { t } = useLocale();
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
const telemetry = useTelemetry();
const userSettingsSchema = z.object({
name: z
.string()
.min(1)
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
}),
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
} = useForm<z.infer<typeof userSettingsSchema>>({
defaultValues: {
name: user?.name || "",
},
reValidateMode: "onChange",
resolver: zodResolver(userSettingsSchema),
});
useEffect(() => {
telemetry.event(telemetryEventTypes.onboardingStarted);
}, [telemetry]);
const defaultOptions = { required: true, maxLength: 255 };
const utils = trpc.useContext();
const onSuccess = async () => {
await utils.viewer.me.invalidate();
@@ -67,7 +77,9 @@ const UserSettings = (props: IUserSettingsProps) => {
{t("full_name")}
</label>
<input
{...register("name", defaultOptions)}
{...register("name", {
required: true,
})}
id="name"
name="name"
type="text"
@@ -77,7 +89,7 @@ const UserSettings = (props: IUserSettingsProps) => {
/>
{errors.name && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{t("required")}
{errors.name.message}
</p>
)}
</div>
@@ -75,7 +75,9 @@ export function CalendarSwitch(props: {
/>
{props.defaultSelected && (
<Badge variant="gray">
<FiArrowLeft className="mr-1" /> {t("adding_events_to")}
<div className="flex">
<FiArrowLeft className="mr-1" /> {t("adding_events_to")}
</div>
</Badge>
)}
</div>
+3 -3
View File
@@ -1,12 +1,12 @@
import Link from "next/link";
import type { TeamPageProps } from "pages/team/[slug]";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui";
type TeamType = TeamPageProps["team"];
type TeamType = NonNullable<TeamWithMembers>;
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
@@ -57,7 +57,7 @@ const Members = ({ members, teamName }: { members: MembersType; teamName: string
);
};
const Team = ({ team }: Omit<TeamPageProps, "trpcState">) => {
const Team = ({ team }: { team: TeamType }) => {
return (
<div>
<Members members={team.members} teamName={team.name} />
+4 -15
View File
@@ -3,8 +3,7 @@ import { components } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations";
import { classNames } from "@calcom/lib";
import Select from "@components/ui/form/Select";
import { Select } from "@calcom/ui";
export type LocationOption = {
label: string;
@@ -17,21 +16,11 @@ export type SingleValueLocationOption = SingleValue<LocationOption>;
export type GroupOptionType = GroupBase<LocationOption>;
const OptionWithIcon = ({
icon,
isSelected,
label,
}: {
icon?: string;
isSelected?: boolean;
label: string;
}) => {
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
return (
<div className="flex items-center gap-3">
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5" />}
<span className={classNames("text-sm font-medium", isSelected ? "text-white" : "text-gray-900")}>
{label}
</span>
<span className={classNames("text-sm font-medium")}>{label}</span>
</div>
);
};
@@ -44,7 +33,7 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
components={{
Option: (props) => (
<components.Option {...props}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} isSelected={props.isSelected} />
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
</components.Option>
),
SingleValue: (props) => (
+10 -1
View File
@@ -10,6 +10,8 @@ import type { ComponentProps, ReactNode } from "react";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { trpc } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui";
@@ -58,6 +60,11 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
return <I18nextAdapter {...passedProps} />;
};
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
}
const AppProviders = (props: AppPropsWithChildren) => {
const session = trpc.viewer.public.session.useQuery().data;
// No need to have intercom on public pages - Good for Page Performance
@@ -85,7 +92,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
storageKey={storageKey}
forcedTheme={forcedTheme}
attribute="class">
<MetaProvider>{props.children}</MetaProvider>
<FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider>
</FeatureFlagsProvider>
</ThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
+14 -18
View File
@@ -1,9 +1,8 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { z } from "zod";
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import slugify from "@calcom/lib/slugify";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
type BookingSelect = {
description: true;
@@ -45,16 +44,14 @@ function getResponsesFromOldBooking(
};
}
async function getBooking(
prisma: PrismaClient,
uid: string,
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">
) {
async function getBooking(prisma: PrismaClient, uid: string) {
const rawBooking = await prisma.booking.findFirst({
where: {
uid,
},
select: {
id: true,
uid: true,
startTime: true,
description: true,
customInputs: true,
@@ -65,6 +62,12 @@ async function getBooking(
select: {
email: true,
name: true,
bookingSeat: true,
},
},
user: {
select: {
id: true,
},
},
},
@@ -74,9 +77,7 @@ async function getBooking(
return rawBooking;
}
const booking = getBookingWithResponses(rawBooking, {
bookingFields,
});
const booking = getBookingWithResponses(rawBooking);
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
@@ -96,16 +97,11 @@ export const getBookingWithResponses = <
};
}>
>(
booking: T,
eventType: {
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
}
booking: T
) => {
return {
...booking,
responses: getBookingResponsesPartialSchema({
bookingFields: eventType.bookingFields,
}).parse(booking.responses || getResponsesFromOldBooking(booking)),
};
responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
} as Omit<T, "responses"> & { responses: z.infer<typeof bookingResponsesDbSchema> };
};
export default getBooking;
+9 -10
View File
@@ -3,23 +3,22 @@ import { RRule } from "rrule";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { detectBrowserTimeFormat, TimeFormat } from "@calcom/lib/timeFormat";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { parseZone } from "./parseZone";
const processDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => {
const processDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(
withDefaultTimeFormat ? TimeFormat.TWELVE_HOUR : detectBrowserTimeFormat
);
const formattedTime = parsedZone?.format(selectedTimeFormat || detectBrowserTimeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
};
export const parseDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => {
export const parseDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
if (!date) return ["No date"];
return processDate(date, i18n, withDefaultTimeFormat);
return processDate(date, i18n, selectedTimeFormat);
};
export const parseRecurringDates = (
@@ -28,13 +27,13 @@ export const parseRecurringDates = (
timeZone,
recurringEvent,
recurringCount,
withDefaultTimeFormat,
selectedTimeFormat,
}: {
startDate: string | null | Dayjs;
timeZone?: string;
recurringEvent: RecurringEvent | null;
recurringCount: number;
withDefaultTimeFormat: boolean;
selectedTimeFormat?: TimeFormat;
},
i18n: I18n
): [string[], Date[]] => {
@@ -54,7 +53,7 @@ export const parseRecurringDates = (
});
const dateStrings = times.map((t) => {
// finally; show in local timeZone again
return processDate(t.tz(timeZone), i18n, withDefaultTimeFormat);
return processDate(t.tz(timeZone), i18n, selectedTimeFormat);
});
return [dateStrings, times.map((t) => t.toDate())];
+3 -9
View File
@@ -1,9 +1,3 @@
import type { Attendee, Booking } from "@prisma/client";
import type { AppsStatus } from "@calcom/types/Calendar";
export type BookingResponse = Booking & {
paymentUid?: string;
attendees: Attendee[];
appsStatus?: AppsStatus[];
};
export type BookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/bookings/lib/handleNewBooking").default>
>;
+8
View File
@@ -99,6 +99,14 @@ const nextConfig = {
skipDefaultConversion: true,
preventFullImport: true,
},
"@calcom/features/insights/components": {
transform: "@calcom/features/insights/components/{{member}}",
skipDefaultConversion: true,
preventFullImport: true,
},
lodash: {
transform: "lodash/{{member}}",
},
// TODO: We need to have all components in `@calcom/ui/components` in order to use this
// "@calcom/ui": {
// transform: "@calcom/ui/components/{{member}}",
+12 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.6.12",
"version": "2.7.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -19,18 +19,18 @@
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
},
"engines": {
"node": "16.x || 18.x",
"yarn": ">=1.19.0 < 2.0.0"
"node": "18",
"yarn": "3.4.1"
},
"dependencies": {
"@boxyhq/saml-jackson": "1.7.1",
"@boxyhq/saml-jackson": "1.8.2",
"@calcom/app-store": "*",
"@calcom/app-store-cli": "*",
"@calcom/core": "*",
"@calcom/dayjs": "*",
"@calcom/embed-core": "*",
"@calcom/embed-react": "*",
"@calcom/embed-snippet": "*",
"@calcom/embed-core": "workspace:*",
"@calcom/embed-react": "workspace:*",
"@calcom/embed-snippet": "workspace:*",
"@calcom/features": "*",
"@calcom/lib": "*",
"@calcom/prisma": "*",
@@ -60,6 +60,7 @@
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^4.3.9",
"@tremor/react": "^2.0.0",
"@types/turndown": "^5.0.1",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
@@ -69,6 +70,7 @@
"bcryptjs": "^2.4.3",
"classnames": "^2.3.1",
"dotenv-cli": "^6.0.0",
"entities": "^4.4.0",
"eslint-config-next": "^13.2.1",
"googleapis": "^84.0.0",
"gray-matter": "^4.0.3",
@@ -110,8 +112,8 @@
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.2.7",
"react-schemaorg": "^2.0.0",
"react-select": "^5.4.0",
"react-timezone-select": "^1.3.2",
"react-select": "^5.7.0",
"react-timezone-select": "^1.4.0",
"react-use-intercom": "1.5.1",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.7",
@@ -133,7 +135,7 @@
"@calcom/config": "*",
"@calcom/types": "*",
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
"@playwright/test": "^1.25.0",
"@playwright/test": "^1.31.2",
"@testing-library/react": "^13.3.0",
"@types/accept-language-parser": "1.5.2",
"@types/async": "^3.2.15",
@@ -161,7 +163,6 @@
"copy-webpack-plugin": "^11.0.0",
"detect-port": "^1.3.0",
"env-cmd": "^10.1.0",
"eslint": "^8.34.0",
"mockdate": "^3.0.5",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
+98 -39
View File
@@ -1,8 +1,10 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import {
@@ -12,15 +14,13 @@ import {
getUsernameList,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { bookEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import {
customInputSchema,
EventTypeMetaDataSchema,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
import type { GetBookingType } from "@lib/getBooking";
import getBooking from "@lib/getBooking";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -70,11 +70,26 @@ export default function Book(props: BookPageProps) {
Book.isThemeSupported = true;
const querySchema = z.object({
bookingUid: z.string().optional(),
count: z.coerce.number().optional(),
embed: z.string().optional(),
rescheduleUid: z.string().optional(),
slug: z.string().optional(),
/** This is the event "type" ID */
type: z.coerce.number().optional(),
user: z.string(),
seatReferenceUid: z.string().optional(),
date: z.string().optional(),
duration: z.coerce.number().optional(),
});
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
const eventTypeSlug = context.query.slug as string;
const recurringEventCountQuery = asStringOrNull(context.query.count);
const query = querySchema.parse(context.query);
const usernameList = getUsernameList(query.user);
const eventTypeSlug = query.slug;
const recurringEventCountQuery = query.count;
const users = await prisma.user.findMany({
where: {
username: {
@@ -99,24 +114,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!users.length) return { notFound: true };
const [user] = users;
const isDynamicGroupBooking = users.length > 1;
const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug;
// Dynamic Group link doesn't need a type but it must have a slug
if ((!isDynamicGroupBooking && !context.query.type) || (isDynamicGroupBooking && !eventTypeSlug)) {
if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) {
return { notFound: true };
}
const eventTypeRaw =
usernameList.length > 1
? getDefaultEvent(eventTypeSlug)
: await prisma.eventType.findUnique({
where: {
id: parseInt(asStringOrThrow(context.query.type)),
},
select: {
...bookEventTypeSelect,
},
});
const eventTypeRaw = isDynamicGroupBooking
? getDefaultEvent(eventTypeSlug)
: await prisma.eventType.findUnique({
where: {
id: query.type,
},
select: {
...bookEventTypeSelect,
},
});
if (!eventTypeRaw) return { notFound: true };
const eventType = {
@@ -178,22 +192,66 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
let booking: GetBookingType | null = null;
if (context.query.rescheduleUid || context.query.bookingUid) {
booking = await getBooking(
prisma,
context.query.rescheduleUid
? (context.query.rescheduleUid as string)
: (context.query.bookingUid as string),
eventTypeObject.bookingFields
);
// If rescheduleUid and event has seats lets convert Uid to bookingUid
let rescheduleUid = query.rescheduleUid;
const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot;
let attendeeEmail: string;
let bookingUidWithSeats: string | null = null;
if (rescheduleEventTypeHasSeats) {
const bookingSeat = await prisma.bookingSeat.findFirst({
where: {
referenceUid: query.rescheduleUid,
},
select: {
id: true,
attendee: true,
booking: {
select: {
uid: true,
},
},
},
});
if (bookingSeat) {
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
}
}
const dynamicNames = isDynamicGroupBooking
? users.map((user) => {
return user.name || "";
})
: [];
if (query.duration) {
// If it's not reschedule but event Type has seats we should obtain
// the bookingUid regardless and use it to get the booking
const currentSeats = await prisma.booking.findFirst({
where: {
eventTypeId: eventTypeRaw.id,
startTime: dayjs(query.date).toISOString(),
endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(),
},
select: {
uid: true,
},
});
if (currentSeats && currentSeats) {
bookingUidWithSeats = currentSeats.uid;
}
}
let booking: GetBookingType | null = null;
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
}
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
const currentAttendee = booking?.attendees.find((attendee) => {
return attendee.email === attendeeEmail;
});
if (currentAttendee) {
booking.attendees = [currentAttendee] || [];
}
}
const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : [];
const profile = isDynamicGroupBooking
? {
@@ -203,9 +261,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: null,
brandColor: "",
darkBrandColor: "",
allowDynamicBooking: !users.some((user) => {
return !user.allowDynamicBooking;
}),
allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking),
eventName: getDynamicEventName(dynamicNames, eventTypeSlug),
}
: {
@@ -222,23 +278,26 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const recurringEventCount =
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
? parseInt(recurringEventCountQuery)
(recurringEventCountQuery <= eventType.recurringEvent.count
? recurringEventCountQuery
: eventType.recurringEvent.count)) ||
null;
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
return {
props: {
away: user.away,
profile,
eventType: eventTypeObject,
booking,
currentSlotBooking: currentSlotBooking,
recurringEventCount,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
hasHashedBookingLink: false,
hashedLink: null,
isEmbed: typeof context.query.embed === "string",
isEmbed: !!query.embed,
},
};
}
+18 -1
View File
@@ -1,14 +1,31 @@
import type { ResetPasswordRequest } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendPasswordResetEmail } from "@calcom/emails";
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
import rateLimit from "@calcom/lib/rateLimit";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
const limiter = rateLimit({
intervalInMs: 60 * 1000, // 1 minute
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common");
const email = z
.string()
.email()
.transform((val) => val.toLowerCase())
.parse(req.body?.email);
const { isRateLimited } = limiter.check(10, email); // 10 requests per minute
if (isRateLimited) {
return res.status(429).json({ message: "Too Many Requests." });
}
if (req.method !== "POST") {
return res.status(405).end();
@@ -17,7 +34,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try {
const maybeUser = await prisma.user.findUnique({
where: {
email: req.body?.email?.toLowerCase(),
email,
},
select: {
name: true,
+1 -1
View File
@@ -60,7 +60,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("OrganizerScheduledEmail", {
renderEmail("OrganizerRequestEmail", {
calEvent: evt,
attendee: evt.organizer,
})
+7 -5
View File
@@ -1,11 +1,10 @@
import { ImageResponse } from "@vercel/og";
import type { NextApiRequest } from "next";
import { remark } from "remark";
import type { SatoriOptions } from "satori";
import strip from "strip-markdown";
import { z } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { md } from "@calcom/lib/markdownIt";
const calFont = fetch(new URL("../../../../public/fonts/cal.ttf", import.meta.url)).then((res) =>
res.arrayBuffer()
@@ -75,10 +74,13 @@ export default async function handler(req: NextApiRequest) {
meetingImage: searchParams.get("meetingImage"),
imageType,
});
const title_ = md.render(title).replace(/(<([^>]+)>)/gi, "");
const img = new ImageResponse(
(
<Meeting
title={title}
title={title_}
profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))}
/>
@@ -108,8 +110,8 @@ export default async function handler(req: NextApiRequest) {
description: searchParams.get("description"),
imageType,
});
const description_ = await (await remark().use(strip).process(description)).toString();
const img = new ImageResponse(<Generic title={title} description={description_} />, ogConfig) as {
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig) as {
body: Buffer;
};
+23 -4
View File
@@ -3,6 +3,7 @@ import matter from "gray-matter";
import MarkdownIt from "markdown-it";
import type { GetStaticPaths, GetStaticPropsContext } from "next";
import path from "path";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import prisma from "@calcom/prisma";
@@ -13,6 +14,23 @@ import App from "@components/apps/App";
const md = new MarkdownIt("default", { html: true, breaks: true });
const sourceSchema = z.object({
content: z.string(),
data: z.object({
description: z.string().optional(),
items: z
.array(
z.union([
z.string(),
z.object({
iframe: z.object({ src: z.string() }),
}),
])
)
.optional(),
}),
});
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
return (
<App
@@ -33,7 +51,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
email={data.email}
licenseRequired={data.licenseRequired}
isProOnly={data.isProOnly}
images={source.data?.items as string[] | undefined}
descriptionItems={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate}
dependencies={data.dependencies}
// tos="https://zoom.us/terms"
@@ -85,10 +103,11 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
source = singleApp.description;
}
const { content, data } = matter(source);
const result = matter(source);
const { content, data } = sourceSchema.parse({ content: result.content, data: result.data });
if (data.items) {
data.items = data.items.map((item: string) => {
if (!item.includes("/api/app-store")) {
data.items = data.items.map((item) => {
if (typeof item === "string" && !item.includes("/api/app-store")) {
// Make relative paths absolute
return `/api/app-store/${appDirname}/${item}`;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ResetPasswordRequest } from "@prisma/client";
import debounce from "lodash/debounce";
import { debounce } from "lodash";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
@@ -22,7 +22,7 @@ type Props = {
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
const { t } = useLocale();
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<{ message: string } | null>(null);
const [, setError] = React.useState<{ message: string } | null>(null);
const [success, setSuccess] = React.useState(false);
const [password, setPassword] = React.useState("");
@@ -1,4 +1,4 @@
import debounce from "lodash/debounce";
import { debounce } from "lodash";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+1 -1
View File
@@ -43,7 +43,7 @@ export default function Logout(props: Props) {
</div>
</div>
</div>
<Button href="/auth/login" passHref className="flex w-full justify-center">
<Button href="/auth/login" className="flex w-full justify-center">
{t("go_back_login")}
</Button>
</AuthContainer>
+11 -3
View File
@@ -94,6 +94,7 @@ export default function Availability() {
data: { schedule: scheduleId },
} = useTypedQuery(querySchema);
const { fromEventType } = router.query;
const { timeFormat } = me.data || { timeFormat: null };
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
@@ -153,7 +154,7 @@ export default function Availability() {
return (
<Shell
backPath="/availability"
backPath={fromEventType ? true : "/availability"}
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
heading={
<Controller
@@ -200,7 +201,14 @@ export default function Availability() {
<VerticalDivider />
<Dialog>
<DialogTrigger asChild>
<Button StartIcon={FiTrash} variant="icon" color="destructive" aria-label={t("delete")} />
<Button
StartIcon={FiTrash}
variant="icon"
color="destructive"
aria-label={t("delete")}
disabled={schedule?.isLastSchedule}
tooltip={t("requires_at_least_one_schedule")}
/>
</DialogTrigger>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
@@ -223,7 +231,7 @@ export default function Availability() {
</Button>
</div>
}>
<div className="w-full">
<div className="mt-4 w-full md:mt-0">
<Form
form={form}
id="availability-form"
+7 -1
View File
@@ -93,7 +93,13 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
key={schedule.id}
schedule={schedule}
updateDefault={updateMutation.mutate}
deleteFunction={deleteMutation.mutate}
deleteFunction={() => {
if (schedules.length === 1) {
showToast(t("requires_at_least_one_schedule"), "error");
} else {
deleteMutation.mutate;
}
}}
/>
))}
</ul>
+86 -64
View File
@@ -39,6 +39,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
@@ -46,7 +47,7 @@ import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Badge, Button, EmailInput, HeadSeo } from "@calcom/ui";
import { Button, EmailInput, HeadSeo, Badge } from "@calcom/ui";
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
import { timeZone } from "@lib/clock";
@@ -179,9 +180,10 @@ const querySchema = z.object({
cancel: stringToBoolean,
changes: stringToBoolean,
reschedule: stringToBoolean,
isSuccessBookingPage: z.string().optional(),
isSuccessBookingPage: stringToBoolean,
formerTime: z.string().optional(),
email: z.string().optional(),
seatReferenceUid: z.string().optional(),
});
export default function Success(props: SuccessProps) {
@@ -194,11 +196,9 @@ export default function Success(props: SuccessProps) {
changes,
formerTime,
email,
seatReferenceUid,
} = querySchema.parse(router.query);
if ((isCancellationMode || changes) && typeof window !== "undefined") {
window.scrollTo(0, document.body.scrollHeight);
}
const tz =
(isSuccessBookingPage
? props.bookingInfo.attendees.find((attendee) => attendee.email === email)?.timeZone
@@ -210,10 +210,6 @@ export default function Success(props: SuccessProps) {
props?.bookingInfo?.metadata || {}
)?.videoCallUrl;
if (!location) {
// Can't use logger.error because it throws error on client. stdout isn't available to it.
console.error(`No location found `);
}
const status = props.bookingInfo?.status;
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
@@ -236,12 +232,24 @@ export default function Success(props: SuccessProps) {
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
function setIsCancellationMode(value: boolean) {
if (value) router.query.cancel = "true";
else delete router.query.cancel;
router.replace({
pathname: router.pathname,
query: { ...router.query },
});
const query_ = { ...router.query };
if (value) {
query_.cancel = "true";
} else {
if (query_.cancel) {
delete query_.cancel;
}
}
router.replace(
{
pathname: router.pathname,
query: { ...query_ },
},
undefined,
{ scroll: false }
);
}
const eventNameObject = {
@@ -258,8 +266,19 @@ export default function Success(props: SuccessProps) {
const giphyImage = giphyAppData?.thankYouPage;
const eventName = getEventName(eventNameObject, true);
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
const isCancelled = status === "CANCELLED" || status === "REJECTED";
// Confirmation can be needed in two cases as of now
// - Event Type has require confirmation option enabled always
// - EventType has conditionally enabled confirmation option based on how far the booking is scheduled.
// - It's a paid event and payment is pending.
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING;
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
const isCancelled =
status === "CANCELLED" ||
status === "REJECTED" ||
(!!seatReferenceUid &&
!bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid));
const telemetry = useTelemetry();
useEffect(() => {
if (top !== window) {
@@ -343,7 +362,6 @@ export default function Success(props: SuccessProps) {
return t("emailed_you_and_attendees" + titleSuffix);
}
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
useTheme(isSuccessBookingPage ? props.profile.theme : "light");
const title = t(
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
@@ -417,7 +435,7 @@ export default function Success(props: SuccessProps) {
: "",
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
)}>
{giphyImage && !needsConfirmation && (
{giphyImage && !needsConfirmation && !isCancelled && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} alt="Gif from Giphy" />
)}
@@ -437,7 +455,9 @@ export default function Success(props: SuccessProps) {
? t("submitted_recurring")
: t("submitted")
: isCancelled
? t("event_cancelled")
? seatReferenceUid
? t("no_longer_attending")
: t("event_cancelled")
: props.recurringBookings
? t("meeting_is_scheduled_recurring")
: t("meeting_is_scheduled")}
@@ -469,7 +489,7 @@ export default function Success(props: SuccessProps) {
</>
)}
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
<div className="col-span-2 mb-6 last:mb-0">{props.bookingInfo.title}</div>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6 last:mb-0">
{reschedule && !!formerTime && (
@@ -501,25 +521,23 @@ export default function Success(props: SuccessProps) {
<>
<div className="font-medium">{t("who")}</div>
<div className="col-span-2 last:mb-0">
<>
{bookingInfo?.user && (
<div className="mb-3">
<p>
<span className="mr-2">{bookingInfo.user.name}</span>
<Badge variant="blue" bold>
{t("Host")}
</Badge>
</p>
<p className="text-bookinglight">{bookingInfo.user.email}</p>
{bookingInfo?.user && (
<div className="mb-3">
<div>
<span className="mr-2">{bookingInfo.user.name}</span>
<Badge variant="blue">{t("Host")}</Badge>
</div>
)}
{bookingInfo?.attendees.map((attendee) => (
<div key={attendee.name} className="mb-3 last:mb-0">
{attendee.name && <p>{attendee.name}</p>}
<p className="text-bookinglight">{attendee.email}</p>
</div>
))}
</>
<p className="text-bookinglight">{bookingInfo.user.email}</p>
</div>
)}
{bookingInfo?.attendees.map((attendee) => (
<div key={attendee.name + attendee.email} className="mb-3 last:mb-0">
{attendee.name && <p>{attendee.name}</p>}
<p data-testid={`attendee-${attendee.email}`} className="text-bookinglight">
{attendee.email}
</p>
</div>
))}
</div>
</>
)}
@@ -592,8 +610,10 @@ export default function Success(props: SuccessProps) {
{!props.recurringBookings && (
<span className="text-bookinglight inline text-gray-700">
<span className="underline">
<Link href={`/reschedule/${bookingInfo?.uid}`} legacyBehavior>
<span className="underline" data-testid="reschedule-link">
<Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
legacyBehavior>
{t("reschedule")}
</Link>
</span>
@@ -623,6 +643,7 @@ export default function Success(props: SuccessProps) {
setIsCancellationMode={setIsCancellationMode}
theme={isSuccessBookingPage ? props.profile.theme : "light"}
allRemainingBookings={allRemainingBookings}
seatReferenceUid={seatReferenceUid}
/>
</>
))}
@@ -630,7 +651,7 @@ export default function Success(props: SuccessProps) {
!needsConfirmation &&
!isCancellationMode &&
!isCancelled &&
calculatedDuration && (
!!calculatedDuration && (
<>
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
@@ -738,6 +759,7 @@ export default function Success(props: SuccessProps) {
</div>
</>
)}
{session === null && !(userIsOwner || props.hideBranding) && (
<>
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
@@ -1005,7 +1027,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const bookingInfoRaw = await prisma.booking.findFirst({
where: {
uid,
uid: await maybeGetBookingUidFromSeat(prisma, uid),
},
select: {
title: true,
@@ -1047,6 +1069,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
timeZone: true,
},
},
seatsReferences: {
select: {
referenceUid: true,
},
},
},
});
if (!bookingInfoRaw) {
@@ -1064,8 +1091,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw);
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
@@ -1116,19 +1142,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
}
let recurringBookings = null;
if (bookingInfo.recurringEventId) {
// We need to get the dates for the bookings to be able to show them in the UI
recurringBookings = await prisma.booking.findMany({
where: {
recurringEventId: bookingInfo.recurringEventId,
},
select: {
startTime: true,
},
});
}
const payment = await prisma.payment.findFirst({
where: {
bookingId: bookingInfo.id,
@@ -1138,21 +1151,30 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
refunded: true,
},
});
const paymentStatus = {
success: payment?.success,
refunded: payment?.refunded,
};
return {
props: {
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
profile,
eventType,
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
trpcState: ssr.dehydrate(),
dynamicEventName: bookingInfo?.eventType?.eventName || "",
bookingInfo,
paymentStatus: payment ? paymentStatus : null,
paymentStatus: payment,
},
};
}
async function getRecurringBookings(recurringEventId: string | null) {
if (!recurringEventId) return null;
const recurringBookings = await prisma.booking.findMany({
where: {
recurringEventId,
},
select: {
startTime: true,
},
});
return recurringBookings.map((obj) => obj.startTime.toString());
}
+1
View File
@@ -120,6 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile,
eventType: eventTypeObject,
booking: null,
currentSlotBooking: null,
trpcState: ssr.dehydrate(),
recurringEventCount,
isDynamicGroupBooking: false,
+1 -5
View File
@@ -351,11 +351,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
/>
)}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{type.hidden && (
<Badge variant="gray" size="lg">
{t("hidden")}
</Badge>
)}
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Tooltip content={t("show_eventtype_on_profile")}>
<div className="self-center rounded-md p-2 hover:bg-gray-200">
<Switch
+116
View File
@@ -0,0 +1,116 @@
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 text-red-500" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <FiRefreshCcw className="h-5 w-5 text-blue-500" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <FiUserPlus className="h-5 w-5 text-green-500" />,
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>
);
}
+21 -7
View File
@@ -1,19 +1,28 @@
import type { GetServerSidePropsContext } from "next";
import { URLSearchParams } from "url";
import { z } from "zod";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { asStringOrUndefined } from "@lib/asStringOrNull";
export default function Type() {
// Just redirect to the schedule page to reschedule it.
return null;
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { uid: bookingId } = z
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
.parse(context.query);
let seatReferenceUid;
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
if (uid) {
seatReferenceUid = bookingId;
}
const booking = await prisma.booking.findUnique({
where: {
uid: asStringOrUndefined(context.query.uid),
uid,
},
select: {
...bookingMinimalSelect,
@@ -42,9 +51,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!booking) {
return {
notFound: true,
} as {
notFound: true;
};
} as const;
}
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
@@ -66,9 +73,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: booking.user?.username || "rick") /* This shouldn't happen */ +
"/" +
eventType?.slug;
const destinationUrl = new URLSearchParams();
if (seatReferenceUid) {
destinationUrl.set("rescheduleUid", seatReferenceUid);
} else {
destinationUrl.set("rescheduleUid", bookingId);
}
return {
redirect: {
destination: "/" + eventPage + "?rescheduleUid=" + context.query.uid,
destination: `/${eventPage}?${destinationUrl.toString()}`,
permanent: false,
},
};
+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;
+3 -7
View File
@@ -1,7 +1,6 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { useIntercom } from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -33,15 +32,13 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
const BillingView = () => {
const { t } = useLocale();
const [, loadChat] = useChat();
const [showChat, setShowChat] = useState(false);
const { open } = useIntercom();
const router = useRouter();
const returnTo = router.asPath;
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
const onContactSupportClick = () => {
setShowChat(true);
loadChat({ open: true });
open();
};
return (
@@ -61,7 +58,6 @@ const BillingView = () => {
{t("contact_support")}
</Button>
</CtaRow>
{showChat && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
</div>
</>
);
+18 -6
View File
@@ -1,12 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { IdentityProvider } from "@prisma/client";
import crypto from "crypto";
import { signOut } from "next-auth/react";
import type { BaseSyntheticEvent } from "react";
import { useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
@@ -315,13 +317,23 @@ const ProfileForm = ({
extraField?: React.ReactNode;
}) => {
const { t } = useLocale();
const emailMd5 = crypto
.createHash("md5")
.update(defaultValues.email || "example@example.com")
.digest("hex");
const profileFormSchema = z.object({
username: z.string(),
avatar: z.string(),
name: z
.string()
.min(1)
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
}),
email: z.string().email(),
bio: z.string(),
});
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(profileFormSchema),
});
const {
@@ -338,7 +350,7 @@ const ProfileForm = ({
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={emailMd5} size="lg" />
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
<div className="ltr:ml-4 rtl:mr-4">
<ImageUploader
target="avatar"
+11 -9
View File
@@ -21,7 +21,8 @@ const PasswordView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user } = trpc.viewer.me.useQuery();
const metadata = userMetadata.parse(user?.metadata);
const metadata = userMetadata.safeParse(user?.metadata);
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
const sessionMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => {
@@ -36,10 +37,10 @@ const PasswordView = () => {
const previousValue = utils.viewer.me.getData();
const previousMetadata = userMetadata.parse(previousValue?.metadata);
if (previousValue && metadata?.sessionTimeout) {
if (previousValue && sessionTimeout) {
utils.viewer.me.setData(undefined, {
...previousValue,
metadata: { ...previousMetadata, sessionTimeout: metadata?.sessionTimeout },
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
});
}
return { previousValue };
@@ -81,19 +82,19 @@ const PasswordView = () => {
defaultValues: {
oldPassword: "",
newPassword: "",
sessionTimeout: metadata?.sessionTimeout,
sessionTimeout,
},
});
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
const { oldPassword, newPassword, sessionTimeout } = values;
const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
if (oldPassword && newPassword) {
passwordMutation.mutate({ oldPassword, newPassword });
}
if (metadata?.sessionTimeout !== sessionTimeout) {
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout } });
if (sessionTimeout !== newSessionTimeout) {
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
}
};
@@ -106,6 +107,7 @@ const PasswordView = () => {
const passwordMinLength = data?.user.role === "USER" ? 7 : 15;
const isUser = data?.user.role === "USER";
return (
<>
<Meta title={t("password")} description={t("password_description")} />
@@ -176,8 +178,8 @@ const PasswordView = () => {
<Select
options={timeoutOptions}
defaultValue={
metadata?.sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === metadata.sessionTimeout)
sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1]
}
isSearchable={false}
+39 -10
View File
@@ -13,7 +13,8 @@ import useTheme from "@calcom/lib/hooks/useTheme";
import { md } from "@calcom/lib/markdownIt";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { Avatar, Button, HeadSeo, AvatarGroup } from "@calcom/ui";
import prisma from "@calcom/prisma";
import { Avatar, AvatarGroup, Button, EmptyScreen, HeadSeo } from "@calcom/ui";
import { FiArrowRight } from "@calcom/ui/components/icon";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
@@ -24,13 +25,15 @@ import Team from "@components/team/screens/Team";
import { ssrInit } from "@server/lib/ssr";
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({ team }: TeamPageProps) {
function TeamPage({ team, isUnpublished }: TeamPageProps) {
useTheme(team.theme);
const showMembers = useToggleQuery("members");
const { t } = useLocale();
const isEmbed = useIsEmbed();
const telemetry = useTelemetry();
const router = useRouter();
const teamName = team.name || "Nameless Team";
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
useEffect(() => {
telemetry.event(
@@ -39,6 +42,18 @@ function TeamPage({ team }: TeamPageProps) {
);
}, [telemetry, router.asPath]);
if (isUnpublished) {
return (
<div className="m-8 flex items-center justify-center">
<EmptyScreen
avatar={<Avatar alt={teamName} imageSrc={getPlaceholderAvatar(team.logo, team.name)} size="lg" />}
headline={t("team_is_unpublished", { team: teamName })}
description={t("team_is_unpublished_description")}
/>
</div>
);
}
const EventTypes = () => (
<ul className="dark:border-darkgray-300 rounded-md border border-gray-200">
{team.eventTypes.map((type, index) => (
@@ -78,12 +93,8 @@ function TeamPage({ team }: TeamPageProps) {
</ul>
);
const teamName = team.name || "Nameless Team";
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
return (
<div>
<>
<HeadSeo
title={teamName}
description={teamName}
@@ -124,7 +135,6 @@ function TeamPage({ team }: TeamPageProps) {
</span>
</div>
</div>
<aside className="mt-8 flex justify-center text-center dark:text-white">
<Button
color="minimal"
@@ -140,7 +150,7 @@ function TeamPage({ team }: TeamPageProps) {
</div>
)}
</main>
</div>
</>
);
}
@@ -148,6 +158,25 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const ssr = await ssrInit(context);
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const unpublishedTeam = await prisma.team.findFirst({
where: {
metadata: {
path: ["requestedSlug"],
equals: slug,
},
},
});
if (unpublishedTeam) {
return {
props: {
isUnpublished: true,
team: unpublishedTeam,
trpcState: ssr.dehydrate(),
},
} as const;
}
const team = await getTeamWithMembers(undefined, slug);
if (!team) return { notFound: true } as { notFound: true };
@@ -165,7 +194,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
team,
trpcState: ssr.dehydrate(),
},
};
} as const;
};
export default TeamPage;
+1 -2
View File
@@ -2,7 +2,6 @@ import type { GetServerSidePropsContext } from "next";
import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import prisma from "@calcom/prisma";
@@ -177,7 +176,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject));
booking = await getBooking(prisma, rescheduleUid);
}
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
+10 -2
View File
@@ -1,4 +1,5 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
@@ -24,6 +25,11 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
TeamBookingPage.isThemeSupported = true;
const querySchema = z.object({
rescheduleUid: z.string().optional(),
bookingUid: z.string().optional(),
});
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
@@ -119,8 +125,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
})[0];
let booking: GetBookingType | null = null;
if (context.query.rescheduleUid) {
booking = await getBooking(prisma, context.query.rescheduleUid as string, eventTypeObject.bookingFields);
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
if (rescheduleUid || bookingUid) {
booking = await getBooking(prisma, rescheduleUid || bookingUid || "");
}
// Checking if number of recurring event ocurrances is valid against event type configuration
@@ -145,6 +152,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject,
recurringEventCount,
booking,
currentSlotBooking: null,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,
hashedLink: null,
+127 -3
View File
@@ -1,21 +1,27 @@
import DailyIframe from "@daily-co/daily-js";
import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next";
import Head from "next/head";
import { useEffect } from "react";
import { useState, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { FiChevronRight } from "@calcom/ui/components/icon";
import { ssrInit } from "@server/lib/ssr";
export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>;
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export default function JoinCall(props: JoinCallPageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword } = props;
const { meetingUrl, meetingPassword, booking } = props;
useEffect(() => {
const callFrame = DailyIframe.createFrame({
@@ -53,7 +59,6 @@ export default function JoinCall(props: JoinCallPageProps) {
<>
<Head>
<title>{title}</title>
<meta name="title" content={APP_NAME + " Video"} />
<meta name="description" content={t("quick_video_meeting")} />
<meta property="og:image" content={SEO_IMG_OGIMG_VIDEO} />
<meta property="og:type" content="website" />
@@ -77,6 +82,120 @@ export default function JoinCall(props: JoinCallPageProps) {
}}
/>
</div>
<VideoMeetingInfo booking={booking} />
</>
);
}
interface ProgressBarProps {
startTime: string;
endTime: string;
}
function ProgressBar(props: ProgressBarProps) {
const { t } = useLocale();
const { startTime, endTime } = props;
const currentTime = dayjs().second(0).millisecond(0);
const startingTime = dayjs(startTime).second(0).millisecond(0);
const isPast = currentTime.isAfter(startingTime);
const currentDifference = dayjs().diff(startingTime, "minutes");
const startDuration = dayjs(endTime).diff(startingTime, "minutes");
const [duration] = useState(() => {
if (currentDifference >= 0 && isPast) {
return startDuration - currentDifference;
} else {
return startDuration;
}
});
const prev = startDuration - duration;
const percentage = prev * (100 / startDuration);
return (
<div>
<p>
{duration} {t("minutes")}
</p>
<div className="relative h-2 max-w-xl overflow-hidden rounded-full">
<div className="absolute h-full w-full bg-gray-500/10" />
<div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} />
</div>
</div>
);
}
interface VideoMeetingInfo {
booking: JoinCallPageProps["booking"];
}
export function VideoMeetingInfo(props: VideoMeetingInfo) {
const [open, setOpen] = useState(false);
const { booking } = props;
const { t } = useLocale();
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
return (
<>
<aside
className={classNames(
"no-scrollbar fixed top-0 left-0 z-30 flex h-full w-64 transform justify-between overflow-x-hidden overflow-y-scroll transition-all duration-300 ease-in-out",
open ? "translate-x-0" : "-translate-x-[232px]"
)}>
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden border-r border-gray-300/20 bg-black/80 p-4 text-white shadow-sm backdrop-blur-lg">
<h3>{t("what")}:</h3>
<p>{booking.title}</p>
<h3>{t("invitee_timezone")}:</h3>
<p>{booking.user?.timeZone}</p>
<h3>{t("when")}:</h3>
<p>
{formatToLocalizedDate(startTime)} <br />
{formatToLocalizedTime(startTime)}
</p>
<h3>{t("time_left")}</h3>
<ProgressBar
key={String(open)}
endTime={endTime.toISOString()}
startTime={startTime.toISOString()}
/>
<h3>{t("who")}:</h3>
<p>
{booking?.user?.name} - {t("organizer")}:{" "}
<a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a>
</p>
{booking.attendees.length
? booking.attendees.map((attendee) => (
<p key={attendee.id}>
{attendee.name} – <a href={`mailto:${attendee.email}`}>{attendee.email}</a>
</p>
))
: null}
{booking.description && (
<>
<h3>{t("description")}:</h3>
<div
className="prose-sm prose prose-invert"
dangerouslySetInnerHTML={{ __html: md.render(booking.description ?? "") }}
/>
</>
)}
</main>
<div className="flex items-center justify-center">
<button
aria-label={`${open ? "close" : "open"} booking description sidebar`}
className="h-20 w-6 rounded-r-md border border-l-0 border-gray-300/20 bg-black/60 text-white shadow-sm backdrop-blur-lg"
onClick={() => setOpen(!open)}>
<FiChevronRight
aria-hidden
className={classNames(open && "rotate-180", "w-5 transition-all duration-300 ease-in-out")}
/>
</button>
</div>
</aside>
</>
);
}
@@ -93,10 +212,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
select: {
...bookingMinimalSelect,
uid: true,
description: true,
user: {
select: {
id: true,
credentials: true,
timeZone: true,
name: true,
email: true,
},
},
references: {
@@ -157,6 +280,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
...(typeof bookingObj.references[0].meetingPassword === "string" && {
meetingPassword: bookingObj.references[0].meetingPassword,
}),
booking: bookingObj,
trpcState: ssr.dehydrate(),
},
};
@@ -5,63 +5,35 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, HeadSeo } from "@calcom/ui";
import { FiArrowRight, FiCalendar, FiX } from "@calcom/ui/components/icon";
import { Button, HeadSeo, EmptyScreen } from "@calcom/ui";
import { FiArrowRight, FiCalendar, FiClock } from "@calcom/ui/components/icon";
export default function MeetingNotStarted(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
return (
<div>
<HeadSeo title="Meeting Unavailable" description="Meeting Unavailable" />
<>
<HeadSeo title={t("this_meeting_has_not_started_yet")} description={props.booking.title} />
<main className="mx-auto my-24 max-w-3xl">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
<div
className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<FiX className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
This meeting has not started yet
</h3>
</div>
<div className="mt-4 border-t border-b py-4">
<h2 className="font-cal mb-2 text-center text-lg font-medium text-gray-600">
{props.booking.title}
</h2>
<p className="text-center text-gray-500">
<FiCalendar className="mr-1 -mt-1 inline-block h-4 w-4" />
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
</p>
</div>
<div className="mt-3 text-center sm:mt-5">
<p className="text-sm text-gray-500">
This meeting will be accessible 60 minutes in advance.
</p>
</div>
</div>
<div className="mt-5 text-center sm:mt-6">
<div className="mt-5">
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
{t("go_back")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
<EmptyScreen
Icon={FiClock}
headline={t("this_meeting_has_not_started_yet")}
description={
<>
<h2 className="mb-2 text-center font-medium">{props.booking.title}</h2>
<p className="text-center text-gray-500">
<FiCalendar className="mr-1 -mt-1 inline-block h-4 w-4" />
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
</p>
</>
}
buttonRaw={
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
{t("go_back")}
</Button>
}
/>
</main>
</div>
</>
);
}
+13 -38
View File
@@ -1,50 +1,25 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, HeadSeo } from "@calcom/ui";
import { Button, EmptyScreen, HeadSeo } from "@calcom/ui";
import { FiX, FiArrowRight } from "@calcom/ui/components/icon";
export default function NoMeetingFound() {
const { t } = useLocale();
return (
<div>
<>
<HeadSeo title={t("no_meeting_found")} description={t("no_meeting_found")} />
<main className="mx-auto my-24 max-w-3xl">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
<div
className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<FiX className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
{t("no_meeting_found")}
</h3>
</div>
<div className="mt-2">
<p className="text-center text-sm text-gray-500">{t("no_meeting_found_description")}</p>
</div>
</div>
<div className="mt-5 text-center sm:mt-6">
<div className="mt-5">
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
{t("go_back_home")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
<EmptyScreen
Icon={FiX}
headline={t("no_meeting_found")}
description={t("no_meeting_found_description")}
buttonRaw={
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
{t("go_back_home")}
</Button>
}
/>
</main>
</div>
</>
);
}
+22
View File
@@ -132,4 +132,26 @@ test.describe("pro user", () => {
// This is the only booking in there that needed confirmation and now it should be empty screen
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
});
test("can book with multiple guests", async ({ page, users }) => {
const additionalGuests = ["test@gmail.com", "test2@gmail.com"];
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "test1234");
await page.fill('[name="email"]', "test1234@example.com");
await page.locator('[data-testid="add-guests"]').click();
await page.locator('input[type="email"]').nth(1).fill(additionalGuests[0]);
await page.locator('[data-testid="add-another-guest"]').click();
await page.locator('input[type="email"]').nth(2).fill(additionalGuests[1]);
await page.locator('[data-testid="confirm-book-button"]').click();
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
additionalGuests.forEach(async (email) => {
await expect(page.locator(`[data-testid="attendee-${email}"]`)).toHaveText(email);
});
});
});
+317
View File
@@ -0,0 +1,317 @@
import { expect } from "@playwright/test";
import type { Prisma } from "@prisma/client";
import { BookingStatus } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import {
bookTimeSlot,
createNewSeatedEventType,
selectFirstAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
const slug = "seats";
const user = await users.create({
eventTypes: [
{ title: "Seated event", slug, seatsPerTimeSlot: 10, requiresConfirmation: true, length: 30 },
],
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
return { user, eventType };
}
async function createUserWithSeatedEventAndAttendees(
fixtures: Pick<Fixtures, "users" | "bookings">,
attendees: Prisma.AttendeeCreateManyBookingInput[]
) {
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
// startTime with 1 day from now and endTime half hour after
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
attendees: {
createMany: {
data: attendees,
},
},
});
return { user, eventType, booking };
}
test.describe("Booking with Seats", () => {
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
const user = await users.create({ name: "Seated event" });
await user.login();
const eventTitle = "My 2-seated event";
await createNewSeatedEventType(page, { eventTitle });
await expect(page.locator(`text=${eventTitle} event type updated successfully`)).toBeVisible();
});
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
const slug = "my-2-seated-event";
const user = await users.create({
name: "Seated event user",
eventTypes: [
{ title: "My 2-seated event", slug, length: 60, seatsPerTimeSlot: 2, seatsShowAttendees: true },
],
});
await page.goto(`/${user.username}/${slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
const bookingUrl = page.url();
await test.step("Attendee #1 can book a seated event time slot", async () => {
await page.goto(bookingUrl);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
await test.step("Attendee #2 can book the same seated event time slot", async () => {
await page.goto(bookingUrl);
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
await test.step("Attendee #3 cannot book the same seated event time slot", async () => {
await page.goto(bookingUrl);
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
});
});
// TODO: Make E2E test: Attendee #1 should be able to cancel his booking
// todo("Attendee #1 should be able to cancel his booking");
// TODO: Make E2E test: Attendee #1 should be able to reschedule his booking
// todo("Attendee #1 should be able to reschedule his booking");
// TODO: Make E2E test: All attendees canceling should delete the booking for the User
// todo("All attendees canceling should delete the booking for the User");
test.describe("Reschedule for booking with seats", () => {
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
];
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
});
await page.goto(`/reschedule/${references[2].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// expect input to be filled with attendee number 3 data
const thirdAttendeeElement = await page.locator("input[name=name]");
const attendeeName = await thirdAttendeeElement.inputValue();
expect(attendeeName).toBe("John Third");
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
// Should expect old booking to be accepted with two attendees
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: { seatsReferences: true, attendees: true },
});
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
expect(oldBooking?.attendees.length).toBe(2);
expect(oldBooking?.seatsReferences.length).toBe(2);
});
test("Should reschedule booking with seats and if everyone rescheduled it should be deleted", async ({
page,
users,
bookings,
}) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
});
await page.goto(`/reschedule/${references[0].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({ url: /.*booking/ });
await page.goto(`/reschedule/${references[1].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({ url: /.*booking/ });
// Should expect old booking to be cancelled
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: {
seatsReferences: true,
attendees: true,
eventType: {
include: { users: true, hosts: true },
},
},
});
expect(oldBooking?.attendees).toBeFalsy();
});
test("Should cancel with seats and have no attendees and cancelled", async ({
page,
users,
bookings,
}) => {
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
await user.login();
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: { seatsReferences: true, attendees: true },
});
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
// Now we cancel the booking as the organizer
await page.goto(`/booking/${booking.uid}?cancel=true`);
await page.locator('[data-testid="cancel"]').click();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
// Should expect old booking to be cancelled
const updatedBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: { seatsReferences: true, attendees: true },
});
expect(oldBooking?.startTime).not.toBe(updatedBooking?.startTime);
});
test("If rescheduled/cancelled booking with seats it should display the correct number of seats", async ({
page,
users,
bookings,
}) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
});
await page.goto(
`/booking/${references[0].referenceUid}?cancel=true&seatReferenceUid=${references[0].referenceUid}`
);
await page.locator('[data-testid="cancel"]').click();
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
select: {
id: true,
status: true,
},
});
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
await page.goto(`/reschedule/${references[1].referenceUid}`);
await page.click('[data-testid="incrementMonth"]');
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
// Validate that the number of seats its 10
expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0);
});
});
});
+4 -25
View File
@@ -4,7 +4,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
@@ -29,38 +29,17 @@ test.describe("Event Types tests", () => {
});
test("can add new event type", async ({ page }) => {
await page.click("[data-testid=new-event-type]");
const nonce = randomString(3);
const eventTitle = `hello ${nonce}`;
await page.fill("[name=title]", eventTitle);
await page.fill("[name=length]", "10");
await page.click("[type=submit]");
await page.waitForNavigation({
url(url) {
return url.pathname !== "/event-types";
},
});
await createNewEventType(page, { eventTitle });
await page.goto("/event-types");
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
});
test("enabling recurring event comes with default options", async ({ page }) => {
await page.click("[data-testid=new-event-type]");
const nonce = randomString(3);
const eventTitle = `my recurring event ${nonce}`;
await page.fill("[name=title]", eventTitle);
await page.fill("[name=length]", "15");
await page.click("[type=submit]");
await page.waitForNavigation({
url(url) {
return url.pathname !== "/event-types";
},
});
await createNewEventType(page, { eventTitle });
await page.click("[data-testid=vertical-tab-recurring]");
await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeHidden();
@@ -140,7 +119,7 @@ test.describe("Event Types tests", () => {
await page.locator("#location-select").click();
await page.locator("text=In Person (Organizer Address)").click();
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(250);
await page.waitForTimeout(1000);
await page.locator('input[name="locationAddress"]').fill(inputText);
await page.locator("[data-testid=display-location]").check();
await page.locator("[data-testid=update-location]").click();
+13 -8
View File
@@ -18,7 +18,18 @@ export const createBookingsFixture = (page: Page) => {
userId: number,
username: string | null,
eventTypeId = -1,
{ rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Prisma.BookingCreateInput> = {},
{
rescheduled = false,
paid = false,
status = "ACCEPTED",
attendees = {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
}: Partial<Prisma.BookingCreateInput> = {},
startDateParam?: Date,
endDateParam?: Date
) => {
@@ -36,13 +47,7 @@ export const createBookingsFixture = (page: Page) => {
id: userId,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
attendees,
eventType: {
connect: {
id: eventTypeId,
+24 -55
View File
@@ -69,61 +69,24 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
} = {}
) => {
const _user = await prisma.user.create({
data: await createUser(workerInfo, opts),
});
await prisma.eventType.create({
data: {
owner: {
connect: {
id: _user.id,
},
},
users: {
connect: {
id: _user.id,
},
},
title: "30 min",
slug: "30-min",
length: 30,
},
});
await prisma.eventType.create({
data: {
users: {
connect: {
id: _user.id,
},
},
owner: {
connect: {
id: _user.id,
},
},
title: "Paid",
slug: "paid",
length: 30,
price: 1000,
},
});
await prisma.eventType.create({
data: {
users: {
connect: {
id: _user.id,
},
},
owner: {
connect: {
id: _user.id,
},
},
title: "Opt in",
slug: "opt-in",
requiresConfirmation: true,
length: 30,
},
data: createUser(workerInfo, opts),
});
let defaultEventTypes: SupportedTestEventTypes[] = [
{ title: "30 min", slug: "30-min", length: 30 },
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
];
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
for (const eventTypeData of defaultEventTypes) {
eventTypeData.owner = { connect: { id: _user.id } };
eventTypeData.users = { connect: { id: _user.id } };
await prisma.eventType.create({
data: eventTypeData,
});
}
if (scenario.seedRoutingForms) {
await prisma.app_RoutingForms_Form.create({
data: {
@@ -314,8 +277,14 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
};
};
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
_bookings?: PrismaType.BookingCreateInput[];
};
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name";
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
timeZone?: TimeZoneEnum;
eventTypes?: SupportedTestEventTypes[];
};
// creates the actual user in the db.
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
+16
View File
@@ -2,6 +2,7 @@ import { test as base } from "@playwright/test";
import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments";
@@ -18,6 +19,21 @@ export interface Fixtures {
prisma: typeof prisma;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace PlaywrightTest {
//FIXME: how to restrict it to Frame only
interface Matchers<R> {
toBeEmbedCalLink(
calNamespace: string,
// eslint-disable-next-line
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
expectedUrlDetails?: ExpectedUrlDetails
): Promise<R>;
}
}
}
/**
* @see https://playwright.dev/docs/test-fixtures
*/
+26 -4
View File
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
import noop from "lodash/noop";
import { noop } from "lodash";
import { test } from "./fixtures";
@@ -133,10 +133,10 @@ export async function bookFirstEvent(page: Page) {
await bookEventOnThisPage(page);
}
export const bookTimeSlot = async (page: Page) => {
export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string }) => {
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.fill('[name="name"]', opts?.name ?? "Test Testson");
await page.fill('[name="email"]', opts?.email ?? "test@example.com");
await page.press('[name="email"]', "Enter");
};
// Provide an standalone localize utility not managed by next-i18n
@@ -148,3 +148,25 @@ export async function localize(locale: string) {
throw "No locale found for the given entry message";
};
}
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
await page.click("[data-testid=new-event-type]");
const eventTitle = args.eventTitle;
await page.fill("[name=title]", eventTitle);
await page.fill("[name=length]", "10");
await page.click("[type=submit]");
await page.waitForNavigation({
url(url) {
return url.pathname !== "/event-types";
},
});
};
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
const eventTitle = args.eventTitle;
await createNewEventType(page, { eventTitle });
await page.locator('[data-testid="vertical-tab-event_advanced_tab_title"]').click();
await page.locator('[data-testid="offer-seats-toggle"]').click();
await page.locator('[data-testid="update-eventtype"]').click();
};
@@ -33,56 +33,108 @@ test.describe("Manage Booking Questions", () => {
await firstEventTypeElement.click();
});
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await addQuestionAndSave({
page,
question: {
name: "how_are_you",
type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
});
await doOnFreshPreview(page, context, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
test.describe("For Team EventType", () => {
test("Do a booking with a user added question and verify a few thing in b/w", async ({
page,
users,
context,
}, testInfo) => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ users });
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
});
const webhookReceiver = await addWebhook(user);
await test.step("Go to First Team Event", async () => {
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
});
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
});
});
await test.step("Show Question Again", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
});
async function runTestStepsCommonForTeamAndUserEventType(
page: Page,
context: PlaywrightTestArgs["context"],
webhookReceiver: {
port: number;
close: () => import("http").Server;
requestList: (import("http").IncomingMessage & { body?: unknown })[];
url: string;
}
) {
await page.click('[href$="tabName=advanced"]');
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
});
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await addQuestionAndSave({
page,
question: {
name: "how_are_you",
type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await test.step("Do a booking", async () => {
await doOnFreshPreview(page, context, async (page) => {
await doOnFreshPreview(page, context, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
});
});
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
});
await test.step("Make rescheduleReason required - It won't be required for a fresh booking", async () => {
await toggleQuestionRequireStatusAndSave({
required: true,
name: "rescheduleReason",
page,
});
});
const previewTabPage =
await test.step("Do a booking and notice that we can book without giving a value for rescheduleReason", async () => {
return await doOnFreshPreview(
page,
context,
async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible();
expect(
@@ -133,146 +185,16 @@ test.describe("Manage Booking Questions", () => {
value: "I am great!",
},
});
});
});
});
});
test.describe("For Team EventType", () => {
test("Do a booking with a user added question and verify a few thing in b/w", async ({
page,
users,
context,
}, testInfo) => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ users });
const webhookReceiver = await addWebhook(user);
await test.step("Go to First Team Event", async () => {
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
});
});
async function runTestStepsCommonForTeamAndUserEventType(
page: Page,
context: PlaywrightTestArgs["context"],
webhookReceiver: {
port: number;
close: () => import("http").Server;
requestList: (import("http").IncomingMessage & { body?: unknown })[];
url: string;
}
) {
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await addQuestionAndSave({
page,
question: {
name: "how_are_you",
type: "Name",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
},
});
await doOnFreshPreview(page, context, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
// There are 2 labels right now. Will be one in future. The second one is hidden
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
});
});
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
});
await test.step("Show Question Again", async () => {
await toggleQuestionAndSave({
name: "how_are_you",
page,
});
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
});
await test.step("Do a booking", async () => {
await doOnFreshPreview(page, context, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible();
expect(await formBuilderFieldLocator.locator('[name="how_are_you"]').getAttribute("placeholder")).toBe(
"I'm fine, thanks"
},
true
);
expect(await formBuilderFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
await formBuilderFieldLocator.locator('[name="how_are_you"]').fill("I am great!");
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
expect(
await page.locator('[data-testid="field-response"][data-fob-field="how_are_you"]').innerText()
).toBe("I am great!");
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const payload = (request.body as any).payload as any;
expect(payload.responses).toMatchObject({
email: {
label: "email_address",
value: "booker@example.com",
},
how_are_you: {
label: "How are you?",
value: "I am great!",
},
name: {
label: "your_name",
value: "Booker",
},
});
expect(payload.location).toBe("integrations:daily");
expect(payload.attendees[0]).toMatchObject({
name: "Booker",
email: "booker@example.com",
});
expect(payload.userFieldsResponses).toMatchObject({
how_are_you: {
label: "How are you?",
value: "I am great!",
},
});
});
await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => {
const page = previewTabPage;
await rescheduleFromTheLinkOnPage({ page });
await page.pause();
await expectErrorToBeThereFor({ page, name: "rescheduleReason" });
});
}
@@ -333,7 +255,6 @@ async function addQuestionAndSave({
required?: boolean;
};
}) {
await page.click('[href$="tabName=advanced"]');
await page.click('[data-testid="add-field"]');
if (question.type !== undefined) {
@@ -379,11 +300,15 @@ async function expectErrorToBeThereFor({ page, name }: { page: Page; name: strin
async function doOnFreshPreview(
page: Page,
context: PlaywrightTestArgs["context"],
callback: (page: Page) => Promise<void>
callback: (page: Page) => Promise<void>,
persistTab = false
) {
const previewTabPage = await openBookingFormInPreviewTab(context, page);
await callback(previewTabPage);
await previewTabPage.close();
if (!persistTab) {
await previewTabPage.close();
}
return previewTabPage;
}
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
@@ -391,6 +316,25 @@ async function toggleQuestionAndSave({ name, page }: { name: string; page: Page
await saveEventType(page);
}
async function toggleQuestionRequireStatusAndSave({
required,
name,
page,
}: {
required: boolean;
name: string;
page: Page;
}) {
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="edit-field-action"]').click();
await page
.locator('[data-testid="edit-field-dialog"]')
.locator('[data-testid="field-required"] button')
.locator(`text=${required ? "Yes" : "No"}`)
.click();
await page.locator('[data-testid="field-add-save"]').click();
await saveEventType(page);
}
async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<typeof createUsersFixture> }) {
const user = await users.create(null, {
hasTeam: true,
@@ -399,6 +343,16 @@ async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<t
return user;
}
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
await page.locator('[data-testid="reschedule-link"]').click();
await page.waitForLoadState();
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url: (url) => url.pathname.endsWith("/book"),
});
await page.click('[data-testid="confirm-reschedule-button"]');
}
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
const previewTabPromise = context.waitForEvent("page");
await page.locator('[data-testid="preview-button"]').click();
+5 -1
View File
@@ -26,7 +26,11 @@ test.describe("Onboarding", () => {
await page.locator("input[name=username]").fill("new user onboarding");
await page.locator("input[name=name]").fill("new user 2");
await page.locator("input[role=combobox]").click();
await page.locator("text=Europe/London GMT +0:00").click();
await page
.locator("*")
.filter({ hasText: /^Europe\/London/ })
.first()
.click();
await page.locator("button[type=submit]").click();
+16 -9
View File
@@ -19,7 +19,7 @@ test.afterEach(({ users }) => users.deleteAll());
test.describe("Reschedule Tests", async () => {
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
});
@@ -45,15 +45,18 @@ test.describe("Reschedule Tests", async () => {
});
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
test.skip(true, "TODO: Re-enable after v1.7 launch");
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
await page.goto(`/${user.username}/${user.eventTypes[0].slug}?rescheduleUid=${booking.uid}`);
const formerTimeElement = page.locator('[data-testid="former_time_p_desktop"]');
await selectFirstAvailableTimeSlotNextMonth(page);
const formerTimeElement = page.locator('[data-testid="former_time_p"]');
await expect(formerTimeElement).toBeVisible();
await booking.delete();
});
@@ -90,13 +93,16 @@ test.describe("Reschedule Tests", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForLoadState("networkidle");
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// NOTE: remove if old booking should not be deleted
expect(await booking.self()).toBeNull();
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
const rescheduledBooking = await prisma.booking.findFirst({ where: { uid: booking.uid } });
expect(newBooking).not.toBeNull();
expect(rescheduledBooking).toBeNull();
await prisma.booking.delete({ where: { id: newBooking?.id } });
});
@@ -106,6 +112,7 @@ test.describe("Reschedule Tests", async () => {
const user = await users.create();
await user.login();
await user.getPaymentCredential();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
@@ -142,7 +149,6 @@ test.describe("Reschedule Tests", async () => {
});
await expect(page).toHaveURL(/.*payment/);
await payment.delete();
});
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
@@ -150,6 +156,7 @@ test.describe("Reschedule Tests", async () => {
await user.login();
await user.getPaymentCredential();
await users.logout();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
@@ -165,12 +172,11 @@ test.describe("Reschedule Tests", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
await payment.delete();
});
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
@@ -191,6 +197,7 @@ test.describe("Reschedule Tests", async () => {
test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
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.

After

Width:  |  Height:  |  Size: 228 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke="white" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke="white" stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

+66 -27
View File
@@ -25,7 +25,7 @@
"rejection_confirmation": "Reject the booking",
"manage_this_event": "Manage this event",
"invite_team_member": "Invite team member",
"invite_team_notifcation_badge":"Inv.",
"invite_team_notifcation_badge": "Inv.",
"your_event_has_been_scheduled": "Your event has been scheduled",
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
@@ -90,6 +90,7 @@
"new_event_scheduled_recurring": "A new recurring event has been scheduled.",
"invitee_email": "Invitee Email",
"invitee_timezone": "Invitee Time Zone",
"time_left": "Time left",
"event_type": "Event Type",
"enter_meeting": "Enter Meeting",
"video_call_provider": "Video call provider",
@@ -110,7 +111,7 @@
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
"upgrade_to_per_seat": "Upgrade to Per-Seat",
"seat_options_doesnt_support_confirmation":"Seats option doesn't support confirmation requirement",
"seat_options_doesnt_support_confirmation": "Seats option doesn't support confirmation requirement",
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.",
"team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.",
"team_upgrade_banner_action": "Upgrade here",
@@ -432,6 +433,7 @@
"password_hint_min": "Minimum 7 characters long",
"password_hint_admin_min": "Minimum 15 characters long",
"password_hint_num": "Contain at least 1 number",
"max_limit_allowed_hint": "Must be {{limit}} or fewer characters long",
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
"incorrect_password": "Password is incorrect.",
"incorrect_username_password": "Username or password is incorrect.",
@@ -861,7 +863,7 @@
"add_new_calendar": "Add a new calendar",
"set_calendar": "Set where to add new events to when you're booked.",
"delete_schedule": "Delete schedule",
"delete_schedule_description":"Deleting a schedule will remove it from all event types. This action cannot be undone.",
"delete_schedule_description": "Deleting a schedule will remove it from all event types. This action cannot be undone.",
"schedule_created_successfully": "{{scheduleName}} schedule created successfully",
"availability_updated_successfully": "{{scheduleName}} schedule updated successfully",
"schedule_deleted_successfully": "Schedule deleted successfully",
@@ -1328,7 +1330,7 @@
"password_reset_leading": "If you don't receive an email soon, check that the email address you entered is correct, check your spam folder or reach out to support if the issue persists.",
"password_updated": "Password updated!",
"pending_payment": "Pending payment",
"pending_invites":"Pending Invites",
"pending_invites": "Pending Invites",
"confirmation_page_rainbow": "Token gate your event with tokens or NFTs on Ethereum, Polygon, and more.",
"not_on_cal": "Not on {{appName}}",
"no_calendar_installed": "No calendar installed",
@@ -1478,10 +1480,10 @@
"add_an_option": "Add an option",
"radio": "Radio",
"google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar",
"individual":"Individual",
"all_bookings_filter_label":"All Bookings",
"all_users_filter_label":"All Users",
"your_bookings_filter_label":"Your Bookings",
"individual": "Individual",
"all_bookings_filter_label": "All Bookings",
"all_users_filter_label": "All Users",
"your_bookings_filter_label": "Your Bookings",
"meeting_url_variable": "Meeting url",
"meeting_url_info": "The event meeting conference url",
"date_overrides": "Date overrides",
@@ -1576,16 +1578,16 @@
"delete_sso_configuration_confirmation": "Yes, delete {{connectionType}} configuration",
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.",
"organizer_timezone": "Organizer timezone",
"email_no_user_cta":"Create your account",
"email_user_cta":"View Invitation",
"email_no_user_invite_heading":"You’ve been invited to join a team on {{appName}}",
"email_no_user_invite_subheading":"{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_no_user_invite_steps_intro":"We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your team in no time.",
"email_no_user_step_one":"Choose your username",
"email_no_user_step_two":"Connect your calendar account",
"email_no_user_step_three":"Set your Availability",
"email_no_user_step_four":"Join {{teamName}}",
"email_no_user_signoff":"Happy Scheduling from the {{appName}} team",
"email_no_user_cta": "Create your account",
"email_user_cta": "View Invitation",
"email_no_user_invite_heading": "You’ve been invited to join a team on {{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your team in no time.",
"email_no_user_step_one": "Choose your username",
"email_no_user_step_two": "Connect your calendar account",
"email_no_user_step_three": "Set your Availability",
"email_no_user_step_four": "Join {{teamName}}",
"email_no_user_signoff": "Happy Scheduling from the {{appName}} team",
"impersonation_user_tip": "You are about to impersonate a user, which means you can make changes on their behalf. Please be careful.",
"available_variables": "Available variables",
"scheduler": "{Scheduler}",
@@ -1597,10 +1599,15 @@
"create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.",
"collective_or_roundrobin": "Collective or round-robin",
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.",
"event_no_longer_attending_subject": "No longer attending {{title}} at {{date}}",
"no_longer_attending": "You are no longer attending this event",
"attendee_no_longer_attending_subject": "An attendee is no longer attending {{title}} at {{date}}",
"attendee_no_longer_attending": "An attendee is no longer attending your event",
"attendee_no_longer_attending_subtitle": "{{name}} has cancelled. This means a seat has opened up for this time slot",
"create_event_on": "Create event on",
"default_app_link_title":"Set a default app link",
"default_app_link_description":"Setting a default app link allows all newly created event types to use the app link you set.",
"change_default_conferencing_app":"Set as default",
"default_app_link_title": "Set a default app link",
"default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.",
"change_default_conferencing_app": "Set as default",
"under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats",
@@ -1612,33 +1619,45 @@
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
"booking_confirmation_failed": "Booking confirmation failed",
"not_enough_seats": "Not enough seats",
"form_builder_field_already_exists": "A field with this name already exists",
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
"form_builder_system_field_cant_delete": "This system field can't be removed.",
"form_builder_system_field_cant_toggle": "This system field can't be toggled.",
"get_started_zapier_templates": "Get started with Zapier templates",
"team_is_unpublished": "{{team}} is unpublished",
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
"team_member": "Team member",
"a_routing_form": "A Routing Form",
"form_description_placeholder": "Form Description",
"keep_me_connected_with_form": "Keep me connected with the form",
"fields_in_form_duplicated":"Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
"fields_in_form_duplicated": "Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
"form_deleted": "Form deleted",
"delete_form": "Are you sure you want to delete this form?",
"delete_form_action": "Yes, delete Form",
"delete_form_confirmation":"Anyone who you've shared the link with will no longer be able to access it.",
"delete_form_confirmation_2":"All associated responses will be deleted.",
"delete_form_confirmation": "Anyone who you've shared the link with will no longer be able to access it.",
"delete_form_confirmation_2": "All associated responses will be deleted.",
"typeform_redirect_url_copied": "Typeform Redirect URL copied! You can go and set the URL in Typeform form.",
"modifications_in_fields_warning": "Modifications in fields and routes of following forms will be reflected in this form.",
"connected_forms": "Connected Forms",
"form_modifications_warning": "Following forms would be affected when you modify fields or routes here.",
"responses_collection_waiting_description": "Wait for some time for responses to be collected. You can go and submit the form yourself as well.",
"this_is_what_your_users_would_see":"This is what your users would see",
"this_is_what_your_users_would_see": "This is what your users would see",
"identifies_name_field": "Identifies field by this name.",
"add_1_option_per_line": "Add 1 option per line",
"select_a_router": "Select a router",
"add_a_new_route": "Add a new Route",
"make_informed_decisions": "Make informed decisions with Insights",
"make_informed_decisions_description": "Our Insights dashboard surfaces all activity across your team and shows you trends that enable better team scheduling and decision making.",
"view_bookings_across": "View bookings across all members",
"view_bookings_across_description": "See who’s receiving the most bookings and ensure the best distribution across your team",
"identify_booking_trends": "Identify booking trends",
"identify_booking_trends_description": "See what times of the week and what times during the day are popular for your bookers",
"spot_popular_event_types": "Spot popular event types",
"spot_popular_event_types_description": "See which of your event types are receiving the most clicks and bookings",
"no_responses_yet": "No responses yet",
"this_will_be_the_placeholder": "This will be the placeholder",
"this_meeting_has_not_started_yet": "This meeting has not started yet",
"this_app_requires_connected_account": "{{appName}} requires a connected {{dependencyName}} account",
"connect_app": "Connect {{dependencyName}}",
"app_is_connected": "{{dependencyName}} is connected",
@@ -1646,8 +1665,28 @@
"verification_code": "Verification code",
"can_you_try_again": "Can you try again with a different time?",
"verify": "Verify",
"invalid_event_name_variables":"There is an invalid variable in your event name",
"invalid_event_name_variables": "There is an invalid variable in your event name",
"select_all": "Select All",
"default_conferencing_bulk_title": "Bulk update existing event types",
"default_conferencing_bulk_description": "Update the locations for the selected event types"
"requires_at_least_one_schedule": "You are required to have at least one schedule",
"default_conferencing_bulk_description": "Update the locations for the selected event types",
"looking_for_more_analytics": "Looking for more analytics?",
"add_filter": "Add filter",
"select_user": "Select User",
"select_event_type": "Select Event Type",
"select_date_range": "Select Date Range",
"popular_events": "Popular Events",
"no_event_types_found": "No event types found",
"average_event_duration": "Average Event Duration",
"most_booked_members": "Most Booked Members",
"least_booked_members": "Least Booked Members",
"events_created": "Events Created",
"events_completed": "Events Completed",
"events_cancelled": "Events Cancelled",
"events_rescheduled": "Events Rescheduled",
"from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"analytics_for_organisation": "Analytics for {{organisationName}}",
"subtitle_analytics": "This is a organisation analytics",
"event_trends": "Event Trends"
}

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