164 Commits
v1.1 ... v1.2

Author SHA1 Message Date
Bailey Pumfleet
33694196e1 Calendly & SavvyCal import (#1512)
* Calendly & SavvyCal import

* added string keys to import

* Update pages/api/import/savvycal.ts

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

* Update pages/api/import/savvycal.ts

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

* Update pages/getting-started.tsx

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

* fixed string

* prettier

Co-authored-by: Peer Richelsen <peeroke@richelsen.net>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-01-15 16:23:42 +00:00
github-actions[bot]
b5569c6b1c New Crowdin translations by Github Action (#1520)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-01-15 15:35:31 +00:00
Peer Richelsen
4e74c0e27f fixed switching background color (#1519) 2022-01-14 22:01:06 +00:00
Peer Richelsen
5e8a80001d added roadmap link in dropdown (#1510)
* added roadmap link in dropdown

* Update public/static/locales/en/common.json

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-01-14 20:39:17 +00:00
Peer Richelsen
9921b76da0 removes trial banner on tablet and smaller (#1516) 2022-01-14 20:38:47 +00:00
Omar López
73f607f27a Auto-seed when resetting and migrating dev (#1513)
* Auto-seed when resetting and migrating dev

* Fixes db-seed script

* Oauth e2e test fixes
2022-01-14 18:36:53 +00:00
Syed Ali Shahbaz
fac4de1144 Enhancement/cal 708 delete account (#1403)
* --WIP

* --WIP

* --WIP

* added prisma migration and delete cascade for user

* stripe customer removal and other --wip

* --wip

* added stripe user delete

* removed log remnants

* fixed signout import

* cleanup

* Changes requested

* fixed common-json apostrophe

* Simplifies account deletion logic and add e2e tests

* Cleanup

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2022-01-14 13:49:15 +00:00
Omar López
e5f8437282 Fixes oauth tests (#1506)
* Fixes oauth tests

* Login page type fixes

* Delegates approval to github UI
2022-01-13 13:45:44 -07:00
Deepak Prabhakara
1a20b0a0c6 Add log in with Google and SAML (#1192)
* Add log in with Google

* Fix merge conflicts

* Merge branch 'main' into feature/copy-add-identity-provider

# Conflicts:
#	pages/api/auth/[...nextauth].tsx
#	pages/api/auth/forgot-password.ts
#	pages/settings/security.tsx
#	prisma/schema.prisma
#	public/static/locales/en/common.json

* WIP: SAML login

* fixed login

* fixed verified_email check for Google

* tweaks to padding

* added BoxyHQ SAML service to local docker-compose

* identityProvider is missing from the select clause

* user may be undefined

* fix for yarn build

* Added SAML configuration to Settings -> Security page

* UI tweaks

* get saml login flag from the server

* UI tweaks

* moved SAMLConfiguration to a component in ee

* updated saml migration date

* fixed merge conflict

* fixed merge conflict

* lint fixes

* check-types fixes

* check-types fixes

* fixed type errors

* updated docker image for SAML Jackson

* added api keys config

* added default values for SAML_TENANT_ID and SAML_PRODUCT_ID

* - move all env vars related to saml into a separate file for easy access
- added SAML_ADMINS comma separated list of emails that will be able to configure the SAML metadata

* cleanup after merging main

* revert mistake during merge

* revert mistake during merge

* set info text to indicate SAML has been configured.

* tweaks to text

* tweaks to text

* i18n text

* i18n text

* tweak

* use a separate db for saml to avoid Prisma schema being out of sync

* use separate docker-compose file for saml

* padding tweak

* Prepare for implementing SAML login for the hosted solution

* WIP: Support for SAML in the hosted solution

* teams view has changed, adjusting saml changes accordingly

* enabled SAML only for PRO plan

* if user was invited and signs in via saml/google then update the user record

* WIP: embed saml lib

* 302 instead of 307

* no separate docker-compose file for saml

* - ogs cleanup
- type fixes

* fixed types for jackson

* cleaned up cors, not needed by the oauth flow

* updated jackson to support encryption at rest

* updated saml-jackson lib

* allow only the required http methods

* fixed issue with latest merge with main

* - Added instructions for deploying SAML support
- Tweaked SAML audience identifier

* fixed check for hosted Cal instance

* Added a new route to initiate Google and SAML login flows

* updated saml-jackson lib (node engine version is now 14.x or above)

* moved SAML instructions from Google Docs to a docs file

* moved randomString to lib

* comment SAML_DATABASE_URL and SAML_ADMINS in .env.example so that default is SAML off.

* fixed path to randomString

* updated @boxyhq/saml-jackson to v0.3.0

* fixed TS errors

* tweaked SAML config UI

* fixed types

* added e2e test for Google login

* setup secrets for Google login test

* test for OAuth login buttons (Google and SAML)

* enabled saml for the test

* added test for SAML config UI

* fixed nextauth import

* use pkce flow

* tweaked NextAuth config for saml

* updated saml-jackson

* added ability to delete SAML configuration

* SAML variables explainers and refactoring

* Prevents constant collision

* Var name changes

* Env explainers

* better validation for email

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

* enabled GOOGLE_API_CREDENTIALS in e2e tests (Github Actions secret)

* cleanup (will create an issue to handle forgot password for Google and SAML identities)

Co-authored-by: Chris <76668588+bytesbuffer@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2022-01-13 20:05:23 +00:00
github-actions[bot]
ffc0f460a0 New Crowdin translations by Github Action (#1500)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-01-13 19:53:26 +00:00
Omar López
c9c21e6a67 Github Workflow cleanup (#1504)
* Update lint.yml

* Build and e2e on PRs

* Adds security checks for PRs

* removes build workflow

Is not needed anymore thanks to E2E
2022-01-13 12:51:15 -07:00
Philip Niedertscheider
9c94aadbf7 Fixed Google Calendar custom destination calendar deletion (#1486)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-01-13 19:47:15 +00:00
Omar López
f8c036164c Adds security checks for PRs (#1503)
* Update lint.yml

* Build and e2e on PRs

* Adds security checks for PRs
2022-01-13 12:37:34 -07:00
Omar López
67bcbfd75a Runs lint on all PRs to main (including external contributors) (#1502)
* Update lint.yml

* Build and e2e on PRs
2022-01-13 18:09:37 +00:00
Omar López
54be2a2ec1 Update e2e.yml (#1498) 2022-01-13 16:52:31 +00:00
github-actions[bot]
4608b9d56d New Crowdin translations by Github Action (#1494)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-01-12 22:22:16 +00:00
Manjunath Reddy
8d9c69916b Update instructions to create new user record (#1489) 2022-01-12 22:18:19 +00:00
github-actions[bot]
643e64a0e4 New Crowdin translations by Github Action (#1493) 2022-01-12 22:17:09 +00:00
Peer Richelsen
20404611b0 Fix/remove date selector telementry (#1491) 2022-01-12 22:16:38 +00:00
Bailey Pumfleet
f7fda47534 Add ability to change email (#1492) 2022-01-12 21:54:48 +00:00
Bailey Pumfleet
c48d0d6c34 Fix reorder arrow alignment (#1487) 2022-01-12 13:57:51 +00:00
Bailey Pumfleet
861cfdfed0 Fix the query invalidation 2022-01-12 13:46:24 +00:00
github-actions[bot]
fbc1df9a30 New Crowdin translations by Github Action (#1475)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-01-12 13:13:24 +00:00
Syed Ali Shahbaz
ac6275b906 hotfix for images hosted elsewhere and link stored in DB (#1480)
* hotfix for images hosted elsewhere and link stored in DB

* improved if else

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-01-12 13:06:39 +00:00
Jamie Pine
d2b9e67424 fix (#1482) 2022-01-12 12:34:50 +00:00
Jamie Pine
70683a89b9 Added "New Event Type" button on Team settings (#1411)
- Moved CreateNewEventButton in pages/event-types/index to dedicated component as this is used in two places now.
- Implemented CreateEventType button on Team settings screen and replaced old markup in on event types page with new component.
- Upgrade vanilla JS inputs to library primitives.
- Created TextArea & TextAreaField components in components/form.
- [Bugfix] Changed back button behavior in Shell to have a specified back path as CreateEventType's modal interfered with the router.goBack behavior.
- Ensure modal data is preserved in URL params for router accuracy and removed on exit.
2022-01-12 01:29:20 -08:00
Peer Richelsen
59d4d92b52 shorten bio for og-image (#1477) 2022-01-11 16:26:45 +00:00
Bailey Pumfleet
8b68263800 Update feature_request.md 2022-01-11 11:38:22 +00:00
Bailey Pumfleet
7739994f4e Add an away mode to disable your booking page (#1418)
* Add away column and status circle

* Add away status toggle

* Show message on booking page when away

* Update common.json

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-01-11 10:32:40 +00:00
Peer Richelsen
a61cb690af temporary fixes daily issue (#1469) 2022-01-11 10:24:37 +00:00
Syed Ali Shahbaz
e24d8889fc Cal 710 turn dataimagejpegbase64 avatar into (#1429)
* --wip

* added next-config custom header path

* added avatar endpoint

* cleanup --wip

* adding gravatar fallback support --wip

* added endpoint rewrite and avatar access

* gravatar support added

* build err fix

* updated HeadSEO with new avatar logic

* --wip

* adds og compat

* added truncated bio

* cleanup

* changed truncate of body from 24 chars to 32 chars

* removed unused, commented code

* removed trailing whitespace

* requested changes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-01-11 08:54:02 +00:00
github-actions[bot]
f0abf47ecc New Crowdin translations by Github Action (#1465)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-01-10 23:32:53 +00:00
Alex van Andel
1c0c3c7690 Fix reschedule uid not prepopulating fields (#1416) 2022-01-10 23:25:06 +00:00
Muhammad Redho Ayassa
09c4040ce5 fix missing date in book page (#1430) 2022-01-08 16:54:02 +00:00
github-actions[bot]
57eeb48a8e New Crowdin translations by Github Action (#1433)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-01-07 22:24:48 +00:00
Omar López
84d75cf693 Upgrades next-auth to v4 (#1185)
* Upgrades next-auth to v4

* Fixes next-auth session types

* Type fixes

* Fixes login issue

* Team page fixes

* Type fixes

* Fixes secret

* Adds test for forgotten password

* Skips if pw secret is undefined

* Prevents error if PW secret is undefined

* Adds PLAYWRIGHT_SECRET explainer

* Adds pending auth TODOs

* Adds missing secret

* Fixed imports

* Fixed imports

* Type fixes

* Test fixes

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-01-07 20:23:37 +00:00
Bailey Pumfleet
bf46038474 Update common.json 2022-01-07 10:23:07 +00:00
Peer Richelsen
e93b7d942a hide slug event-types on mobile (#1426) 2022-01-06 22:13:38 +00:00
Edward Fernández
6e7359ae96 fix code validation when the google calendar integration does not have all permission (#1425)
Co-authored-by: Edward Fernandez <edward.fernandez@rappi.com>
2022-01-06 15:06:31 -07:00
Edward Fernández
bd2a795d7a [CAL-770] add new integration architecture revamp (#1369)
* [CAL-770] add new integration architecture revamp

* Type fixes

* Type fixes

* [CAL-770] Remove tsconfig.tsbuildinfo

* [CAL-770] add integration test

* Improve google calendar test integration

* Remove console.log

* Change response any to void in the deleteEvent method

* Remove unnecesary const

* Add tsconfig.tsbuildinfo to the .gitignore

* Remove process env variables as const

Co-authored-by: Edward Fernández <edwardfernandez@Edwards-Mac-mini.local>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Edward Fernandez <edward.fernandez@rappi.com>
2022-01-06 12:28:31 -05:00
Peer Richelsen
8a70ea66e9 added url to event-types preview (#1420)
* added url to event-types preview

* wip

* wip
2022-01-06 10:39:16 +00:00
Peer Richelsen
46df4c048e hide order buttons on mobile event types (#1421)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-01-06 10:24:19 +00:00
github-actions[bot]
592dcd36b3 New Crowdin translations by Github Action (#1412)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-01-06 10:12:35 +00:00
Flemming
3bb76a3a62 Update Readme to create a local .env (#1313) 2022-01-04 17:19:44 +00:00
Flemming
4537117624 [WIP] Create password change test (#1333) 2022-01-04 17:18:49 +00:00
André Ricardo
9b5da1bca3 performance increase for the DatePicker component (#1404)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-01-04 10:06:05 +00:00
github-actions[bot]
80bd7fd89b New Crowdin translations by Github Action (#1407)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-01-04 10:05:19 +00:00
Omar López
a66610d9c2 Zomars/cal 777 switching off paid mode bug (#1401)
* Adds Form component

* Disabling price sets it to 0
2022-01-03 23:19:05 +00:00
Omar López
4cd7a4ce5b Adds trial banner and in-app upgrade (#1402)
* WIP trial banner

* Fixes days left count

* Defers stripe loading until needed

* Fixes auth issues

* Checkout fixes

* Allows for signup testing

* Debugging checkout

* Adds tests for trial banner

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-01-03 22:50:59 +00:00
Peer Richelsen
baa7e868bd Fix/bookings mobile (#1405)
* added title for truncated description booking page

* minor changes
2022-01-03 12:01:06 +00:00
Omar López
445faa406a Zomars/cal 798 issue with billing portal (#1392)
* Uses stripeCustomerId from used metadata in billing portal

* Uses stripeCustomerId from used metadata in billing portal

# Conflicts:
#	ee/pages/api/integrations/stripepayment/portal.ts
2021-12-30 11:42:06 -05:00
Syed Ali Shahbaz
4be4a01968 Blank success page in January bookings (#1399)
* added 1 to UTC month conversion to make it 1 to 12

* with as numtype
2021-12-30 09:04:08 -07:00
Alex van Andel
bc46f4fbc4 Fixes day of month start day (#1389) 2021-12-29 08:27:49 +00:00
Omar López
9b583694a3 Adds users metadata column (#1387) 2021-12-28 10:15:52 +01:00
Peer Richelsen
81e2ae1352 fixed mobile layouts for team and team availability (#1382) 2021-12-27 19:54:11 +01:00
depfu[bot]
3e1fe30186 Update all Yarn dependencies (2021-11-29) (#1233)
* Update all Yarn dependencies (2021-11-29)
2021-12-27 16:00:15 +00:00
github-actions[bot]
f91ed7837c New Crowdin translations by Github Action (#1384)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-12-27 15:31:27 +01:00
Adrien La
1567feb75e i18n - Translate booking status when empty screen (#1219)
* i18n translate booking status when empty screen

* i18n - status key added to fr & en

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-12-27 13:29:43 +01:00
github-actions[bot]
43a721dce6 New Crowdin translations by Github Action (#1381)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-12-27 13:11:23 +01:00
albanobattistella
0f82427b1e Update common.json (#1380) 2021-12-27 12:52:24 +01:00
Alex van Andel
3e5987abec For now; entirely disable sharing owner role (#1372) 2021-12-22 18:05:49 -07:00
Nathaniel
3761a75b28 fixed zoom video not creating when credentials are not valid (#1329)
* fixed nextcloud

* fixed nextcloud & fastmail issues

* fixed zoom video not creating when credentials are not valid
also fixed reponse to reflect create failure.

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-12-22 13:38:24 -07:00
Alex van Andel
9d7dc09974 Use the matched user email to send the password reset to (#1366) 2021-12-21 18:31:32 +01:00
Syed Ali Shahbaz
bab72f1514 upgraded prisma to v3.0.2 (#1284)
* upgraded prisma to v3.0.2

* updated queryRaw changes for prisma 3

* queryRaw further changes

* --wip

* --wip

* --WIP

* Preview flag "selectRelationCount" is not needed anymore

* Adds missing migrations

* removed temporary test

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
2021-12-21 08:32:16 -07:00
github-actions[bot]
fbbd7ea45a New Crowdin translations by Github Action (#1362) 2021-12-21 12:23:40 +00:00
Omar López
7bc7b241ac Zomars/cal 794 normalize emails in db (#1361)
* Email input UX improvements

* Makes email queries case insensitive

* Lowercases all emails

* Type fixes

* Re adds lowercase email to login

* Removes citext dependency

* Updates schema

* Migration fixes

* Added failsafes to team invites

* Team invite improvements

* Deleting the index, lowercasing 

```
calendso=> UPDATE users SET email=LOWER(email);
ERROR:  duplicate key value violates unique constraint "users.email_unique"
DETAIL:  Key (email)=(free@example.com) already exists.
```

vs.

```
calendso=> CREATE UNIQUE INDEX "users.email_unique" ON "users" (email);
ERROR:  could not create unique index "users.email_unique"
DETAIL:  Key (email)=(Free@example.com) is duplicated.
```

I think it'll be easier to rectify for users if they try to run the migrations if the index stays in place.

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-12-21 00:59:06 +00:00
Syed Ali Shahbaz
0dd72888a9 removed team from profile url (#1359) 2021-12-20 17:26:35 +01:00
Nikolay Rademacher
a6382cf07f fix: apostrophe in delete event type text (#1353)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-12-20 15:00:16 +00:00
Alex van Andel
39761c520e Uses Intl to translate weekdays and time related booking i18n (#1354)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-12-20 11:55:49 +00:00
Jamie Pine
c9a8bd369c fixes VSCode auto importing (#1358)
automatic imports resolved to relative (".../../components") instead of respecting tsconfig path ("@components")
2021-12-20 10:38:46 +00:00
Peer Richelsen
d95e26d55c fixes 404 for subpaths, adds prefilled url to sign up form (#1355)
* fixes 404 for subpaths, adds prefilled url to sign up form

* Added tweak to support BASE_URL for self hosted (#1356)

* Added tweak to support BASE_URL for self hosted (without linking to our signup)
* also hides the signup popular page

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-12-20 09:11:39 +00:00
Omar López
3bc659af44 Let email case sensitive (#1357) 2021-12-19 21:01:25 +00:00
Alex van Andel
cbf528c33e Allows setting the event frequency to other than event length (#1349) 2021-12-19 12:11:31 +00:00
Joel Lu
38f762f7b2 fix: refresh UserDropdown after update (#1352)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-12-18 15:06:05 +00:00
Alex van Andel
94a10992d2 Fixes contextual translation error (NL) (#1350)
Currently it translates as "Minimum preparation time" - contextually this could be right but in our case it isn't. Re-translated as "Minimum forward notice" - it's the closest I could come up with and used by booking.nl (Dutch version of Booking.com) - so I reckon that's best.
2021-12-18 10:44:19 +00:00
Omar López
26e1194ef3 Trigger redeploy (#1348) 2021-12-17 20:37:07 +00:00
Omar López
21103580f7 Zomars/cal 748 paid bookings are failing (#1335)
* E2E video adjustments

* Adds test to add Stripe integration

* Type fix

* WIP: Payment troubleshooting

* Paid bookings shouldn't be confirmed by default

* Runs stripe test only if installed

* BookingListItem Adjustments

* Pending paid bookings should be unconfirmed

* Attempt to fix paid bookings

* Type fixes

* Type fixes

* Tests fixes

* Adds paid booking to seeder

* Moves stripe tests to own file

* Matches app locale to Stripe's

* Fixes minimun price for testing

* Stripe test fixes

* Fixes stripe frame test

* Added some Stripe TODOs
2021-12-17 16:58:23 +00:00
Syed Ali Shahbaz
ca405743fb removed brand color from stripe card (#1342) 2021-12-17 13:44:38 +00:00
Peer Richelsen
6b426b5386 Revert "removed empty language files, triyng to debug crowdin (#1341)" (#1344) 2021-12-17 11:32:23 +00:00
Peer Richelsen
c0c4cb53db removed empty language files, triyng to debug crowdin (#1341) 2021-12-17 00:29:08 +00:00
Jamie Pine
c21f0c2d49 Even Better Teams (#1304)
- dropdown improvements
- Improve performance of team availability
- Fix default timezone
- Allow team admins to edit event types
- Change team availability slot input to dropdown select (15,30,60)
- Prevent teams from access if not pro user
2021-12-17 00:16:59 +00:00
Jamie Pine
4ce879e5dc UX improvement to public facing team pages
- Added default member avatars
- Fixed member item spacing
- Added team description (#1305)
2021-12-17 00:12:06 +00:00
Jamie Pine
25372b3c9e - fix border radius (#1339)
- upgrade button / input components
- clean up markup
2021-12-16 17:03:32 -07:00
Alex van Andel
a3bd226347 Bugfix/year change (#1323) 2021-12-16 15:20:38 +00:00
Omar López
e6f71c81bb E2E tests refactoring (#1318)
* Adds test todos

* Can't seem to change locales

* WIP playwright test refactoring

* jest-playwright cleanup

* Test fixes

* Test fixes

* More test fixes

* WIP: Testing fixes

* More test fixes

* Removes unused files

* Installs missing browsers for e2e

* ts-node fixes

* ts-check fixes

* Type fixes

* Fixes e2e

* FFS

* Renamex webhook snapshot

* Fixes webhook cross-platform

* Renamed webhook snapshot

* Apply suggestions from code review

Co-authored-by: Max Schmitt <max@schmitt.mx>

* Removes kont dependency

* Cleanup playwright options

* Next.js cache optimizations on CI

* Uses cache on e2e as well

* Fixme is introducing side-effects

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Max Schmitt <max@schmitt.mx>
2021-12-15 16:25:49 +00:00
Peer Richelsen
972402be2c removed unused roboto.ttf (#1327) 2021-12-15 15:23:03 +00:00
Alex van Andel
5c5d9d3406 Fixes zoom expiry date (#1315)
* Fixes zoom expiry date

* Ensure backwards compatibility with old zoom connections

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-12-15 14:02:39 +00:00
Peer Richelsen
c2a60657d4 removed overflow hidden from dialog to fix dropdowns (#1321)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-12-15 13:47:38 +00:00
Syed Ali Shahbaz
d2965627d0 added brand color to dark mode timepicker (#1307) 2021-12-15 10:26:39 +00:00
Omar López
5deea2c5f6 Fixing items readded to location dropdown issue (#1316)
Co-authored-by: Manoj <yogeshwaranmanoharan@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-12-14 22:14:35 +00:00
Peer Richelsen
7e6d56ca1f update crowdin.yml 2021-12-14 21:59:39 +00:00
Peer Richelsen
725a7ec0f4 fixed tooltips (#1311) 2021-12-14 12:44:11 +00:00
Alex Johansson
ad8ffd3de4 prevent i18n flickering on pages (#1308)
* prevent i18n flickering on pages

- 404
- `/cancel`
- `/success`

* ssg for 404

* comments

* tweak

* 404

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-12-14 12:31:54 +00:00
Peer Richelsen
8e447ea4b5 fixed border on success (#1310) 2021-12-14 12:29:15 +00:00
Syed Ali Shahbaz
8bbfc0c7f0 Adds complementing text color for various brand colors that the user might choose (#1289)
* added contrast evaluator

* added brandtext --WIP

* further changes and fixes

* fixed type err

* fixed datepicker bug

* changed brandtext to brandcontrast

* further dark mode changes

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-12-14 10:39:32 +00:00
Jamie Pine
2abd7779ac Fix for incorrect <hr /> color as result of Tailwind upgrade (#1303) 2021-12-13 21:14:26 -07:00
Omar López
b6518b9ce1 Fixes cancel booking page (#1301) 2021-12-13 23:10:10 +00:00
Nathaniel
43c939e342 Fixed nextcloud & fastmail events not created (#1300)
* fixed nextcloud

* fixed nextcloud & fastmail issues
2021-12-13 13:58:09 +00:00
Omar López
357e279dd8 Upgrades to tailwindcss v3 (#1294)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-12-13 11:01:33 +00:00
Alex van Andel
8afcba23c8 Commented out minimumBookingNotice, needs fixing (#1297) 2021-12-12 00:09:34 +01:00
Jamie Pine
c359ebe85c fix for this horrific bug (#1295) 2021-12-11 16:32:25 +00:00
Omar López
3587e1ac9c Uses vercel url on integration endpoints for staging (#1293)
* Legibility and base url fixes

* Uses vercel url on integration endpoints for staging

* We validate the user before creating credentials
2021-12-10 21:14:54 +00:00
sec0ndhand
3ff99f7877 Fix timezones being returned from office 365 (#1269)
Per the [api documentation](https://docs.microsoft.com/en-us/graph/api/calendar-list-calendarview?view=graph-rest-1.0&tabs=javascript#query-parameters)
the `Prefer: outlook.timezone` is ignored if a timezone value is passed
in the request.  This forces the dates to be passed in UTC.

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-12-10 19:00:03 +00:00
Jamie Pine
c1d90eb438 Improvement/teams (#1285)
* [WIP] checkpoint before pull & merge

- Added teams to sidebar
- Refactored team settings
- Improved team list UI

This code will be partly reverted next commit.

* [WIP]
- Moved team code back to components
- Removed team link from sidebar
- Built new team manager screen based on Event Type designs
- Component-ized frequently reused code (SettingInputContainer, FlatIconButton)

* [WIP]
- Created LinkIconButton as standalone component
- Added functionality to sidebar of team settings
- Fixed type bug on public team page induced by my normalization of members array in team query
- Removed teams-old which was kept as refrence
- Cleaned up loose ends

* [WIP]
- added create team model
- fixed profile missing label due to my removal of default label from component

* [WIP]
- Fixed TeamCreateModal trigger
- removed TeamShell, it didn't make the cut
- added getPlaceHolderAvatar
- renamed TeamCreate to TeamCreateModal
- removed deprecated UsernameInput and replaced uses with suggested TextField

* fix save button

* [WIP]
- Fixed drop down actions on team list
- Cleaned up state updates

* [WIP] converting teams to tRPC

* [WIP] Finished refactor to tRPC

* [WIP] Finishing touches

* [WIP] Team availability beginning

* team availability mvp

* - added validation to change role
- modified layout of team availability
- corrected types

* fix ui issue on team availability screen

* - added virtualization to team availability
- added flexChildrenContainer boolean to Shell to allow for flex on children

* availability style fix

* removed hard coded team type as teams now use inferred type from tRPC

* Removed unneeded vscode settings

* Reverted prisma schema

* Fixed migrations

* Removes unused dayjs plugins

* Reverts type regression

* Type fix

* Type fixes

* Type fixes

* Moves team availability code to ee

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
2021-12-09 23:51:30 +00:00
Omar López
5902f78fb2 Zomars/calendars UI fixes (#1288)
* Updates yarn.lock

* Primary calendar selector UI fixes

* Update translation string

* Mobile fixes
2021-12-09 19:37:29 +00:00
Omar López
8617b2db65 Adds deploy script (#1286) 2021-12-09 16:19:52 +00:00
Alex Johansson
850497ea80 add select primary calendar (#1133)
* add primary

* fix

* refactor eventmanager to take `CalendarDestination`

* `DestinationCalendar`

* fix

* wip

* wip

* Minor fixes (#1156)

* Followup for #1242

* Updates schema

* Renames fields to destinationCalendar

* Migration fixes

* Updates user destination calendar

* Abstracts convertDate to BaseCalendarApiAdapter

* Type fixes

* Uses abstracted convertDate method

* Abstracts getDuration and getAttendees

* Fixes circular dependecy issue

* Adds notEmpty util

* Reverts empty location string

* Fixes property name

* Removes deprecated code

* WIP

* AppleCal is basically CalDav

* Fixes missing destinationCalendar

* Type fixes

* Select primary calendar on Office and gCal

* Adds pretty basic instructions for destination calendar

* Cleanup

* Type fix

* Test fixes

* Updates test snapshot

* Local test fixes

* Type fixes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-12-09 15:51:37 +00:00
Nathan
1890d5daf7 Update event.ts (#1280) 2021-12-09 12:25:38 +00:00
Joel Lu
8d4b3c1c2c Fix user dropdown text overflow (#1283) 2021-12-09 11:53:34 +00:00
Bailey Pumfleet
22a6d6ee3b Swap availability and booking icons (#1276) 2021-12-08 19:28:36 +00:00
Alex Johansson
05fa1feab0 Migrate availability schedule for everyone (#1179)
* wip

* tmp mig

* add cron api key to header

* feels safer

* Revert "wip"

This reverts commit 15a8358661c6785ce9eedb93a473a8829f79d760.

* test

* add name

* normalize dates

* maybe works

* test

* fixz

* maybe fix ci

* deprecated

* step 1 -- raw sql

* seems to work

* migration seems to work

* br

* fix comment

* timouet

* disconnect prisma test

* test order

* maybs

* seems to work

* tz

* tweak

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-12-08 12:08:57 +00:00
Bill Gale
bbf96a2e1d fix: team avatar booking option 2 (#1274) 2021-12-08 11:40:48 +00:00
depfu[bot]
3b00bc7508 Update next to version 12.0.7 (#1270)
Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2021-12-08 09:36:03 +00:00
Alex van Andel
878c8b8248 Reversed order of custom inputs & notes (#1268) 2021-12-07 21:05:29 +00:00
Bill Gale
23127318dc fix: prevent image uploader converting every image to jpeg (#1262) 2021-12-07 17:05:26 +00:00
Bill Gale
db7711869f fix: add team bio to public page (#1265) 2021-12-07 16:11:43 +00:00
Omar López
ec2acedf34 Zomars/refactor emails followup (#1216) 2021-12-07 15:48:08 +00:00
Mihai C
d76ef4a007 fix: calendar event description (#1266) 2021-12-07 15:32:07 +00:00
Joel Lu
c43e6783a7 Fix: duplicate team name no prompt (#1267)
* Fix: duplicate team name no prompt

* Fix syntax error for error message
2021-12-07 15:04:34 +00:00
Bill Gale
91f2c380c5 fix: event description for teams on mobile (#1261)
* fix: event description for teams on mobile

* fix: replace sm:truncate with overflow-ellipsis overflow-hidden

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-12-07 10:08:25 +00:00
Bill Gale
b11d81fdd2 fix: user bio width when booking a team member (#1264) 2021-12-07 10:02:36 +00:00
Bill Gale
6792e17c80 fix: move pull request template (#1263)
* fix: move pull request template

* fix: rename template
2021-12-07 10:01:45 +00:00
Mihai C
dd446abeec fix: calendar event fixes (#1260)
* fix: calendar event fixes

* update after code review
2021-12-06 19:34:31 +00:00
Rory Hughes
c109ab1e30 Ensure credential objects come oldest first (#1258)
Given the credentials are loaded based on userId, sort is not consistent.
Without this, events are booked on whichever calendar credential is loaded first. 
813eaa83b7/lib/events/EventManager.ts (L240-L244)

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-12-06 13:25:22 +00:00
Rory Hughes
dc13c95644 Favour the user's default calendar notification settings without overriding (#1259)
If people want emails for every event on their calendar, they can set that up.
2021-12-06 13:24:32 +00:00
Joel Lu
c85f0650fe Fix time view not in full length (#1256)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-12-06 11:37:15 +00:00
Alex van Andel
7e6628e3ac Fixes missing locations (#1257) 2021-12-06 11:22:35 +00:00
Peer Richelsen
e8af9110a7 fixed border color of daily video (#1255) 2021-12-06 09:49:56 +00:00
TomBoss
ec9c8bb35d correction of typo (#1251) 2021-12-03 16:50:39 +00:00
Alex van Andel
22aa083883 Adds eventTypeId as a parameter (#1217) 2021-12-03 16:18:31 +00:00
Alex van Andel
8c1b69cc0f Feature/field prefills (#1249)
* Needs more testing, but looks functional

* Add metadata feature to booking create payload

* Forward URL parameters given in link

* Moved stringifying of custom inputs to backend
2021-12-03 10:15:20 +00:00
Peer Richelsen
8d1d3fcc7a fixed subtitle for event-types (#1247) 2021-12-02 22:32:38 +00:00
Mike Casey
dd48749f42 Update README instructions new user record (#1103)
This commit clarifies the minimum required fields that must be set for the new user record in Prisma Studio to avoid the createContext error  when first starting the application on a local development environment

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-12-02 22:15:05 +00:00
Peer Richelsen
bd51316c68 removed imgur from emails, added missing / to footer logo (#1245) 2021-12-02 22:05:58 +00:00
Omar López
2430784142 Followup for #1242 (#1243) 2021-12-02 21:41:09 +00:00
Peer Richelsen
2b51cd9c8d tablet navigation: removed code redundancy & fixed alignment (#1241)
* minor design changes to tablet navigation

* added white icon logo

* reduced code redundancy in tablet view & fixed alignment
2021-12-02 20:52:38 +00:00
Alex Johansson
de3c4aa75a fix zoom leading to integrations:zoom location (#1242) 2021-12-02 17:18:17 +00:00
Peer Richelsen
813eaa83b7 minor design changes to tablet navigation (#1240) 2021-12-02 09:52:59 +00:00
Syed Ali Shahbaz
ec2d0a89ba Bugfix/event types buggy view on tablet (#1238) 2021-12-01 14:56:25 +00:00
Mihai C
80a2b6c068 chore: clean up and fix images (#1224)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-12-01 10:32:08 +00:00
Omar López
7faa9508c4 Removed unused webhook (#1227) 2021-11-30 23:50:49 +00:00
Flemming
5773d064c2 Prevent user form entering negative numbers into event duration (#1231)
* Add Docker Compose as requirement to run the quick start

* Add basic frontend validation/needs for event duration

* Only add min prop to the duration field

* Don't allow negative value for the event buffer time

* Increase min duration of a event type

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-11-30 19:18:28 +00:00
depfu[bot]
0a7233d452 Update all Yarn dependencies (2021-11-26) (#1223)
Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2021-11-29 20:53:02 +00:00
Syed Ali Shahbaz
529f3027cd replaced btn-primary, btn-secondary, btn-white with respective Button equivalents (#1218)
* replaced btn primary, secondary, white with Button

* removed unused Link var

* replaced <button> in settings/security

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Peer Richelsen <peeroke@richelsen.net>
2021-11-29 06:37:31 +00:00
Peer Richelsen
20cbab1c15 fixed edit icon for event-type details (#1230) 2021-11-28 08:54:45 +00:00
Flemming
98d3cb1915 Add Docker Compose as requirement to run the quick start (#1225) 2021-11-26 18:36:01 +00:00
Mihai C
8322e5c8d1 Emails Revamp (#1201)
* refactor: emails (WIP)

* wip

* wip

* refactor: calendarClient

* chore: remove comment

* feat: new templates

* feat: more templates (wip)

* feat: email templates wip

* feat: email templates wip

* feat: prepare for testing

* For testing stripe integration

* Uses imported BASE_URL

* Fixes types

* use BASE_URL

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-11-26 11:03:43 +00:00
Adrien La
36767afbf5 Add full FR language translation from EN (#1202)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-11-25 16:04:13 +00:00
Alex van Andel
396355e350 Call setSelectedTimeZone as we don't want to refactor Scheduler (#1213)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-11-25 16:03:37 +00:00
depfu[bot]
da2c825dad Update all Yarn dependencies (2021-11-24) (#1212)
Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2021-11-25 13:51:15 +01:00
depfu[bot]
dab146a313 Update all Yarn dependencies (2021-11-15) (#1176)
Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-11-24 23:33:25 +00:00
Alex van Andel
712266664e Fixes #1205 - Able to schedule for past times in current date (#1211) 2021-11-24 22:53:41 +00:00
Omar López
f8781e4d5f Create pull_request_template.md (#1204) 2021-11-24 18:40:25 +00:00
Syed Ali Shahbaz
d8b3c42c28 Improvement/cal 639 turn edit location dialog into radix uu (#1055)
* replaced disclosure with collapsible

* added radix radio-group

* removed radix-UI radio group

* more fix

* --WIP

* --WIP

* react-hook-formify --WIP

* radix ui radio replaces headless ui radio

* further fixes --WIP

* --WIP

* form handling and availability wrapping

* minuteField fix

* availability fix

* fixed react-select menu overflow in dialog

* --WIP

* added default value for custom inputs

* fixed locations

* fixed daterangepicker

* fixed team eventType

* basic cleanup --wip

* fixed locations removal bug

* removed old locations state remnants

* some cleanup --wip

* rebase conflict resolution

* removed debug rem and fixed radio text size

* further requested changes

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Omar López <zomars@me.com>
2021-11-24 10:07:49 -07:00
Nathaniel
24e36ad46a upgrade tsdav to v1.1.5 (#993)
* upgrade tsdav to v1.1.1

* upgrade to v1.1.5

* updated lockfile

* updated tsdav

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@richelsen.net>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-11-24 11:52:57 +00:00
Alex Johansson
644b0f1b0e re-enable batching (#1199)
This reverts commit 58f55f84e2. /  #1197
2021-11-24 11:45:09 +00:00
Alex Johansson
deb97fdab0 get rid of circular references in viewer.eventTypes (#1198) 2021-11-24 10:42:55 +00:00
Alex Johansson
58f55f84e2 temporarily disable batching (#1197) 2021-11-24 09:53:03 +00:00
Alex van Andel
5b3dd02747 Webhook tweaks + Support added for "Custom payload templates" / x-www-form-urlencoded / json (#1193)
* Changed styling of webhook test & updated <Form> component

* Implements custom webhook formats

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-11-22 11:37:07 +00:00
Syed Ali Shahbaz
ecc960f0a3 fixed mobile UI (#1195) 2021-11-22 11:07:17 +00:00
Mihai C
dfb1b5602d hotfix: location on approval email (#1186)
* hotfix: location on confirmation email

* fix: build checks
2021-11-18 11:13:38 +00:00
Alex van Andel
d6dd13a9d8 Updated team to also use getWorkingHours (#1188) 2021-11-18 10:20:48 +00:00
Alex van Andel
e0d1b6b5ea Working availability Schedule for every timezone (few things TODO) (#1187) 2021-11-18 03:29:36 +00:00
Alex van Andel
ffdf0b9217 Fixes user availability to be contextual to the user timezone (#1166)
* WIP, WIP, WIP, WIP

* Adds missing types

* Type fixes for useSlots

* Type fixes

* Fixes periodType 500 error when updating

* Adds missing dayjs plugin and type fixes

* An attempt was made to fix tests

* Save work in progress

* Added UTC overflow to days

* Update lib/availability.ts

Co-authored-by: Alex Johansson <alexander@n1s.se>

* No more magic numbers

* Fixed slots.test & added getWorkingHours.test

* Tests pass, simpler logic, profit?

* Timezone shifting!

* Forgot to unskip tests

* Updated the user page

* Added American seed user, some fixes

* tmp fix so to continue testing availability

* Removed timeZone parameter, fix defaultValue auto-scroll

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
2021-11-18 01:03:19 +00:00
314 changed files with 19882 additions and 9504 deletions

View File

@@ -1,4 +1,4 @@
# Set this value to 'agree' to accept our license:
# Set this value to 'agree' to accept our license:
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
#
# Summary of terms:
@@ -8,14 +8,28 @@
NEXT_PUBLIC_LICENSE_CONSENT=''
# DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
GOOGLE_API_CREDENTIALS='secret'
# Needed to enable Google Calendar integrationa and Login with Google
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS='{}'
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` above
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
GOOGLE_LOGIN_ENABLED=false
BASE_URL='http://localhost:3000'
NEXT_PUBLIC_APP_URL='http://localhost:3000'
JWT_SECRET='secret'
# This is used so we can bypass emails in auth flows for E2E testing
PLAYWRIGHT_SECRET=
# To enable SAML login, set both these variables
# @see https://github.com/calendso/calendso/tree/main/ee#setting-up-saml-login
# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml"
# SAML_ADMINS='pro@example.com'
# @see: https://github.com/calendso/calendso/issues/263
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
@@ -56,11 +70,11 @@ CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
# Stripe Config
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
STRIPE_PRIVATE_KEY= # sk_test_...
STRIPE_CLIENT_ID= # ca_...
STRIPE_WEBHOOK_SECRET= # whsec_...
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
STRIPE_PRIVATE_KEY= # sk_test_...
STRIPE_CLIENT_ID= # ca_...
STRIPE_WEBHOOK_SECRET= # whsec_...
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
# Application Key for symmetric encryption and decryption
# must be 32 bytes for AES256 encryption algorithm

View File

@@ -2,12 +2,10 @@
name: Feature request
about: Suggest a feature or idea
title: ""
labels: enhancement
labels: feature
assignees: ""
---
> Please check if your Feature Request has not been already raised in the [Discussions Tab](https://github.com/calendso/calendso/discussions), as we would like to reduce duplicates. If it has been already raised, simply upvote it 🔼.
### Is your proposal related to a problem?
<!--

31
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,31 @@
## What does this PR do?
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes # (issue)
## Type of change
<!-- Please delete options that are not relevant. -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
## How should this be tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
- [ ] Test A
- [ ] Test B
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code and corrected any misspellings
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes

View File

@@ -1,71 +0,0 @@
name: Build
on: [push]
jobs:
build:
name: Build on Node ${{ matrix.node }} and ${{ matrix.os }}
env:
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
NODE_ENV: test
BASE_URL: http://localhost:3000
JWT_SECRET: secret
services:
postgres:
image: postgres:12.1
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
ports:
- 5432:5432
runs-on: ${{ matrix.os }}
strategy:
matrix:
node: ["14.x"]
os: [ubuntu-latest]
steps:
- name: Checkout repo
uses: actions/checkout@v2
- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Install deps
uses: bahmutov/npm-install@v1
- name: Next.js cache
uses: actions/cache@v2
with:
path: ${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs
- run: yarn prisma migrate deploy
- run: yarn test
- run: yarn build
types:
name: Check types
strategy:
matrix:
node: ["14.x"]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repo
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Install deps
uses: bahmutov/npm-install@v1
- run: yarn check-changed-files

30
.github/workflows/check-types.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Check types
on:
pull_request:
branches:
- main
jobs:
types:
name: Check types
strategy:
matrix:
node: ["14.x"]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repo
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Install deps
uses: bahmutov/npm-install@v1
- run: yarn check-changed-files

View File

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v2
- name: crowdin action
uses: crowdin/github-action@1.4.0
uses: crowdin/github-action@1.4.2
with:
upload_translations: true
download_translations: true

View File

@@ -1,18 +1,29 @@
name: E2E test
on: [push]
on:
pull_request_target:
branches:
- main
jobs:
test:
timeout-minutes: 10
name: ${{ matrix.node }} and ${{ matrix.os }}
env:
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
BASE_URL: http://localhost:3000
JWT_SECRET: secret
GOOGLE_API_CREDENTIALS: "{}"
# GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
PLAYWRIGHT_SECRET: ${{ secrets.CI_PLAYWRIGHT_SECRET }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: true
# CRON_API_KEY: xxx
# CALENDSO_ENCRYPTION_KEY: xxx
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
PAYMENT_FEE_PERCENTAGE: 0.005
PAYMENT_FEE_FIXED: 10
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
SAML_ADMINS: pro@example.com
# NEXTAUTH_URL: xxx
# EMAIL_FROM: xxx
# EMAIL_SERVER_HOST: xxx
@@ -39,6 +50,9 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v1
@@ -51,14 +65,16 @@ jobs:
uses: actions/cache@v2
with:
path: ${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-nextjs
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn test
- run: yarn prisma migrate deploy
- run: yarn db-seed
- run: yarn test
- run: yarn build
- run: yarn start &
- run: npx wait-port 3000 --timeout 10000
- name: Cache playwright binaries
uses: actions/cache@v2
@@ -71,7 +87,7 @@ jobs:
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
- name: Install playwright deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install-deps
run: yarn playwright install --with-deps
- run: yarn test-playwright
@@ -83,3 +99,4 @@ jobs:
path: |
playwright/screenshots
playwright/videos
playwright/results

View File

@@ -1,5 +1,8 @@
name: Lint
on: [push]
on:
pull_request:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest

5
.gitignore vendored
View File

@@ -14,6 +14,8 @@
.nyc_output
playwright/videos
playwright/screenshots
playwright/artifacts
playwright/results
# next.js
/.next/
@@ -36,6 +38,7 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
.env.*
# vercel
.vercel
@@ -54,3 +57,5 @@ yarn-error.log*
# Local History for Visual Studio Code
.history/
# Typescript
tsconfig.tsbuildinfo

View File

@@ -6,8 +6,5 @@
"source.fixAll.eslint": true
},
"eslint.run": "onSave",
"workbench.colorCustomizations": {
"titleBar.activeBackground": "#292929",
"titleBar.inactiveBackground": "#888888"
}
"typescript.preferences.importModuleSpecifier": "non-relative"
}

257
@types/ical.d.ts vendored Normal file
View File

@@ -0,0 +1,257 @@
// SPDX-FileCopyrightText: © 2019 EteSync Authors
// SPDX-License-Identifier: GPL-3.0-only
// https://github.com/mozilla-comm/ical.js/issues/367#issuecomment-568493517
declare module "ical.js" {
function parse(input: string): any[];
export class helpers {
public updateTimezones(vcal: Component): Component;
}
class Component {
public fromString(str: string): Component;
public name: string;
constructor(jCal: any[] | string, parent?: Component);
public toJSON(): any[];
public getFirstSubcomponent(name?: string): Component | null;
public getAllSubcomponents(name?: string): Component[];
public getFirstPropertyValue<T = any>(name?: string): T;
public getFirstProperty(name?: string): Property;
public getAllProperties(name?: string): Property[];
public addProperty(property: Property): Property;
public addPropertyWithValue(name: string, value: string | number | Record<string, unknown>): Property;
public hasProperty(name?: string): boolean;
public updatePropertyWithValue(name: string, value: string | number | Record<string, unknown>): Property;
public removeAllProperties(name?: string): boolean;
public addSubcomponent(component: Component): Component;
}
export class Event {
public uid: string;
public summary: string;
public startDate: Time;
public endDate: Time;
public description: string;
public location: string;
public attendees: Property[];
/**
* The sequence value for this event. Used for scheduling.
*
* @type {number}
* @memberof Event
*/
public sequence: number;
/**
* The duration. This can be the result directly from the property, or the
* duration calculated from start date and end date. Setting the property
* will remove any `dtend` properties.
*
* @type {Duration}
* @memberof Event
*/
public duration: Duration;
/**
* The organizer value as an uri. In most cases this is a mailto: uri,
* but it can also be something else, like urn:uuid:...
*/
public organizer: string;
/** The sequence value for this event. Used for scheduling */
public sequence: number;
/** The recurrence id for this event */
public recurrenceId: Time;
public component: Component;
public constructor(
component?: Component | null,
options?: { strictExceptions: boolean; exepctions: Array<Component | Event> }
);
public isRecurring(): boolean;
public iterator(startTime?: Time): RecurExpansion;
}
export class Property {
public name: string;
public type: string;
constructor(jCal: any[] | string, parent?: Component);
public getFirstValue<T = any>(): T;
public getValues<T = any>(): T[];
public setParameter(name: string, value: string | string[]): void;
public setValue(value: string | Record<string, unknown>): void;
public setValues(values: (string | Record<string, unknown>)[]): void;
public toJSON(): any;
}
interface TimeJsonData {
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
isDate?: boolean;
}
export class Time {
public fromString(str: string): Time;
public fromJSDate(aDate: Date | null, useUTC: boolean): Time;
public fromData(aData: TimeJsonData): Time;
public now(): Time;
public isDate: boolean;
public timezone: string;
public zone: Timezone;
public year: number;
public month: number;
public day: number;
public hour: number;
public minute: number;
public second: number;
constructor(data?: TimeJsonData);
public compare(aOther: Time): number;
public clone(): Time;
public convertToZone(zone: Timezone): Time;
public adjust(
aExtraDays: number,
aExtraHours: number,
aExtraMinutes: number,
aExtraSeconds: number,
aTimeopt?: Time
): void;
public addDuration(aDuration: Duration): void;
public subtractDateTz(aDate: Time): Duration;
public toUnixTime(): number;
public toJSDate(): Date;
public toJSON(): TimeJsonData;
public get icaltype(): "date" | "date-time";
}
export class Duration {
public weeks: number;
public days: number;
public hours: number;
public minutes: number;
public seconds: number;
public isNegative: boolean;
public icalclass: string;
public icaltype: string;
}
export class RecurExpansion {
public complete: boolean;
public dtstart: Time;
public last: Time;
public next(): Time;
public fromData(options);
public toJSON();
constructor(options: {
/** Start time of the event */
dtstart: Time;
/** Component for expansion, required if not resuming. */
component?: Component;
});
}
export class Timezone {
public utcTimezone: Timezone;
public localTimezone: Timezone;
public convert_time(tt: Time, fromZone: Timezone, toZone: Timezone): Time;
public tzid: string;
public component: Component;
constructor(
data:
| Component
| {
component: string | Component;
tzid?: string;
location?: string;
tznames?: string;
latitude?: number;
longitude?: number;
}
);
}
export class TimezoneService {
public get(tzid: string): Timezone | null;
public has(tzid: string): boolean;
public register(tzid: string, zone: Timezone | Component);
public remove(tzid: string): Timezone | null;
}
export type FrequencyValues =
| "YEARLY"
| "MONTHLY"
| "WEEKLY"
| "DAILY"
| "HOURLY"
| "MINUTELY"
| "SECONDLY";
export enum WeekDay {
SU = 1,
MO,
TU,
WE,
TH,
FR,
SA,
}
export class RecurData {
public freq?: FrequencyValues;
public interval?: number;
public wkst?: WeekDay;
public until?: Time;
public count?: number;
public bysecond?: number[] | number;
public byminute?: number[] | number;
public byhour?: number[] | number;
public byday?: string[] | string;
public bymonthday?: number[] | number;
public byyearday?: number[] | number;
public byweekno?: number[] | number;
public bymonth?: number[] | number;
public bysetpos?: number[] | number;
}
export class RecurIterator {
public next(): Time;
}
export class Recur {
constructor(data?: RecurData);
public until: Time | null;
public freq: FrequencyValues;
public count: number | null;
public clone(): Recur;
public toJSON(): Omit<RecurData, "until"> & { until?: string };
public iterator(startTime?: Time): RecurIterator;
public isByCount(): boolean;
}
}

View File

@@ -2,7 +2,7 @@
<p align="center">
<a href="https://github.com/calendso/calendso">
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
</a>
<h3 align="center">Cal.com (formerly Calendso)</h3>
@@ -29,7 +29,7 @@
<a href="https://github.com/calendso/calendso/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
<img src="https://img.shields.io/github/package-json/v/calendso/calendso">
<a href="https://github.com/calendso/calendso/pulse"><img src="https://img.shields.io/github/commit-activity/m/calendso/calendso" alt="Commits-per-month"></a>
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
<a href="https://cal.crowdin.com/Cal"><img src="https://badges.crowdin.net/e/5a55420475b48696779e30e0208a1899/localized.svg" alt="Translate Slack"></a>
</p>
@@ -86,15 +86,17 @@ You will also need Google API credentials. You can get this from the [Google API
## Development
### Setup
#### Quick start with `yarn dx`
> - **Requires Docker to be installed**
> - **Requires Docker and Docker Compose to be installed**
> - Will start a local Postgres instance with a few test users - the credentials will be logged in the console
```bash
git clone git@github.com:calendso/calendso.git
cd calendso
yarn
cp .env.example .env
yarn dx
```
@@ -156,7 +158,7 @@ yarn dx
npx prisma studio
```
8. Click on the `User` model to add a new user record.
9. Fill out the fields (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
9. Fill out the fields `email`, `username`, `password`, and set `metadata` to empty `{}` (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
10. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
11. Set a 32 character random string in your .env file for the CALENDSO_ENCRYPTION_KEY.
@@ -217,11 +219,10 @@ yarn test-playwright
### Docker
The Docker configuration for Cal is an effort powered by people within the community. Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
If you want to contribute to the Docker repository, [reply here](https://github.com/calendso/docker/discussions/32).
The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker).
### Railway
@@ -269,7 +270,7 @@ Contributions are what make the open source community such an amazing place to b
3. Set **Who can use this application or access this API?** to **Accounts in any organizational directory (Any Azure AD directory - Multitenant)**
4. Set the **Web** redirect URI to `<Cal.com URL>/api/integrations/office365calendar/callback` replacing Cal.com URL with the URI at which your application runs.
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attriubte
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
## Obtaining Zoom Client ID and Secret
@@ -289,11 +290,10 @@ Contributions are what make the open source community such an amazing place to b
## Obtaining Daily API Credentials
1. Open [Daily](https://www.daily.co/) and sign into your account.
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.
5. If you have a [Daily Scale Plan](https://www.daily.co/pricing) can also enable the ability to record Daily video meetings. To do so, set the `DAILY_SCALE_PLAN` environment variable to `'true'`
1. Open [Daily](https://www.daily.co/) and sign into your account.
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.
<!-- LICENSE -->

View File

@@ -13,14 +13,14 @@ export default function AddToHomescreen() {
}
}
return !closeBanner ? (
<div className="fixed sm:hidden bottom-0 inset-x-0 pb-2 sm:pb-5">
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
<div className="fixed inset-x-0 bottom-0 pb-2 sm:hidden sm:pb-5">
<div className="px-2 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="p-2 rounded-lg shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
<div className="flex items-center justify-between flex-wrap">
<div className="w-0 flex-1 flex items-center">
<span className="flex p-2 rounded-lg bg-opacity-30 bg-brand">
<div className="flex flex-wrap items-center justify-between">
<div className="flex items-center flex-1 w-0">
<span className="flex p-2 rounded-lg bg-opacity-30 bg-brand text-brandcontrast">
<svg
className="h-7 w-7 text-indigo-500 fill-current"
className="text-indigo-500 fill-current h-7 w-7"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 50"
enableBackground="new 0 0 50 50">
@@ -34,13 +34,13 @@ export default function AddToHomescreen() {
</p>
</div>
<div className="order-2 flex-shrink-0 sm:order-3 sm:ml-2">
<div className="flex-shrink-0 order-2 sm:order-3 sm:ml-2">
<button
onClick={() => setCloseBanner(true)}
type="button"
className="-mr-1 flex p-2 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
className="flex p-2 -mr-1 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
<span className="sr-only">{t("dismiss")}</span>
<XIcon className="h-6 w-6 text-white" aria-hidden="true" />
<XIcon className="w-6 h-6 text-white" aria-hidden="true" />
</button>
</div>
</div>

View File

@@ -1,8 +1,38 @@
import { useEffect } from "react";
function computeContrastRatio(a: number[], b: number[]) {
const lum1 = computeLuminance(a[0], a[1], a[2]);
const lum2 = computeLuminance(b[0], b[1], b[2]);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}
function computeLuminance(r: number, g: number, b: number) {
const a = [r, g, b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}
function hexToRGB(hex: string) {
const color = hex.replace("#", "");
return [parseInt(color.slice(0, 2), 16), parseInt(color.slice(2, 4), 16), parseInt(color.slice(4, 6), 16)];
}
function getContrastingTextColor(bgColor: string | null): string {
bgColor = bgColor == "" || bgColor == null ? "#292929" : bgColor;
const rgb = hexToRGB(bgColor);
const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]);
const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929
return whiteContrastRatio > blackContrastRatio ? "#ffffff" : "#292929";
}
const BrandColor = ({ val = "#292929" }: { val: string | undefined | null }) => {
useEffect(() => {
document.documentElement.style.setProperty("--brand-color", val);
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val));
}, [val]);
return null;
};

View File

@@ -6,7 +6,7 @@ export function Dialog(props: DialogProps) {
const { children, ...other } = props;
return (
<DialogPrimitive.Root {...other}>
<DialogPrimitive.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
<DialogPrimitive.Overlay className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" />
{children}
</DialogPrimitive.Root>
);
@@ -17,7 +17,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
({ children, ...props }, forwardedRef) => (
<DialogPrimitive.Content
{...props}
className="min-w-[360px] fixed left-1/2 top-1/2 p-6 text-left bg-white rounded shadow-xl overflow-hidden -translate-x-1/2 -translate-y-1/2 sm:align-middle sm:w-full sm:max-w-lg"
className="min-w-[360px] fixed left-1/2 top-1/2 p-6 text-left bg-white rounded shadow-xl -translate-x-1/2 -translate-y-1/2 sm:align-middle sm:w-full sm:max-w-lg"
ref={forwardedRef}>
{children}
</DialogPrimitive.Content>
@@ -32,10 +32,10 @@ type DialogHeaderProps = {
export function DialogHeader(props: DialogHeaderProps) {
return (
<div className="mb-8">
<h3 className="font-cal text-gray-900 text-lg font-bold leading-6" id="modal-title">
<h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
{props.title}
</h3>
{props.subtitle && <div className="text-gray-400 text-sm">{props.subtitle}</div>}
{props.subtitle && <div className="text-sm text-gray-400">{props.subtitle}</div>}
</div>
);
}
@@ -43,7 +43,7 @@ export function DialogHeader(props: DialogHeaderProps) {
export function DialogFooter(props: { children: ReactNode }) {
return (
<div>
<div className="mt-5 flex space-x-2 justify-end">{props.children}</div>
<div className="flex justify-end mt-5 space-x-2">{props.children}</div>
</div>
);
}

View File

@@ -110,7 +110,7 @@ export default function ImageUploader({
(opened) => !opened && setFile(null) // unset file on close
}>
<DialogTrigger asChild>
<div className="flex items-center px-3">
<div className="flex items-center">
<Button color="secondary" type="button" className="py-1 text-xs">
{buttonMsg}
</Button>
@@ -125,9 +125,9 @@ export default function ImageUploader({
</div>
</div>
<div className="mb-4">
<div className="flex flex-col items-center justify-center p-8 mt-6 cropper bg-gray-50">
<div className="flex flex-col items-center justify-center p-8 mt-6 cropper">
{!result && (
<div className="flex items-center justify-start w-20 h-20 bg-gray-500 rounded-full max-h-20">
<div className="flex items-center justify-start w-20 h-20 bg-gray-50 rounded-full max-h-20">
{!imageSrc && (
<p className="w-full text-sm text-center text-white sm:text-xs">
{t("no_target", { target })}

View File

@@ -1,13 +1,17 @@
export default function Logo({ small }: { small?: boolean }) {
export default function Logo({ small, icon }: { small?: boolean; icon?: boolean }) {
return (
<h1 className="brand-logo inline">
<h1 className="inline">
<strong>
<img
className={small ? "h-4 w-auto" : "h-5 w-auto"}
alt="Cal"
title="Cal"
src="/calendso-logo-white-word.svg"
/>
{icon ? (
<img className="w-9 mx-auto" alt="Cal" title="Cal" src="/cal-com-icon-white.svg" />
) : (
<img
className={small ? "h-4 w-auto" : "h-5 w-auto"}
alt="Cal"
title="Cal"
src="/calendso-logo-white-word.svg"
/>
)}
</strong>
</h1>
);

View File

@@ -1,20 +1,24 @@
import { SelectorIcon } from "@heroicons/react/outline";
import {
CalendarIcon,
ArrowLeftIcon,
ClockIcon,
CogIcon,
ExternalLinkIcon,
LinkIcon,
LogoutIcon,
PuzzleIcon,
MoonIcon,
MapIcon,
} from "@heroicons/react/solid";
import { signOut, useSession } from "next-auth/client";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { ReactNode, useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import LicenseBanner from "@ee/components/LicenseBanner";
import TrialBanner from "@ee/components/TrialBanner";
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
import classNames from "@lib/classNames";
@@ -36,8 +40,9 @@ import Dropdown, {
import { useViewerI18n } from "./I18nLanguageHandler";
import Logo from "./Logo";
import Button from "./ui/Button";
function useMeQuery() {
export function useMeQuery() {
const meQuery = trpc.useQuery(["viewer.me"], {
retry(failureCount) {
return failureCount > 3;
@@ -48,7 +53,8 @@ function useMeQuery() {
}
function useRedirectToLoginIfUnauthenticated() {
const [session, loading] = useSession();
const { data: session, status } = useSession();
const loading = status === "loading";
const router = useRouter();
useEffect(() => {
@@ -106,7 +112,7 @@ export function ShellSubHeading(props: {
</h2>
{props.subtitle && <p className="mr-4 text-sm text-neutral-500">{props.subtitle}</p>}
</div>
{props.actions && <div className="flex-shrink-0 mb-4">{props.actions}</div>}
{props.actions && <div className="flex-shrink-0">{props.actions}</div>}
</div>
);
}
@@ -118,6 +124,10 @@ export default function Shell(props: {
subtitle?: ReactNode;
children: ReactNode;
CTA?: ReactNode;
HeadingLeftIcon?: ReactNode;
backPath?: string; // renders back button to specified path
// use when content needs to expand with flex
flexChildrenContainer?: boolean;
}) {
const { t } = useLocale();
const router = useRouter();
@@ -136,13 +146,13 @@ export default function Shell(props: {
{
name: t("bookings"),
href: "/bookings/upcoming",
icon: ClockIcon,
icon: CalendarIcon,
current: router.asPath.startsWith("/bookings"),
},
{
name: t("availability"),
href: "/availability",
icon: CalendarIcon,
icon: ClockIcon,
current: router.asPath.startsWith("/availability"),
},
{
@@ -195,17 +205,23 @@ export default function Shell(props: {
<Toaster position="bottom-right" />
</div>
<div className="flex h-screen overflow-hidden bg-gray-100">
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-56">
<div className="flex h-screen overflow-hidden bg-gray-100" data-testid="dashboard-shell">
<div className="hidden md:flex lg:flex-shrink-0">
<div className="flex flex-col w-14 lg:w-56">
<div className="flex flex-col flex-1 h-0 bg-white border-r border-gray-200">
<div className="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
<div className="flex flex-col flex-1 pt-3 pb-4 overflow-y-auto lg:pt-5">
<Link href="/event-types">
<a className="px-4">
<a className="px-4 md:hidden lg:inline">
<Logo small />
</a>
</Link>
<nav className="flex-1 px-2 mt-5 space-y-1 bg-white">
{/* logo icon for tablet */}
<Link href="/event-types">
<a className="md:inline lg:hidden">
<Logo small icon />
</a>
</Link>
<nav className="flex-1 px-2 mt-2 space-y-1 bg-white lg:mt-5">
{navigation.map((item) => (
<Link key={item.name} href={item.href}>
<a
@@ -224,21 +240,31 @@ export default function Shell(props: {
)}
aria-hidden="true"
/>
{item.name}
<span className="hidden lg:inline">{item.name}</span>
</a>
</Link>
))}
</nav>
</div>
<TrialBanner />
<div className="p-2 pt-2 pr-2 m-2 rounded-sm hover:bg-gray-100">
<UserDropdown />
<span className="hidden lg:inline">
<UserDropdown />
</span>
<span className="hidden md:inline lg:hidden">
<UserDropdown small />
</span>
</div>
</div>
</div>
</div>
<div className="flex flex-col flex-1 w-0 overflow-hidden">
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]">
<main
className={classNames(
"flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]",
props.flexChildrenContainer && "flex flex-col"
)}>
{/* show top navigation for md and smaller (tablet and phones) */}
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
<Link href="/event-types">
@@ -258,8 +284,24 @@ export default function Shell(props: {
<UserDropdown small />
</div>
</nav>
<div className={classNames(props.centered && "md:max-w-5xl mx-auto", "py-8")}>
<div
className={classNames(
props.centered && "md:max-w-5xl mx-auto",
props.flexChildrenContainer && "flex flex-col flex-1",
"py-8"
)}>
{!!props.backPath && (
<div className="mx-3 mb-8 sm:mx-8">
<Button
onClick={() => router.push(props.backPath as string)}
StartIcon={ArrowLeftIcon}
color="secondary">
Back
</Button>
</div>
)}
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8 min-h-[80px]">
{props.HeadingLeftIcon && <div className="mr-4">{props.HeadingLeftIcon}</div>}
<div className="w-full mb-8">
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
{props.heading}
@@ -268,7 +310,13 @@ export default function Shell(props: {
</div>
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
</div>
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
<div
className={classNames(
"px-4 sm:px-6 md:px-8",
props.flexChildrenContainer && "flex flex-col flex-1"
)}>
{props.children}
</div>
{/* show bottom navigation for md and smaller (tablet and phones) */}
<nav className="fixed bottom-0 flex w-full bg-white shadow bottom-nav md:hidden">
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
@@ -313,19 +361,33 @@ function UserDropdown({ small }: { small?: boolean }) {
const { t } = useLocale();
const query = useMeQuery();
const user = query.data;
const mutation = trpc.useMutation("viewer.away", {
onSettled() {
utils.invalidateQueries("viewer.me");
},
});
const utils = trpc.useContext();
return (
<Dropdown>
<DropdownMenuTrigger asChild>
<div className="flex items-center space-x-2 cursor-pointer group">
<Avatar
imageSrc={user?.avatar || ""}
alt={user?.username || "Nameless User"}
className={classNames(small ? "w-8 h-8" : "w-10 h-10", "bg-gray-300 rounded-full flex-shrink-0")}
/>
<div className="flex items-center w-full space-x-2 cursor-pointer group">
<span
className={classNames(
small ? "w-8 h-8" : "w-10 h-10",
"bg-gray-300 rounded-full flex-shrink-0 relative"
)}>
<Avatar imageSrc={user?.avatar || ""} alt={user?.username || "Nameless User"} />
{!user?.away && (
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-white rounded-full"></div>
)}
{user?.away && (
<div className="absolute bottom-0 right-0 w-3 h-3 bg-yellow-500 border-2 border-white rounded-full"></div>
)}
</span>
{!small && (
<>
<span className="flex-grow text-sm">
<span className="flex items-center flex-grow truncate">
<span className="flex-grow text-sm truncate">
<span className="block font-medium text-gray-900 truncate">
{user?.username || "Nameless User"}
</span>
@@ -337,11 +399,31 @@ function UserDropdown({ small }: { small?: boolean }) {
className="flex-shrink-0 w-5 h-5 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
</>
</span>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<a
onClick={() => {
mutation.mutate({ away: !user?.away });
utils.invalidateQueries("viewer.me");
}}
className="flex px-4 py-2 text-sm cursor-pointer hover:bg-gray-100 hover:text-gray-900">
<MoonIcon
className={classNames(
user?.away
? "text-purple-500 group-hover:text-purple-700"
: "text-gray-500 group-hover:text-gray-700",
"mr-2 flex-shrink-0 h-5 w-5"
)}
aria-hidden="true"
/>
{user?.away ? t("set_as_free") : t("set_as_away")}
</a>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{user?.username && (
<DropdownMenuItem>
<a
@@ -385,6 +467,15 @@ function UserDropdown({ small }: { small?: boolean }) {
{t("join_our_slack")}
</a>
</DropdownMenuItem>
<DropdownMenuItem>
<a
target="_blank"
rel="noopener noreferrer"
href="https://cal.com/roadmap"
className="flex items-center px-4 py-2 text-sm text-gray-700">
<MapIcon className="w-5 h-5 mr-3 text-gray-500" /> {t("visit_roadmap")}
</a>
</DropdownMenuItem>
<HelpMenuItemDynamic />
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem>

View File

@@ -23,7 +23,7 @@ export function Tooltip({
onOpenChange={onOpenChange}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content
className="bg-brand text-xs -mt-2 text-white px-1 py-0.5 shadow-lg rounded-sm"
className="bg-black text-xs -mt-2 text-white px-1 py-0.5 shadow-lg rounded-sm"
side="top"
align="center"
{...props}>

View File

@@ -3,23 +3,20 @@ import { SchedulingType } from "@prisma/client";
import { Dayjs } from "dayjs";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { FC } from "react";
import React, { FC, useEffect, useState } from "react";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { useSlots } from "@lib/hooks/useSlots";
import Loader from "@components/Loader";
type AvailableTimesProps = {
workingHours: {
days: number[];
startTime: number;
endTime: number;
}[];
timeFormat: string;
minimumBookingNotice: number;
eventTypeId: number;
eventLength: number;
slotInterval: number | null;
date: Dayjs;
users: {
username: string | null;
@@ -31,38 +28,44 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
date,
eventLength,
eventTypeId,
slotInterval,
minimumBookingNotice,
workingHours,
timeFormat,
users,
schedulingType,
}) => {
const { t } = useLocale();
const { t, i18n } = useLocale();
const router = useRouter();
const { rescheduleUid } = router.query;
const { slots, loading, error } = useSlots({
date,
slotInterval,
eventLength,
schedulingType,
workingHours,
users,
minimumBookingNotice,
eventTypeId,
});
const [brand, setBrand] = useState("#292929");
useEffect(() => {
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
}, []);
return (
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:-mb-5">
<div className="text-gray-600 font-light text-lg mb-4 text-left">
<span className="w-1/2 dark:text-white text-gray-600">
<strong>{t(date.format("dddd").toLowerCase())}</strong>
<div className="flex flex-col mt-8 text-center sm:pl-4 sm:mt-0 sm:w-1/3 md:-mb-5">
<div className="mb-4 text-lg font-light text-left text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong>{date.toDate().toLocaleString(i18n.language, { weekday: "long" })}</strong>
<span className="text-gray-500">
{date.format(", DD ")}
{t(date.format("MMMM").toLowerCase())}
{date.format(", D ")}
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
</span>
</span>
</div>
<div className="md:max-h-[364px] overflow-y-auto">
<div className="flex-grow md:h-[364px] overflow-y-auto">
{!loading &&
slots?.length > 0 &&
slots.map((slot) => {
@@ -91,7 +94,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<a
className="block font-medium mb-2 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-brand dark:border-transparent rounded-sm hover:text-white hover:bg-brand dark:hover:border-black py-4 dark:hover:bg-black"
className={classNames(
"block py-4 mb-2 font-medium bg-white border rounded-sm dark:bg-gray-600 text-primary-500 dark:text-neutral-200 dark:border-transparent hover:text-white hover:bg-brand hover:text-brandcontrast dark:hover:border-black dark:hover:bg-brand dark:hover:text-brandcontrast",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">
{slot.time.format(timeFormat)}
</a>
@@ -100,7 +106,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
);
})}
{!loading && !error && !slots.length && (
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
<div className="flex flex-col items-center content-center justify-center w-full h-full -mt-4">
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
</div>
)}
@@ -108,10 +114,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
{loading && <Loader />}
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="p-4 border-l-4 border-yellow-400 bg-yellow-50">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
<ExclamationIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>

View File

@@ -9,7 +9,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingItem = inferQueryOutput<"viewer.bookings">[number];
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
function BookingListItem(booking: BookingItem) {
const { t, i18n } = useLocale();
@@ -73,20 +73,17 @@ function BookingListItem(booking: BookingItem) {
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
return (
<tr>
<td className="hidden px-6 py-4 align-top sm:table-cell whitespace-nowrap">
<tr className="flex">
<td className="hidden py-4 pl-6 align-top sm:table-cell whitespace-nowrap">
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</div>
</td>
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
<td className={"pl-4 py-4 flex-1" + (booking.rejected ? " line-through" : "")}>
<div className="sm:hidden">
{!booking.confirmed && !booking.rejected && (
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
{t("unconfirmed")}
</span>
)}
{!booking.confirmed && !booking.rejected && <Tag className="mb-2 mr-2">{t("unconfirmed")}</Tag>}
{!!booking?.eventType?.price && !booking.paid && <Tag className="mb-2 mr-2">Pending payment</Tag>}
<div className="text-sm font-medium text-gray-900">
{startTime}:{" "}
<small className="text-sm text-gray-500">
@@ -94,13 +91,16 @@ function BookingListItem(booking: BookingItem) {
</small>
</div>
</div>
<div className="text-sm font-medium leading-6 truncate text-neutral-900 max-w-52 md:max-w-96">
<div
title={booking.title}
className="text-sm font-medium leading-6 truncate text-neutral-900 max-w-56 md:max-w-max">
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title}
{!!booking?.eventType?.price && !booking.paid && (
<Tag className="hidden ml-2 sm:inline-flex">Pending payment</Tag>
)}
{!booking.confirmed && !booking.rejected && (
<span className="ml-2 hidden sm:inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
{t("unconfirmed")}
</span>
<Tag className="hidden ml-2 sm:inline-flex">{t("unconfirmed")}</Tag>
)}
</div>
{booking.description && (
@@ -115,7 +115,7 @@ function BookingListItem(booking: BookingItem) {
)}
</td>
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<td className="py-4 pr-4 text-sm font-medium text-right whitespace-nowrap">
{isUpcoming && !isCancelled ? (
<>
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
@@ -130,4 +130,13 @@ function BookingListItem(booking: BookingItem) {
);
}
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
return (
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800 ${className}`}>
{children}
</span>
);
};
export default BookingListItem;

View File

@@ -1,66 +1,115 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import { EventType, PeriodType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
// Then, include dayjs-business-time
import dayjsBusinessTime from "dayjs-business-time";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import classNames from "@lib/classNames";
import { timeZone } from "@lib/clock";
import { weekdayNames } from "@lib/core/i18n/weekday";
import { useLocale } from "@lib/hooks/useLocale";
import getSlots from "@lib/slots";
import { WorkingHours } from "@lib/types/schedule";
import Loader from "@components/Loader";
dayjs.extend(dayjsBusinessTime);
dayjs.extend(utc);
dayjs.extend(timezone);
// FIXME prop types
type DatePickerProps = {
weekStart: string;
onDatePicked: (pickedDate: Dayjs) => void;
workingHours: WorkingHours[];
eventLength: number;
date: Dayjs | null;
periodType: PeriodType;
periodStartDate: Date | null;
periodEndDate: Date | null;
periodDays: number | null;
periodCountCalendarDays: boolean | null;
minimumBookingNotice: number;
};
function isOutOfBounds(
time: dayjs.ConfigType,
{
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
}: Pick<
EventType,
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
>
) {
const date = dayjs(time);
switch (periodType) {
case PeriodType.ROLLING: {
const periodRollingEndDay = periodCountCalendarDays
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
return date.endOf("day").isAfter(periodRollingEndDay);
}
case PeriodType.RANGE: {
const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day");
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
}
case PeriodType.UNLIMITED:
default:
return false;
}
}
function DatePicker({
weekStart,
onDatePicked,
workingHours,
organizerTimeZone,
eventLength,
date,
periodType = "unlimited",
periodType = PeriodType.UNLIMITED,
periodStartDate,
periodEndDate,
periodDays,
periodCountCalendarDays,
minimumBookingNotice,
}: any): JSX.Element {
const { t } = useLocale();
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
}: DatePickerProps): JSX.Element {
const { i18n } = useLocale();
const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
const [selectedMonth, setSelectedMonth] = useState<number | null>(
date
? periodType === "range"
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
: date.month()
: dayjs().month() /* High chance server is going to have the same month */
);
const [month, setMonth] = useState<string>("");
const [year, setYear] = useState<string>("");
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
useEffect(() => {
if (dayjs().month() !== selectedMonth) {
setSelectedMonth(dayjs().month());
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
setBrowsingDate(date || dayjs().tz(timeZone()));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Handle month changes
const incrementMonth = () => {
setSelectedMonth((selectedMonth ?? 0) + 1);
};
const decrementMonth = () => {
setSelectedMonth((selectedMonth ?? 0) - 1);
};
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
}, [date, browsingDate]);
useEffect(() => {
if (browsingDate) {
setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
setYear(browsingDate.format("YYYY"));
setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
}
}, [browsingDate, i18n.language]);
const days = useMemo(() => {
if (!browsingDate) {
return [];
}
// Create placeholder elements for empty days in first week
let weekdayOfFirst = inviteeDate().date(1).day();
let weekdayOfFirst = browsingDate.date(1).day();
if (weekStart === "Monday") {
weekdayOfFirst -= 1;
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
@@ -69,65 +118,45 @@ function DatePicker({
const days = Array(weekdayOfFirst).fill(null);
const isDisabled = (day: number) => {
const date: Dayjs = inviteeDate().date(day);
switch (periodType) {
case "rolling": {
const periodRollingEndDay = periodCountCalendarDays
? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
: dayjs().tz(organizerTimeZone).addBusinessTime(periodDays, "days").endOf("day");
return (
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
date.endOf("day").isAfter(periodRollingEndDay) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}).length
);
}
case "range": {
const periodRangeStartDay = dayjs(periodStartDate).tz(organizerTimeZone).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).tz(organizerTimeZone).endOf("day");
return (
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
date.endOf("day").isBefore(periodRangeStartDay) ||
date.endOf("day").isAfter(periodRangeEndDay) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}).length
);
}
case "unlimited":
default:
return (
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
minimumBookingNotice,
workingHours,
organizerTimeZone,
}).length
);
}
const date = browsingDate.startOf("day").date(day);
return (
isOutOfBounds(date, {
periodType,
periodStartDate,
periodEndDate,
periodCountCalendarDays,
periodDays,
}) ||
!getSlots({
inviteeDate: date,
frequency: eventLength,
minimumBookingNotice,
workingHours,
}).length
);
};
const daysInMonth = inviteeDate().daysInMonth();
const daysInMonth = browsingDate.daysInMonth();
for (let i = 1; i <= daysInMonth; i++) {
days.push({ disabled: isDisabled(i), date: i });
}
setDays(days);
return days;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMonth]);
}, [browsingDate]);
if (!browsingDate) {
return <Loader />;
}
// Handle month changes
const incrementMonth = () => {
setBrowsingDate(browsingDate?.add(1, "month"));
};
const decrementMonth = () => {
setBrowsingDate(browsingDate?.subtract(1, "month"));
};
return (
<div
@@ -137,39 +166,30 @@ function DatePicker({
? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-gray-800 sm:pl-4 sm:pr-6 "
: "w-full sm:pl-4")
}>
<div className="flex text-gray-600 font-light text-xl mb-4">
<div className="flex mb-4 text-xl font-light text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong className="text-gray-900 dark:text-white">
{t(inviteeDate().format("MMMM").toLowerCase())}
</strong>{" "}
<span className="text-gray-500">{inviteeDate().format("YYYY")}</span>
<strong className="text-gray-900 dark:text-white">{month}</strong>{" "}
<span className="text-gray-500">{year}</span>
</span>
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<button
onClick={decrementMonth}
className={classNames(
"group mr-2 p-1",
typeof selectedMonth === "number" &&
selectedMonth <= dayjs().month() &&
"text-gray-400 dark:text-gray-600"
)}
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}
className={classNames("group mr-2 p-1", isFirstMonth && "text-gray-400 dark:text-gray-600")}
disabled={isFirstMonth}
data-testid="decrementMonth">
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
<ChevronLeftIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
</button>
<button className="group p-1" onClick={incrementMonth} data-testid="incrementMonth">
<ChevronRightIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
<button className="p-1 group" onClick={incrementMonth} data-testid="incrementMonth">
<ChevronRightIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-4 text-center border-b border-t dark:border-gray-800 sm:border-0">
{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
.map((weekDay) => (
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
{t(weekDay.toLowerCase()).substring(0, 3)}
</div>
))}
<div className="grid grid-cols-7 gap-4 text-center border-t border-b dark:border-gray-800 sm:border-0">
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
<div key={weekDay} className="my-4 text-xs tracking-widest text-gray-500 uppercase">
{weekDay}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-2 text-center">
{days.map((day, idx) => (
@@ -178,23 +198,21 @@ function DatePicker({
style={{
paddingTop: "100%",
}}
className="w-full relative">
className="relative w-full">
{day === null ? (
<div key={`e-${idx}`} />
) : (
<button
onClick={() => onDatePicked(inviteeDate().date(day.date))}
onClick={() => onDatePicked(browsingDate.date(day.date))}
disabled={day.disabled}
className={classNames(
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto",
"hover:border hover:border-brand dark:hover:border-white",
day.disabled
? "text-gray-400 font-light hover:border-0 cursor-default"
: "dark:text-white text-primary-500 font-medium",
date && date.isSame(inviteeDate().date(day.date), "day")
? "bg-brand text-white-important"
day.disabled ? "text-gray-400 font-light hover:border-0 cursor-default" : "font-medium",
date && date.isSame(browsingDate.date(day.date), "day")
? "bg-brand text-brandcontrast"
: !day.disabled
? " bg-gray-100 dark:bg-gray-600"
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
: ""
)}
data-testid="day"

View File

@@ -35,19 +35,19 @@ const TimeOptions: FC<Props> = (props) => {
};
return selectedTimeZone !== "" ? (
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
<div className="absolute z-10 w-full px-4 py-2 bg-white border border-gray-200 rounded-sm max-w-80 dark:bg-gray-700 dark:border-0">
<div className="flex mb-4">
<div className="w-1/2 dark:text-white text-gray-600 font-medium">{t("time_options")}</div>
<div className="w-1/2 font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
<div className="w-1/2">
<Switch.Group as="div" className="flex items-center justify-end">
<Switch.Label as="span" className="mr-3">
<span className="text-sm dark:text-white text-gray-500">{t("am_pm")}</span>
<span className="text-sm text-gray-500 dark:text-white">{t("am_pm")}</span>
</Switch.Label>
<Switch
checked={is24hClock}
onChange={handle24hClockToggle}
className={classNames(
is24hClock ? "bg-brand" : "dark:bg-gray-600 bg-gray-200",
is24hClock ? "bg-brand text-brandcontrast" : "dark:bg-gray-600 bg-gray-200",
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
)}>
<span className="sr-only">{t("use_setting")}</span>
@@ -60,7 +60,7 @@ const TimeOptions: FC<Props> = (props) => {
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm dark:text-white text-gray-500">{t("24_h")}</span>
<span className="text-sm text-gray-500 dark:text-white">{t("24_h")}</span>
</Switch.Label>
</Switch.Group>
</div>
@@ -69,7 +69,7 @@ const TimeOptions: FC<Props> = (props) => {
id="timeZone"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
className="mb-2 shadow-sm focus:ring-black focus:border-brand mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full mt-1 mb-2 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
) : null;

View File

@@ -61,7 +61,6 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
}, [telemetry]);
const changeDate = (newDate: Dayjs) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
router.replace(
{
query: {
@@ -93,8 +92,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
name={profile.name}
avatar={profile.image}
name={profile.name || undefined}
avatar={profile.image || undefined}
/>
<CustomBranding val={profile.brandColor} />
<div>
@@ -109,14 +108,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<div className="block p-4 sm:p-8 md:hidden">
<div className="flex items-center">
<AvatarGroup
items={[{ image: profile.image, alt: profile.name }].concat(
eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar,
}))
)}
items={
[
{ image: profile.image, alt: profile.name, title: profile.name },
...eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar || undefined,
alt: user.name || undefined,
})),
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
}
size={9}
truncateAfter={5}
/>
@@ -153,14 +156,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}>
<AvatarGroup
items={[{ image: profile.image, alt: profile.name }].concat(
eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: user.avatar,
}))
)}
items={
[
{ image: profile.image, alt: profile.name, title: profile.name },
...eventType.users
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
alt: user.name,
image: user.avatar,
})),
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
}
size={10}
truncateAfter={3}
/>
@@ -209,10 +216,10 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
{selectedDate && (
<AvailableTimes
workingHours={workingHours}
timeFormat={timeFormat}
minimumBookingNotice={eventType.minimumBookingNotice}
eventTypeId={eventType.id}
slotInterval={eventType.slotInterval}
eventLength={eventType.length}
date={selectedDate}
users={eventType.users}

View File

@@ -9,24 +9,27 @@ import { EventTypeCustomInputType } from "@prisma/client";
import dayjs from "dayjs";
import Head from "next/head";
import { useRouter } from "next/router";
import { stringify } from "querystring";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { createPaymentLink } from "@ee/lib/stripe/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { ensureArray } from "@lib/ensureArray";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
import { parseZone } from "@lib/parseZone";
import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { BookingCreateBody } from "@lib/types/booking";
import CustomBranding from "@components/CustomBranding";
import { EmailInput, Form } from "@components/form/fields";
import AvatarGroup from "@components/ui/AvatarGroup";
import { Button } from "@components/ui/Button";
import PhoneInput from "@components/ui/form/PhoneInput";
@@ -39,31 +42,76 @@ type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => {
const { t, i18n } = useLocale();
const router = useRouter();
const { rescheduleUid } = router.query;
/*
* This was too optimistic
* I started, then I remembered what a beast book/event.ts is
* Gave up shortly after. One day. Maybe.
*
const mutation = trpc.useMutation("viewer.bookEvent", {
onSuccess: ({ booking }) => {
// go to success page.
},
});*/
const mutation = useMutation(createBooking, {
onSuccess: async ({ attendees, paymentUid, ...responseData }) => {
if (paymentUid) {
return await router.push(
createPaymentLink({
paymentUid,
date,
name: attendees[0].name,
absolute: false,
})
);
}
const location = (function humanReadableLocation(location) {
if (!location) {
return;
}
if (location.includes("integration")) {
return t("web_conferencing_details_to_follow");
}
return location;
})(responseData.location);
return router.push({
pathname: "/success",
query: {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
email: attendees[0].email,
location,
},
});
},
});
const rescheduleUid = router.query.rescheduleUid as string;
const { isReady } = useTheme(props.profile.theme);
const date = asStringOrNull(router.query.date);
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [guestToggle, setGuestToggle] = useState(false);
const [guestEmails, setGuestEmails] = useState([]);
const locations = props.eventType.locations || [];
const [guestToggle, setGuestToggle] = useState(props.booking && props.booking.attendees.length > 1);
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
locations.length === 1 ? locations[0].type : ""
type Location = { type: LocationType; address?: string };
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
const locations: Location[] = useMemo(
() => (props.eventType.locations as Location[]) || [],
[props.eventType.locations]
);
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
}, []);
if (router.query.guest) {
setGuestToggle(true);
}
}, [router.query.guest]);
function toggleGuestEmailInput() {
setGuestToggle(!guestToggle);
}
const telemetry = useTelemetry();
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
@@ -76,113 +124,124 @@ const BookingPage = (props: BookingPageProps) => {
[LocationType.Daily]: "Daily.co Video",
};
const _bookingHandler = (event) => {
const book = async () => {
setLoading(true);
setError(false);
let notes = "";
if (props.eventType.customInputs) {
notes = props.eventType.customInputs
.map((input) => {
const data = event.target["custom_" + input.id];
if (data) {
if (input.type === EventTypeCustomInputType.BOOL) {
return input.label + "\n" + (data.checked ? t("yes") : t("no"));
} else {
return input.label + "\n" + data.value;
}
}
})
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += `\n\n${t("additional_notes")}:\n` + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
const payload: BookingCreateBody = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
name: event.target.name.value,
email: event.target.email.value,
notes: notes,
guests: guestEmails,
eventTypeId: props.eventType.id,
timeZone: timeZone(),
language: i18n.language,
};
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
if (typeof router.query.user === "string") payload.user = router.query.user;
if (selectedLocation) {
switch (selectedLocation) {
case LocationType.Phone:
payload["location"] = event.target.phone.value;
break;
case LocationType.InPerson:
payload["location"] = locationInfo(selectedLocation).address;
break;
// Catches all other location types, such as Google Meet, Zoom etc.
default:
payload["location"] = selectedLocation;
}
}
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
const content = await createBooking(payload).catch((e) => {
console.error(e.message);
setLoading(false);
setError(true);
});
if (content?.id) {
const params: { [k: string]: any } = {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: payload.name,
email: payload.email,
};
if (payload["location"]) {
if (payload["location"].includes("integration")) {
params.location = t("web_conferencing_details_to_follow");
} else {
params.location = payload["location"];
}
}
const query = stringify(params);
let successUrl = `/success?${query}`;
if (content?.paymentUid) {
successUrl = createPaymentLink({
paymentUid: content?.paymentUid,
name: payload.name,
date,
absolute: false,
});
}
await router.push(successUrl);
} else {
setLoading(false);
setError(true);
}
type BookingFormValues = {
name: string;
email: string;
notes?: string;
locationType?: LocationType;
guests?: string[];
phone?: string;
customInputs?: {
[key: string]: string;
};
event.preventDefault();
book();
};
const bookingHandler = useCallback(_bookingHandler, [guestEmails]);
const defaultValues = () => {
if (!rescheduleUid) {
return {
name: (router.query.name as string) || "",
email: (router.query.email as string) || "",
notes: (router.query.notes as string) || "",
guests: ensureArray(router.query.guest) as string[],
customInputs: props.eventType.customInputs.reduce(
(customInputs, input) => ({
...customInputs,
[input.id]: router.query[slugify(input.label)],
}),
{}
),
};
}
if (!props.booking || !props.booking.attendees.length) {
return {};
}
const primaryAttendee = props.booking.attendees[0];
if (!primaryAttendee) {
return {};
}
return {
name: primaryAttendee.name || "",
email: primaryAttendee.email || "",
guests: props.booking.attendees.slice(1).map((attendee) => attendee.email),
};
};
const bookingForm = useForm<BookingFormValues>({
defaultValues: defaultValues(),
});
const selectedLocation = useWatch({
control: bookingForm.control,
name: "locationType",
defaultValue: ((): LocationType | undefined => {
if (router.query.location) {
return router.query.location as LocationType;
}
if (locations.length === 1) {
return locations[0]?.type;
}
})(),
});
const getLocationValue = (booking: Pick<BookingFormValues, "locationType" | "phone">) => {
const { locationType } = booking;
switch (locationType) {
case LocationType.Phone: {
return booking.phone || "";
}
case LocationType.InPerson: {
return locationInfo(locationType)?.address || "";
}
// Catches all other location types, such as Google Meet, Zoom etc.
default:
return selectedLocation || "";
}
};
const parseDate = (date: string | null) => {
if (!date) return "No date";
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(timeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
};
const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
const metadata = Object.keys(router.query)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
}),
{}
);
mutation.mutate({
...booking,
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
eventTypeId: props.eventType.id,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
user: router.query.user,
location: getLocationValue(booking.locationType ? booking : { locationType: selectedLocation }),
metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: booking.customInputs![inputId],
})),
});
};
return (
<div>
@@ -209,12 +268,12 @@ const BookingPage = (props: BookingPageProps) => {
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
<AvatarGroup
size={14}
items={[{ image: props.profile.image, alt: props.profile.name }].concat(
items={[{ image: props.profile.image || "", alt: props.profile.name || "" }].concat(
props.eventType.users
.filter((user) => user.name !== props.profile.name)
.map((user) => ({
image: user.avatar,
title: user.name,
image: user.avatar || "",
alt: user.name || "",
}))
)}
/>
@@ -243,30 +302,30 @@ const BookingPage = (props: BookingPageProps) => {
{selectedLocation === LocationType.InPerson && (
<p className="mb-2 text-gray-500">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{locationInfo(selectedLocation).address}
{getLocationValue({ locationType: selectedLocation })}
</p>
)}
<p className="mb-4 text-green-500">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{parseZone(date).format(timeFormat + ", dddd DD MMMM YYYY")}
{parseDate(date)}
</p>
<p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p>
</div>
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<form onSubmit={bookingHandler}>
<Form form={bookingForm} handleSubmit={bookEvent}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
{t("your_name")}
</label>
<div className="mt-1">
<input
{...bookingForm.register("name")}
type="text"
name="name"
id="name"
required
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder="John Doe"
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
/>
</div>
</div>
@@ -277,15 +336,11 @@ const BookingPage = (props: BookingPageProps) => {
{t("email_address")}
</label>
<div className="mt-1">
<input
type="email"
name="email"
id="email"
inputMode="email"
<EmailInput
{...bookingForm.register("email")}
required
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder="you@example.com"
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
/>
</div>
</div>
@@ -294,16 +349,14 @@ const BookingPage = (props: BookingPageProps) => {
<span className="block text-sm font-medium text-gray-700 dark:text-white">
{t("location")}
</span>
{locations.map((location) => (
<label key={location.type} className="block">
{locations.map((location, i) => (
<label key={i} className="block">
<input
type="radio"
required
onChange={(e) => setSelectedLocation(e.target.value)}
className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black"
name="location"
{...bookingForm.register("locationType", { required: true })}
value={location.type}
checked={selectedLocation === location.type}
defaultChecked={selectedLocation === location.type}
/>
<span className="ml-2 text-sm dark:text-gray-500">
{locationLabels[location.type]}
@@ -324,74 +377,78 @@ const BookingPage = (props: BookingPageProps) => {
</div>
</div>
)}
{props.eventType.customInputs &&
props.eventType.customInputs
.sort((a, b) => a.id - b.id)
.map((input) => (
<div className="mb-4" key={"input-" + input.label.toLowerCase}>
{input.type !== EventTypeCustomInputType.BOOL && (
{props.eventType.customInputs
.sort((a, b) => a.id - b.id)
.map((input) => (
<div className="mb-4" key={input.id}>
{input.type !== EventTypeCustomInputType.BOOL && (
<label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
placeholder=""
/>
<label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
placeholder=""
required={input.required}
/>
<label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
</div>
)}
</div>
))}
</div>
)}
</div>
))}
{!props.eventType.disableGuests && (
<div className="mb-4">
{!guestToggle && (
<label
onClick={toggleGuestEmailInput}
onClick={() => setGuestToggle(!guestToggle)}
htmlFor="guests"
className="block mb-1 text-sm font-medium text-blue-500 dark:text-white hover:cursor-pointer">
className="block mb-1 text-sm font-medium dark:text-white hover:cursor-pointer">
{/*<UserAddIcon className="inline-block w-5 h-5 mr-1 -mt-1" />*/}
{t("additional_guests")}
</label>
)}
@@ -402,27 +459,31 @@ const BookingPage = (props: BookingPageProps) => {
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{t("guests")}
</label>
<ReactMultiEmail
className="relative"
placeholder="guest@example.com"
emails={guestEmails}
onChange={(_emails: string[]) => {
setGuestEmails(_emails);
}}
getLabel={(
email: string,
index: number,
removeEmail: (index: number) => void
) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
<Controller
control={bookingForm.control}
name="guests"
render={({ field: { onChange, value } }) => (
<ReactMultiEmail
className="relative"
placeholder="guest@example.com"
emails={value}
onChange={onChange}
getLabel={(
email: string,
index: number,
removeEmail: (index: number) => void
) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
/>
)}
/>
</div>
)}
@@ -435,25 +496,23 @@ const BookingPage = (props: BookingPageProps) => {
{t("additional_notes")}
</label>
<textarea
name="notes"
{...bookingForm.register("notes")}
id="notes"
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={t("share_additional_notes")}
defaultValue={props.booking ? props.booking.description : ""}
/>
</div>
<div className="flex items-start space-x-2">
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
<Button type="submit" loading={loading}>
<Button type="submit" loading={mutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
</Button>
<Button color="secondary" type="button" onClick={() => router.back()}>
{t("cancel")}
</Button>
</div>
</form>
{error && (
</Form>
{mutation.isError && (
<div className="p-4 mt-2 border-l-4 border-yellow-400 bg-yellow-50">
<div className="flex">
<div className="flex-shrink-0">

View File

@@ -1,7 +1,7 @@
import { ExclamationIcon } from "@heroicons/react/outline";
import { CheckIcon } from "@heroicons/react/solid";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { PropsWithChildren } from "react";
import React, { PropsWithChildren, ReactNode } from "react";
import { useLocale } from "@lib/hooks/useLocale";
@@ -9,6 +9,7 @@ import { DialogClose, DialogContent } from "@components/Dialog";
import { Button } from "@components/ui/Button";
export type ConfirmationDialogContentProps = {
confirmBtn?: ReactNode;
confirmBtnText?: string;
cancelBtnText?: string;
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
@@ -21,6 +22,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
const {
title,
variety,
confirmBtn = null,
confirmBtnText = t("confirm"),
cancelBtnText = t("cancel"),
onConfirm,
@@ -33,34 +35,34 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
{variety && (
<div className="mr-3 mt-0.5">
{variety === "danger" && (
<div className="text-center p-2 rounded-full mx-auto bg-red-100">
<div className="p-2 mx-auto text-center bg-red-100 rounded-full">
<ExclamationIcon className="w-5 h-5 text-red-600" />
</div>
)}
{variety === "warning" && (
<div className="text-center p-2 rounded-full mx-auto bg-orange-100">
<div className="p-2 mx-auto text-center bg-orange-100 rounded-full">
<ExclamationIcon className="w-5 h-5 text-orange-600" />
</div>
)}
{variety === "success" && (
<div className="text-center p-2 rounded-full mx-auto bg-green-100">
<div className="p-2 mx-auto text-center bg-green-100 rounded-full">
<CheckIcon className="w-5 h-5 text-green-600" />
</div>
)}
</div>
)}
<div>
<DialogPrimitive.Title className="font-cal text-xl font-bold text-gray-900">
<DialogPrimitive.Title className="text-xl font-bold text-gray-900 font-cal">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-neutral-500 text-sm">
<DialogPrimitive.Description className="text-sm text-neutral-500">
{children}
</DialogPrimitive.Description>
</div>
</div>
<div className="mt-5 sm:mt-8 sm:flex sm:flex-row-reverse gap-x-2">
<DialogClose onClick={onConfirm} asChild>
<Button color="primary">{confirmBtnText}</Button>
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
</DialogClose>
<DialogClose asChild>
<Button color="secondary">{cancelBtnText}</Button>

View File

@@ -0,0 +1,244 @@
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import { useRouter } from "next/router";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useMutation } from "react-query";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import createEventType from "@lib/mutations/event-types/create-event-type";
import showToast from "@lib/notification";
import { CreateEventType } from "@lib/types/event-type";
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import { TextField, InputLeading, TextAreaField, Form } from "@components/form/fields";
import Avatar from "@components/ui/Avatar";
import { Button } from "@components/ui/Button";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/ui/Dropdown";
import * as RadioArea from "@components/ui/form/radio-area";
// this describes the uniform data needed to create a new event type on Profile or Team
interface EventTypeParent {
teamId: number | null | undefined; // if undefined, then it's a profile
name?: string | null;
slug?: string | null;
image?: string | null;
}
interface Props {
// set true for use on the team settings page
canAddEvents: boolean;
// set true when in use on the team settings page
isIndividualTeam?: boolean;
// EventTypeParent can be a profile (as first option) or a team for the rest.
options: EventTypeParent[];
}
export default function CreateEventTypeButton(props: Props) {
const { t } = useLocale();
const router = useRouter();
const modalOpen = useToggleQuery("new");
const form = useForm<CreateEventType>({
defaultValues: { length: 15 },
});
const { setValue, watch, register } = form;
useEffect(() => {
const subscription = watch((value, { name, type }) => {
if (name === "title" && type === "change") {
if (value.title) setValue("slug", value.title.replace(/\s+/g, "-").toLowerCase());
else setValue("slug", "");
}
});
return () => subscription.unsubscribe();
}, [watch, setValue]);
// URL encoded params
const teamId: number | null = Number(router.query.teamId) || null;
const pageSlug = router.query.eventPage || props.options[0].slug;
const hasTeams = !!props.options.find((option) => option.teamId);
const createMutation = useMutation(createEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types/" + eventType.id);
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
},
});
// inject selection data into url for correct router history
const openModal = (option: EventTypeParent) => {
// setTimeout fixes a bug where the url query params are removed immediately after opening the modal
setTimeout(() => {
router.push({
pathname: router.pathname,
query: {
...router.query,
new: "1",
eventPage: option.slug,
...(option.teamId
? {
teamId: option.teamId,
}
: {}),
},
});
});
};
// remove url params after close modal to reset state
const closeModal = () => {
router.replace({
pathname: router.pathname,
query: { id: router.query.id },
});
};
return (
<Dialog
open={modalOpen.isOn}
onOpenChange={(isOpen) => {
if (!isOpen) closeModal();
}}>
{!hasTeams || props.isIndividualTeam ? (
<Button
onClick={() => openModal(props.options[0])}
data-testid="new-event-type"
StartIcon={PlusIcon}
{...(props.canAddEvents
? {
href: modalOpen.hrefOn,
}
: {
disabled: true,
})}>
{t("new_event_type_btn")}
</Button>
) : (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button EndIcon={ChevronDownIcon}>{t("new_event_type_btn")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("new_event_subtitle")}</DropdownMenuLabel>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{props.options.map((option) => (
<DropdownMenuItem
key={option.slug}
className="px-3 py-2 cursor-pointer hover:bg-neutral-100 focus:outline-none"
onSelect={() => openModal(option)}>
<Avatar alt={option.name || ""} imageSrc={option.image} size={6} className="inline mr-2" />
{option.name ? option.name : option.slug}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
)}
<DialogContent>
<div className="mb-4">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
</h3>
<div>
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
</div>
</div>
<Form
form={form}
handleSubmit={(values) => {
const payload: CreateEventType = {
title: values.title,
slug: values.slug,
description: values.description,
length: values.length,
};
if (router.query.teamId) {
payload.teamId = parseInt(`${router.query.teamId}`, 10);
payload.schedulingType = values.schedulingType as SchedulingType;
}
createMutation.mutate(payload);
}}>
<div className="mt-3 space-y-4">
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
<TextField
label={t("url")}
required
addOnLeading={
<InputLeading>
{process.env.NEXT_PUBLIC_APP_URL}/{pageSlug}/
</InputLeading>
}
{...register("slug")}
/>
<TextAreaField
label={t("description")}
placeholder={t("quick_video_meeting")}
{...register("description")}
/>
<div className="relative">
<TextField
type="number"
required
placeholder="15"
defaultValue={15}
label={t("length")}
className="pr-20"
{...register("length")}
/>
<div className="absolute inset-y-0 right-0 flex items-center pt-4 mt-1.5 pr-3 text-sm text-gray-400">
{t("minutes")}
</div>
</div>
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
{t("scheduling_type")}
</label>
<RadioArea.Group
{...register("schedulingType")}
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
className="relative flex mt-1 space-x-6 rounded-sm shadow-sm">
<RadioArea.Item value={SchedulingType.COLLECTIVE} className="w-1/2 text-sm">
<strong className="block mb-1">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item value={SchedulingType.ROUND_ROBIN} className="w-1/2 text-sm">
<strong className="block mb-1">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
)}
</div>
<div className="flex flex-row-reverse mt-8 gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
</div>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -32,8 +32,9 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
<>
<div className={classNames("text-neutral-500 dark:text-white", className)}>
{eventType.description && (
<h2 className="opacity-60 truncate max-w-[280px] sm:max-w-[500px]">
<h2 className="opacity-60 text-ellipsis overflow-hidden max-w-[280px] sm:max-w-[500px]">
{eventType.description.substring(0, 100)}
{eventType.description.length > 100 && "..."}
</h2>
)}
<ul className="flex mt-2 space-x-4 ">

View File

@@ -10,13 +10,14 @@ import showToast from "@lib/notification";
import { Alert } from "@components/ui/Alert";
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return (
<input
{...props}
ref={ref}
className={classNames(
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm",
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-1 focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm",
props.className
)}
/>
@@ -31,6 +32,14 @@ export function Label(props: JSX.IntrinsicElements["label"]) {
);
}
export function InputLeading(props: JSX.IntrinsicElements["div"]) {
return (
<span className="inline-flex items-center flex-shrink-0 px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{props.children}
</span>
);
}
type InputFieldProps = {
label?: ReactNode;
addOnLeading?: ReactNode;
@@ -50,26 +59,28 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
: "",
className,
addOnLeading,
...passThroughToInput
...passThrough
} = props;
return (
<div>
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
{!!props.name && (
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
)}
{addOnLeading ? (
<div className="flex mt-1 rounded-md shadow-sm">
{addOnLeading}
<Input
id={id}
placeholder={placeholder}
className={classNames(className, "mt-0")}
{...passThroughToInput}
className={classNames(className, "mt-0", props.addOnLeading && "rounded-l-none")}
{...passThrough}
ref={ref}
/>
</div>
) : (
<Input id={id} placeholder={placeholder} className={className} {...passThroughToInput} ref={ref} />
<Input id={id} placeholder={placeholder} className={className} {...passThrough} ref={ref} />
)}
{methods?.formState?.errors[props.name] && (
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
@@ -89,8 +100,76 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
});
export const EmailInput = forwardRef<HTMLInputElement, JSX.IntrinsicElements["input"]>(function EmailInput(
props,
ref
) {
return (
<input
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
ref={ref}
{...props}
/>
);
});
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
return <InputField type="email" inputMode="email" ref={ref} {...props} />;
return <EmailInput ref={ref} {...props} />;
});
type TextAreaProps = Omit<JSX.IntrinsicElements["textarea"], "name"> & { name: string };
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextAreaInput(props, ref) {
return (
<textarea
ref={ref}
{...props}
className={classNames(
"block w-full font-mono border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm",
props.className
)}
/>
);
});
type TextAreaFieldProps = {
label?: ReactNode;
} & React.ComponentProps<typeof TextArea> & {
labelProps?: React.ComponentProps<typeof Label>;
};
export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(function TextField(
props,
ref
) {
const id = useId();
const { t } = useLocale();
const methods = useFormContext();
const {
label = t(props.name as string),
labelProps,
placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
? t(props.name + "_placeholder")
: "",
...passThrough
} = props;
return (
<div>
{!!props.name && (
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
)}
<TextArea ref={ref} placeholder={placeholder} {...passThrough} />
{methods?.formState?.errors[props.name] && (
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
)}
</div>
);
});
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<

View File

@@ -1,5 +1,6 @@
import React, { Fragment } from "react";
import React, { Fragment, useState } from "react";
import { useMutation } from "react-query";
import Select from "react-select";
import { QueryCell } from "@lib/QueryCell";
import { useLocale } from "@lib/hooks/useLocale";
@@ -98,63 +99,138 @@ function ConnectedCalendarsList(props: Props) {
<QueryCell
query={query}
empty={() => null}
success={({ data }) => (
<List>
{data.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars ? (
<IntegrationListItem
{...item.integration}
description={item.primary?.externalId || "No external Id"}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn">
{t("disconnect")}
</Button>
)}
onOpenChange={props.onChanged}
/>
}>
<ul className="p-4 space-y-2">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId as string}
title={cal.name as string}
type={item.integration.type}
defaultSelected={cal.isSelected}
success={({ data }) => {
if (!data.connectedCalendars.length) {
return null;
}
return (
<List>
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars ? (
<IntegrationListItem
{...item.integration}
description={item.primary?.externalId || "No external Id"}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
{t("disconnect")}
</Button>
)}
onOpenChange={props.onChanged}
/>
))}
</ul>
</IntegrationListItem>
) : (
<Alert
severity="warning"
title="Something went wrong"
message={item.error?.message}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn">
{t("disconnect")}
</Button>
)}
onOpenChange={() => props.onChanged()}
/>
}
/>
)}
</Fragment>
))}
</List>
)}
}>
<ul className="p-4 space-y-2">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId as string}
title={cal.name as string}
type={item.integration.type}
defaultSelected={cal.isSelected}
/>
))}
</ul>
</IntegrationListItem>
) : (
<Alert
severity="warning"
title="Something went wrong"
message={item.error?.message}
actions={
<DisconnectIntegration
id={item.credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
Disconnect
</Button>
)}
onOpenChange={() => props.onChanged()}
/>
}
/>
)}
</Fragment>
))}
</List>
);
}}
/>
);
}
function PrimaryCalendarSelector() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"], {
suspense: true,
});
const [selectedOption, setSelectedOption] = useState(() => {
const selected = query.data?.connectedCalendars
.map((connected) => connected.calendars ?? [])
.flat()
.find((cal) => cal.externalId === query.data.destinationCalendar?.externalId);
if (!selected) {
return null;
}
return {
value: `${selected.integration}:${selected.externalId}`,
label: selected.name,
};
});
const mutation = trpc.useMutation("viewer.setUserDestinationCalendar");
if (!query.data?.connectedCalendars.length) {
return null;
}
const options =
query.data.connectedCalendars.map((selectedCalendar) => ({
key: selectedCalendar.credentialId,
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
options: (selectedCalendar.calendars ?? []).map((cal) => ({
label: cal.name || "",
value: `${cal.integration}:${cal.externalId}`,
})),
})) ?? [];
return (
<div className="relative">
{/* There's no easy way to customize the displayed value for a Select, so we fake it. */}
<div className="absolute z-10 pointer-events-none">
<Button size="sm" color="secondary" className="border-transparent m-[1px] rounded-sm">
{t("select_destination_calendar")}: {selectedOption?.label || ""}
</Button>
</div>
<Select
name={"primarySelectedCalendar"}
placeholder={`${t("select_destination_calendar")}:`}
options={options}
isSearchable={false}
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
onChange={(option) => {
setSelectedOption(option);
if (!option) {
return;
}
/* Split only the first `:`, since Apple uses the full URL as externalId */
const [integration, externalId] = option.value.split(/:(.+)/);
mutation.mutate({
integration,
externalId,
});
}}
isLoading={mutation.isLoading}
value={selectedOption}
/>
</div>
);
}
function CalendarList(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations"]);
@@ -172,7 +248,7 @@ function CalendarList(props: Props) {
<ConnectIntegration
type={item.type}
render={(btnProps) => (
<Button color="secondary" {...btnProps}>
<Button color="secondary" {...btnProps} data-testid="integration-connection-button">
{t("connect")}
</Button>
)}
@@ -200,13 +276,23 @@ export function CalendarListContainer(props: { heading?: false }) {
<>
{heading && (
<ShellSubHeading
className="mt-10"
title={<SubHeadingTitleWithConnections title={t("calendar")} numConnections={query.data?.length} />}
className="mt-10 mb-0"
title={
<SubHeadingTitleWithConnections
title="Calendars"
numConnections={query.data?.connectedCalendars.length}
/>
}
subtitle={t("configure_how_your_event_types_interact")}
actions={
<div className="block max-w-full sm:min-w-80">
<PrimaryCalendarSelector />
</div>
}
/>
)}
<ConnectedCalendarsList onChanged={onChanged} />
{!!query.data?.length && (
{!!query.data?.connectedCalendars.length && (
<ShellSubHeading
className="mt-6"
title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />}

View File

@@ -2,8 +2,8 @@ import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types
import { useState } from "react";
import { useMutation } from "react-query";
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration";
import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration";
import { ButtonBaseProps } from "@components/ui/Button";

View File

@@ -5,6 +5,8 @@ import Select, { OptionTypeBase } from "react-select";
import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button";
interface Props {
onSubmit: SubmitHandler<IFormInput>;
onCancel: () => void;
@@ -82,7 +84,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
<input
type="text"
id="placeholder"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-sm"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
defaultValue={selectedCustomInput?.placeholder}
{...register("placeholder")}
/>
@@ -114,12 +116,10 @@ const CustomInputTypeForm: FC<Props> = (props) => {
{...register("id", { valueAsNumber: true })}
/>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
<button onClick={onCancel} type="button" className="mr-2 btn btn-white">
<Button type="submit">{t("save")}</Button>
<Button onClick={onCancel} type="button" color="secondary" className="mr-2">
{t("cancel")}
</button>
</Button>
</div>
</form>
);

View File

@@ -4,6 +4,8 @@ import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import Button from "@components/ui/Button";
const ChangePasswordSection = () => {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@@ -97,11 +99,7 @@ const ChangePasswordSection = () => {
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
<div className="py-8 flex justify-end">
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("save")}
</button>
<Button type="submit">{t("save")}</Button>
</div>
<hr className="mt-4" />
</div>

View File

@@ -4,11 +4,11 @@ import React from "react";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return (
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-brand text-brandcontrast bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<ShieldCheckIcon className="w-6 h-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">
<h3 className="text-lg font-medium leading-6 text-gray-900 font-cal" id="modal-title">
{title}
</h3>
<p className="text-sm text-gray-400">{description}</p>

View File

@@ -10,8 +10,8 @@ export type HeadSeoProps = {
description: string;
siteName?: string;
name?: string;
avatar?: string;
url?: string;
username?: string;
canonical?: string;
nextSeoProps?: NextSeoProps;
};
@@ -39,9 +39,6 @@ const buildSeoMeta = (pageProps: {
images: [
{
url: image,
//width: 1077,
//height: 565,
//alt: "Alt image"
},
],
},
@@ -66,11 +63,14 @@ const buildSeoMeta = (pageProps: {
};
};
const constructImage = (name: string, avatar: string, description: string): string => {
const constructImage = (name: string, description: string, username: string): string => {
return (
encodeURIComponent("Meet **" + name + "** <br>" + description).replace(/'/g, "%27") +
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
encodeURIComponent(avatar)
(process.env.NEXT_PUBLIC_APP_URL || process.env.BASE_URL) +
"/" +
username +
"/avatar.png"
);
};
@@ -82,18 +82,31 @@ export const HeadSeo: React.FC<HeadSeoProps & { children?: never }> = (props) =>
title,
description,
name = null,
avatar = null,
username = null,
siteName,
canonical = defaultUrl,
nextSeoProps = {},
} = props;
const truncatedDescription = description.length > 24 ? description.substring(0, 23) + "..." : description;
const pageTitle = title + " | Cal.com";
let seoObject = buildSeoMeta({ title: pageTitle, image, description, canonical, siteName });
let seoObject = buildSeoMeta({
title: pageTitle,
image,
description: truncatedDescription,
canonical,
siteName,
});
if (name && avatar) {
const pageImage = getSeoImage("ogImage") + constructImage(name, avatar, description);
seoObject = buildSeoMeta({ title: pageTitle, description, image: pageImage, canonical, siteName });
if (name && username) {
const pageImage = getSeoImage("ogImage") + constructImage(name, truncatedDescription, username);
seoObject = buildSeoMeta({
title: pageTitle,
description: truncatedDescription,
image: pageImage,
canonical,
siteName,
});
}
const seoProps: NextSeoProps = merge(nextSeoProps, seoObject);

View File

@@ -1,292 +0,0 @@
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
import React, { useEffect, useRef, useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import showToast from "@lib/notification";
import { Team } from "@lib/team";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ImageUploader from "@components/ImageUploader";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import MemberInvitationModal from "@components/team/MemberInvitationModal";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import { UsernameInput } from "@components/ui/UsernameInput";
import ErrorAlert from "@components/ui/alerts/Error";
import MemberList from "./MemberList";
export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
const [members, setMembers] = useState([]);
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const [hasErrors, setHasErrors] = useState(false);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
const [errorMessage, setErrorMessage] = useState("");
const [imageSrc, setImageSrc] = useState<string>("");
const { t } = useLocale();
const loadMembers = () =>
fetch("/api/teams/" + props.team?.id + "/membership")
.then((res) => res.json())
.then((data) => setMembers(data.members));
useEffect(() => {
loadMembers();
}, []);
const deleteTeam = () => {
return fetch("/api/teams/" + props.team?.id, {
method: "DELETE",
}).then(props.onCloseEdit());
};
const onRemoveMember = (member: Member) => {
return fetch("/api/teams/" + props.team?.id + "/membership", {
method: "DELETE",
body: JSON.stringify({ userId: member.id }),
headers: {
"Content-Type": "application/json",
},
}).then(loadMembers);
};
const onInviteMember = (team: Team | null | undefined) => {
setShowMemberInvitationModal(true);
setInviteModalTeam(team);
};
const handleError = async (resp: Response) => {
if (!resp.ok) {
const error = await resp.json();
throw new Error(error.message);
}
};
async function updateTeamHandler(event) {
event.preventDefault();
const enteredUsername = teamUrlRef?.current?.value.toLowerCase();
const enteredName = nameRef?.current?.value;
const enteredDescription = descriptionRef?.current?.value;
const enteredLogo = logoRef?.current?.value;
const enteredHideBranding = hideBrandingRef?.current?.checked;
// TODO: Add validation
await fetch("/api/teams/" + props.team?.id + "/profile", {
method: "PATCH",
body: JSON.stringify({
username: enteredUsername,
name: enteredName,
description: enteredDescription,
logo: enteredLogo,
hideBranding: enteredHideBranding,
}),
headers: {
"Content-Type": "application/json",
},
})
.then(handleError)
.then(() => {
showToast(t("your_team_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
})
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
}
const onMemberInvitationModalExit = () => {
loadMembers();
setShowMemberInvitationModal(false);
};
const handleLogoChange = (newLogo: string) => {
logoRef.current.value = newLogo;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, "value").set;
nativeInputValueSetter?.call(logoRef.current, newLogo);
const ev2 = new Event("input", { bubbles: true });
logoRef?.current?.dispatchEvent(ev2);
updateTeamHandler(ev2);
setImageSrc(newLogo);
};
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="mb-4">
<Button
type="button"
color="secondary"
size="sm"
StartIcon={ArrowLeftIcon}
onClick={() => props.onCloseEdit()}>
{t("back")}
</Button>
</div>
<div>
<div className="pb-5 pr-4 sm:pb-6">
<h3 className="text-lg font-bold leading-6 text-gray-900">{props.team?.name}</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>{t("manage_your_team")}</p>
</div>
</div>
</div>
<hr className="mt-2" />
<h3 className="font-cal font-bold leading-6 text-gray-900 mt-7 text-md">{t("profile")}</h3>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
{hasErrors && <ErrorAlert message={errorMessage} />}
<div className="py-6 lg:pb-8">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<div className="block sm:flex">
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
<UsernameInput
ref={teamUrlRef}
defaultValue={props.team?.slug}
label={t("my_team_url")}
/>
</div>
<div className="w-full sm:w-1/2 sm:ml-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("team_name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder={t("your_team_name")}
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.team?.name}
/>
</div>
</div>
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
{t("about")}
</label>
<div className="mt-1">
<textarea
ref={descriptionRef}
id="about"
name="about"
rows={3}
defaultValue={props.team?.bio}
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
</div>
</div>
<div>
<div className="flex mt-1">
<Avatar
className="relative w-10 h-10 rounded-full"
imageSrc={imageSrc ? imageSrc : props.team?.logo}
displayName="Logo"
/>
<input
ref={logoRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={imageSrc ?? props.team?.logo}
/>
<ImageUploader
target="logo"
id="logo-upload"
buttonMsg={imageSrc !== "" ? t("edit_logo") : t("upload_a_logo")}
handleAvatarChange={handleLogoChange}
imageSrc={imageSrc ?? props.team?.logo}
/>
</div>
<hr className="mt-6" />
</div>
<div className="flex justify-between mt-7">
<h3 className="font-cal font-bold leading-6 text-gray-900 text-md">{t("members")}</h3>
<div className="relative flex items-center">
<Button
type="button"
color="secondary"
StartIcon={PlusIcon}
onClick={() => onInviteMember(props.team)}>
{t("new_member")}
</Button>
</div>
</div>
<div>
{!!members.length && (
<MemberList members={members} onRemoveMember={onRemoveMember} onChange={loadMembers} />
)}
<hr className="mt-6" />
</div>
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={hideBrandingRef}
defaultChecked={props.team?.hideBranding}
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding")}
</label>
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
</div>
</div>
<hr className="mt-6" />
</div>
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">{t("danger_zone")}</h3>
<div>
<div className="relative flex items-start">
<Dialog>
<DialogTrigger
onClick={(e) => {
e.stopPropagation();
}}
className="btn-sm btn-white">
<TrashIcon className="group-hover:text-red text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
{t("disband_team")}
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => deleteTeam()}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</div>
</div>
<hr className="mt-8" />
<div className="flex justify-end py-4">
<Button type="submit" color="primary">
{t("save")}
</Button>
</div>
</div>
</form>
{showMemberInvitationModal && (
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { MembershipRole } from "@prisma/client";
import { useState } from "react";
import React, { SyntheticEvent } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import Button from "@components/ui/Button";
import ModalContainer from "@components/ui/ModalContainer";
export default function MemberChangeRoleModal(props: {
memberId: number;
teamId: number;
initialRole: MembershipRole;
onExit: () => void;
}) {
const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER);
const [errorMessage, setErrorMessage] = useState("");
const { t } = useLocale();
const utils = trpc.useContext();
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
function changeRole(e: SyntheticEvent) {
e.preventDefault();
changeRoleMutation.mutate({
teamId: props.teamId,
memberId: props.memberId,
role,
});
}
return (
<ModalContainer>
<>
<div className="mb-4 sm:flex sm:items-start">
<div className="text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("change_member_role")}
</h3>
</div>
</div>
<form onSubmit={changeRole}>
<div className="mb-4">
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as MembershipRole)}
id="role"
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
<option value="MEMBER">{t("member")}</option>
<option value="ADMIN">{t("admin")}</option>
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
</select>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ml-2">
{t("save")}
</Button>
<Button type="button" color="secondary" onClick={props.onExit}>
{t("cancel")}
</Button>
</div>
</form>
</>
</ModalContainer>
);
}

View File

@@ -1,58 +1,50 @@
import { UsersIcon } from "@heroicons/react/outline";
import { UserIcon } from "@heroicons/react/outline";
import { MembershipRole } from "@prisma/client";
import { useState } from "react";
import React, { SyntheticEvent } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Team } from "@lib/team";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import { EmailInput } from "@components/form/fields";
import Button from "@components/ui/Button";
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
const [errorMessage, setErrorMessage] = useState("");
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const handleError = async (res: Response) => {
const responseData = await res.json();
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
if (res.ok === false) {
setErrorMessage(responseData.message);
throw new Error(responseData.message);
}
return responseData;
};
const inviteMember = (e: SyntheticEvent) => {
function inviteMember(e: SyntheticEvent) {
e.preventDefault();
if (!props.team) return;
const target = e.target as typeof e.target & {
elements: {
role: { value: string };
role: { value: MembershipRole };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
const payload = {
inviteMemberMutation.mutate({
teamId: props.team.id,
language: i18n.language,
role: target.elements["role"].value,
usernameOrEmail: target.elements["inviteUser"].value,
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
};
return fetch("/api/teams/" + props?.team?.id + "/invite", {
method: "POST",
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
})
.then(handleError)
.then(props.onExit)
.catch(() => {
// do nothing.
});
};
});
}
return (
<div
@@ -71,8 +63,8 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="w-6 h-6 text-black" />
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-brand text-brandcontrast bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UserIcon className="w-6 h-6 text-brandcontrast" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
@@ -89,7 +81,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
{t("email_or_username")}
</label>
<input
<EmailInput
type="text"
name="inviteUser"
id="inviteUser"
@@ -106,7 +98,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
id="role"
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
<option value="MEMBER">{t("member")}</option>
<option value="OWNER">{t("owner")}</option>
<option value="ADMIN">{t("admin")}</option>
</select>
</div>
<div className="relative flex items-start">

View File

@@ -1,30 +1,20 @@
import { Member } from "@lib/member";
import { inferQueryOutput } from "@lib/trpc";
import MemberListItem from "./MemberListItem";
export default function MemberList(props: {
members: Member[];
onRemoveMember: (text: Member) => void;
onChange: (text: string) => void;
}) {
const selectAction = (action: string, member: Member) => {
switch (action) {
case "remove":
props.onRemoveMember(member);
break;
}
};
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
members: inferQueryOutput<"viewer.teams.get">["members"];
}
export default function MemberList(props: Props) {
if (!props.members.length) return <></>;
return (
<div>
<ul className="px-6 mb-2 -mx-6 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
{props.members.map((member) => (
<MemberListItem
onChange={props.onChange}
key={member.id}
member={member}
onActionSelect={(action: string) => selectAction(action, member)}
/>
<ul className="px-4 mb-2 -mx-4 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
{props.members?.map((member) => (
<MemberListItem key={member.id} member={member} team={props.team} />
))}
</ul>
</div>

View File

@@ -1,104 +1,177 @@
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
import { useState } from "react";
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
import Link from "next/link";
import React, { useState } from "react";
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import showToast from "@lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import ModalContainer from "@components/ui/ModalContainer";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/Dropdown";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamRole from "./TeamRole";
import { MembershipRole } from ".prisma/client";
export default function MemberListItem(props: {
member: Member;
onActionSelect: (text: string) => void;
onChange: (text: string) => void;
}) {
const [member] = useState(props.member);
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
member: inferQueryOutput<"viewer.teams.get">["members"][number];
}
export default function MemberListItem(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const name =
props.member.name ||
(() => {
const emailName = props.member.email.split("@")[0];
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
})();
const removeMember = () =>
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
return (
member && (
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex flex-col justify-between w-full sm:flex-row">
<div className="flex">
<Avatar
imageSrc={
props.member.avatar
? props.member.avatar
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
encodeURIComponent(props.member.name || "")
}
alt={props.member.name || ""}
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
</div>
</div>
<div>
{props.member.role === "INVITEE" && (
<>
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
{t("pending")}
</span>
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
{t("member")}
</span>
</>
)}
{props.member.role === "MEMBER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
{t("member")}
</span>
)}
{props.member.role === "OWNER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
{t("owner")}
</span>
)}
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex flex-col justify-between w-full sm:flex-row">
<div className="flex">
<Avatar
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
alt={name || ""}
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{name}</span>
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
</div>
</div>
<div className="flex">
{/* <div className="flex flex-col-reverse"> */}
<Dropdown>
<DropdownMenuTrigger>
<DotsHorizontalIcon className="w-5 h-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={UserRemoveIcon}
className="w-full">
{t("remove_member")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={() => props.onActionSelect("remove")}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
{/* </div> */}
<div className="flex mt-2 mr-2 sm:mt-0 sm:justify-center">
{!props.member.accepted && <TeamRole invitePending />}
<TeamRole role={props.member.role} />
</div>
</div>
</li>
)
<div className="flex">
<Tooltip content={t("team_view_user_availability")}>
<Button
// Disabled buttons don't trigger Tooltips
title={
props.member.accepted
? t("team_view_user_availability")
: t("team_view_user_availability_disabled")
}
disabled={!props.member.accepted}
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
color="minimal"
className="hidden w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white sm:block">
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
</Button>
</Tooltip>
<Dropdown>
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={"/" + props.member.username}>
<a target="_blank">
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full font-normal">
{t("view_public_page")}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{(props.team.membership.role === MembershipRole.OWNER ||
props.team.membership.role === MembershipRole.ADMIN) && (
<>
<DropdownMenuItem>
<Button
onClick={() => setShowChangeMemberRoleModal(true)}
color="minimal"
StartIcon={PencilIcon}
className="flex-shrink-0 w-full font-normal">
{t("edit_role")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={UserRemoveIcon}
className="w-full font-normal">
{t("remove_member")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={removeMember}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</div>
{showChangeMemberRoleModal && (
<MemberChangeRoleModal
teamId={props.team?.id}
memberId={props.member.id}
initialRole={props.member.role as MembershipRole}
onExit={() => setShowChangeMemberRoleModal(false)}
/>
)}
{showTeamAvailabilityModal && (
<ModalContainer wide noPadding>
<TeamAvailabilityModal team={props.team} member={props.member} />
<div className="p-5 space-x-2 border-t">
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
{props.team.membership.role !== MembershipRole.MEMBER && (
<Link href={`/settings/teams/${props.team.id}/availability`}>
<Button color="secondary">{t("Open Team Availability")}</Button>
</Link>
)}
</div>
</ModalContainer>
)}
</li>
);
}

View File

@@ -0,0 +1,86 @@
import { UsersIcon } from "@heroicons/react/outline";
import { useRef } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
interface Props {
onClose: () => void;
}
export default function TeamCreate(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
onSuccess: () => {
utils.invalidateQueries(["viewer.teams.list"]);
props.onClose();
},
});
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
createTeamMutation.mutate({ name: nameRef?.current?.value });
};
return (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="w-6 h-6 text-neutral-900" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("create_new_team")}
</h3>
<div>
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
</div>
</div>
</div>
<form onSubmit={createTeam}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Acme Inc."
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
{t("create_team")}
</button>
<button onClick={props.onClose} type="button" className="mr-2 btn btn-white">
{t("cancel")}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,39 +1,44 @@
import { Team } from "@lib/team";
import showToast from "@lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
import TeamListItem from "./TeamListItem";
export default function TeamList(props: {
teams: Team[];
onChange: () => void;
onEditTeam: (text: Team) => void;
}) {
const selectAction = (action: string, team: Team) => {
interface Props {
teams: inferQueryOutput<"viewer.teams.list">;
}
export default function TeamList(props: Props) {
const utils = trpc.useContext();
function selectAction(action: string, teamId: number) {
switch (action) {
case "edit":
props.onEditTeam(team);
break;
case "disband":
deleteTeam(team);
deleteTeam(teamId);
break;
}
};
}
const deleteTeam = async (team: Team) => {
await fetch("/api/teams/" + team.id, {
method: "DELETE",
});
return props.onChange();
};
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.list"]);
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam(teamId: number) {
deleteTeamMutation.mutate({ teamId });
}
return (
<div>
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{props.teams.map((team: Team) => (
<ul className="mb-2 bg-white border divide-y rounded divide-neutral-200">
{props.teams.map((team) => (
<TeamListItem
onChange={props.onChange}
key={team.id}
key={team?.id as number}
team={team}
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
))}
</ul>
</div>

View File

@@ -1,173 +1,212 @@
import {
DotsHorizontalIcon,
ExternalLinkIcon,
LinkIcon,
PencilAltIcon,
TrashIcon,
} from "@heroicons/react/outline";
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
import Link from "next/link";
import { useState } from "react";
import classNames from "@lib/classNames";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@components/ui/Dropdown";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
import TeamRole from "./TeamRole";
import { MembershipRole } from ".prisma/client";
interface Team {
id: number;
name: string | null;
slug: string | null;
logo: string | null;
bio: string | null;
role: string | null;
hideBranding: boolean;
prevState: null;
interface Props {
team: inferQueryOutput<"viewer.teams.list">[number];
key: number;
onActionSelect: (text: string) => void;
}
export default function TeamListItem(props: {
onChange: () => void;
key: number;
team: Team;
onActionSelect: (text: string) => void;
}) {
const [team, setTeam] = useState<Team | null>(props.team);
export default function TeamListItem(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const team = props.team;
const acceptInvite = () => invitationResponse(true);
const declineInvite = () => invitationResponse(false);
const invitationResponse = (accept: boolean) =>
fetch("/api/user/membership", {
method: accept ? "PATCH" : "DELETE",
body: JSON.stringify({ teamId: props.team.id }),
headers: {
"Content-Type": "application/json",
},
}).then(() => {
// success
setTeam(null);
props.onChange();
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
onSuccess: () => {
utils.invalidateQueries(["viewer.teams.list"]);
},
});
function acceptOrLeave(accept: boolean) {
acceptOrLeaveMutation.mutate({
teamId: team?.id as number,
accept,
});
}
const acceptInvite = () => acceptOrLeave(true);
const declineInvite = () => acceptOrLeave(false);
const isOwner = props.team.role === MembershipRole.OWNER;
const isInvitee = !props.team.accepted;
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
if (!team) return <></>;
const teamInfo = (
<div className="flex px-5 py-5">
<Avatar
size={9}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="rounded-full w-9 h-9 min-w-9 min-h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
<span className="block text-xs text-gray-400">
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
</span>
</div>
</div>
);
return (
team && (
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex">
<Avatar
size={9}
imageSrc={
props.team.logo
? props.team.logo
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
encodeURIComponent(props.team.name || "")
}
alt="Team Logo"
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{props.team.name}</span>
<span className="block -mt-1 text-xs text-gray-400">
{process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
</span>
</div>
</div>
{props.team.role === "INVITEE" && (
<div>
<li className="divide-y">
<div
className={classNames(
"flex justify-between items-center",
!isInvitee && "group hover:bg-neutral-50"
)}>
{!isInvitee ? (
<Link href={"/settings/teams/" + team.id}>
<a className="flex-grow text-sm truncate cursor-pointer" title={`${team.name}`}>
{teamInfo}
</a>
</Link>
) : (
teamInfo
)}
<div className="px-5 py-5">
{isInvitee && (
<>
<Button type="button" color="secondary" onClick={declineInvite}>
{t("reject")}
</Button>
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
<Button type="button" color="primary" className="ml-2" onClick={acceptInvite}>
{t("accept")}
</Button>
</div>
</>
)}
{props.team.role === "MEMBER" && (
<div>
<Button type="button" color="primary" onClick={declineInvite}>
{t("leave")}
</Button>
</div>
)}
{props.team.role === "OWNER" && (
<div className="flex space-x-4">
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
{t("owner")}
</span>
<Tooltip content={t("copy_link")}>
{!isInvitee && (
<div className="flex space-x-2">
<TeamRole role={team.role as MembershipRole} />
<Tooltip content={t("copy_link_team")}>
<Button
onClick={() => {
navigator.clipboard.writeText(
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
);
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
showToast(t("link_copied"), "success");
}}
className="w-10 h-10 transition-none"
size="icon"
color="minimal"
StartIcon={LinkIcon}
type="button"
/>
type="button">
<LinkIcon className="w-5 h-5 group-hover:text-gray-600" />
</Button>
</Tooltip>
<Dropdown>
<DropdownMenuTrigger className="group w-10 h-10 p-0 border border-transparent text-neutral-400 hover:border-gray-200">
<DotsHorizontalIcon className="w-5 h-5" />
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 ">
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{isAdmin && (
<DropdownMenuItem>
<Link href={"/settings/teams/" + team.id}>
<a>
<Button
type="button"
color="minimal"
className="w-full font-normal"
StartIcon={PencilIcon}>
{t("edit_team")}
</Button>
</a>
</Link>
</DropdownMenuItem>
)}
{isAdmin && <DropdownMenuSeparator className="h-px bg-gray-200" />}
<DropdownMenuItem>
<Button
type="button"
color="minimal"
className="w-full"
onClick={() => props.onActionSelect("edit")}
StartIcon={PencilAltIcon}>
{" "}
{t("edit_team")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team.slug}`} passHref={true}>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
<a target="_blank">
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
<Button
type="button"
color="minimal"
className="w-full font-normal"
StartIcon={ExternalLinkIcon}>
{" "}
{t("preview_team")}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={TrashIcon}
className="w-full">
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => props.onActionSelect("disband")}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{isOwner && (
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={TrashIcon}
className="w-full font-normal">
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => props.onActionSelect("disband")}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
)}
{!isOwner && (
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
color="warn"
StartIcon={LogoutIcon}
className="w-full"
onClick={(e) => {
e.stopPropagation();
}}>
{t("leave_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("leave_team")}
confirmBtnText={t("confirm_leave_team")}
onConfirm={declineInvite}>
{t("leave_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</li>
)
</div>
</li>
);
}

View File

@@ -0,0 +1,37 @@
import { MembershipRole } from "@prisma/client";
import classNames from "classnames";
import { useLocale } from "@lib/hooks/useLocale";
interface Props {
role?: MembershipRole;
invitePending?: boolean;
}
export default function TeamRole(props: Props) {
const { t } = useLocale();
return (
<span
className={classNames("self-center px-3 py-1 mr-2 text-xs capitalize border rounded-md", {
"bg-blue-50 border-blue-200 text-blue-700": props.role === "MEMBER",
"bg-gray-50 border-gray-200 text-gray-700": props.role === "OWNER",
"bg-red-50 border-red-200 text-red-700": props.role === "ADMIN",
"bg-yellow-50 border-yellow-200 text-yellow-700": props.invitePending,
})}>
{(() => {
if (props.invitePending) return t("invitee");
switch (props.role) {
case "OWNER":
return t("owner");
case "ADMIN":
return t("admin");
case "MEMBER":
return t("member");
default:
return "";
}
})()}
</span>
);
}

View File

@@ -0,0 +1,210 @@
import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid";
import React, { useRef, useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import ImageUploader from "@components/ImageUploader";
import { TextField } from "@components/form/fields";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
import SettingInputContainer from "@components/ui/SettingInputContainer";
interface Props {
team: TeamWithMembers | null | undefined;
}
export default function TeamSettings(props: Props) {
const { t } = useLocale();
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const team = props.team;
const hasLogo = !!team?.logo;
const utils = trpc.useContext();
const mutation = trpc.useMutation("viewer.teams.update", {
onError: (err) => {
setHasErrors(true);
setErrorMessage(err.message);
},
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("your_team_updated_successfully"), "success");
setHasErrors(false);
},
});
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
function updateTeamData() {
if (!team) return;
const variables = {
name: nameRef.current?.value,
slug: teamUrlRef.current?.value,
bio: descriptionRef.current?.value,
hideBranding: hideBrandingRef.current?.checked,
};
// remove unchanged variables
for (const key in variables) {
//@ts-expect-error will fix types
if (variables[key] === team?.[key]) delete variables[key];
}
mutation.mutate({ id: team.id, ...variables });
}
function updateLogo(newLogo: string) {
if (!team) return;
logoRef.current.value = newLogo;
mutation.mutate({ id: team.id, logo: newLogo });
}
const removeLogo = () => updateLogo("");
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="">
{hasErrors && <Alert severity="error" title={errorMessage} />}
<form
className="divide-y divide-gray-200 lg:col-span-9"
onSubmit={(e) => {
e.preventDefault();
updateTeamData();
}}>
<div className="py-6">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<SettingInputContainer
Icon={LinkIcon}
label="Team URL"
htmlFor="team-url"
Input={
<TextField
name="" // typescript requires name but we don't want component to render name label
id="team-url"
addOnLeading={
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
</span>
}
ref={teamUrlRef}
defaultValue={team?.slug as string}
/>
}
/>
<SettingInputContainer
Icon={HashtagIcon}
label="Team Name"
htmlFor="name"
Input={
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder={t("your_team_name")}
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
defaultValue={team?.name as string}
/>
}
/>
<hr />
<div>
<SettingInputContainer
Icon={InformationCircleIcon}
label={t("about")}
htmlFor="about"
Input={
<>
<textarea
ref={descriptionRef}
id="about"
name="about"
rows={3}
defaultValue={team?.bio as string}
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"></textarea>
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
</>
}
/>
</div>
<div>
<SettingInputContainer
Icon={PhotographIcon}
label={"Logo"}
htmlFor="avatar"
Input={
<>
<div className="flex mt-1">
<input
ref={logoRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
defaultValue={team?.logo ?? undefined}
/>
<ImageUploader
target="logo"
id="logo-upload"
buttonMsg={hasLogo ? t("edit_logo") : t("upload_a_logo")}
handleAvatarChange={updateLogo}
imageSrc={team?.logo ?? undefined}
/>
{hasLogo && (
<Button
onClick={removeLogo}
color="secondary"
type="button"
className="py-1 ml-1 text-xs">
{t("remove_logo")}
</Button>
)}
</div>
</>
}
/>
<hr className="mt-6" />
</div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={hideBrandingRef}
defaultChecked={team?.hideBranding}
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding")}
</label>
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end py-4">
<Button type="submit" color="primary">
{t("save")}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import LinkIconButton from "@components/ui/LinkIconButton";
import { MembershipRole } from ".prisma/client";
// import Switch from "@components/ui/Switch";
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
const { t } = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("your_team_updated_successfully"), "success");
},
});
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
onSuccess: () => {
utils.invalidateQueries(["viewer.teams.list"]);
router.push(`/settings/teams`);
},
});
function deleteTeam() {
if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
}
function leaveTeam() {
if (props.team?.id)
acceptOrLeaveMutation.mutate({
teamId: props.team.id,
accept: false,
});
}
return (
<div className="px-2 space-y-6">
<CreateEventTypeButton
isIndividualTeam
canAddEvents={true}
options={[
{ teamId: props.team?.id, name: props.team?.name, slug: props.team?.slug, image: props.team?.logo },
]}
/>
{/* <Switch
name="isHidden"
defaultChecked={hidden}
onCheckedChange={setHidden}
label={"Hide team from view"}
/> */}
<div className="space-y-1">
<Link href={permalink} passHref={true}>
<a target="_blank">
<LinkIconButton Icon={ExternalLinkIcon}>{t("preview")}</LinkIconButton>
</a>
</Link>
<LinkIconButton
Icon={LinkIcon}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
{props.role === "OWNER" ? (
<Dialog>
<DialogTrigger asChild>
<LinkIconButton
onClick={(e) => {
e.stopPropagation();
}}
Icon={TrashIcon}>
{t("disband_team")}
</LinkIconButton>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={deleteTeam}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
) : (
<Dialog>
<DialogTrigger asChild>
<LinkIconButton
Icon={LogoutIcon}
onClick={(e) => {
e.stopPropagation();
}}>
{t("leave_team")}
</LinkIconButton>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("leave_team")}
confirmBtnText={t("confirm_leave_team")}
onConfirm={leaveTeam}>
{t("leave_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
)}
</div>
{props.team?.id && props.role !== MembershipRole.MEMBER && (
<Link href={`/settings/teams/${props.team.id}/availability`}>
<div className="hidden mt-5 space-y-1 sm:block">
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
</div>
</Link>
)}
</div>
);
}

View File

@@ -2,24 +2,31 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon } from "@heroicons/react/solid";
import classnames from "classnames";
import Link from "next/link";
import { TeamPageProps } from "pages/team/[slug]";
import React from "react";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
const Team = ({ team }) => {
type TeamType = TeamPageProps["team"];
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
const Team = ({ team }: TeamPageProps) => {
const { t } = useLocale();
const Member = ({ member }) => {
const Member = ({ member }: { member: MemberType }) => {
const classes = classnames(
"group",
"relative",
"flex flex-col",
"space-y-4",
"p-4",
"min-w-full sm:min-w-64 sm:max-w-64",
"bg-white dark:bg-neutral-900 dark:border-0 dark:bg-opacity-8",
"border border-neutral-200",
"hover:cursor-pointer",
@@ -29,7 +36,7 @@ const Team = ({ team }) => {
);
return (
<Link key={member.id} href={`/${member.user.username}`}>
<Link key={member.id} href={`/${member.username}`}>
<div className={classes}>
<ArrowRightIcon
className={classnames(
@@ -42,11 +49,15 @@ const Team = ({ team }) => {
/>
<div>
<Avatar displayName={member.user.name} imageSrc={member.user.avatar} className="w-12 h-12" />
<section className="space-y-2">
<Text variant="title">{member.user.name}</Text>
<Text variant="subtitle" className="w-6/8">
{member.user.bio}
<Avatar
alt={member.name || ""}
imageSrc={getPlaceholderAvatar(member.avatar, member.username)}
className="w-12 h-12 -mt-4"
/>
<section className="w-full mt-2 space-y-1">
<Text variant="title">{member.name}</Text>
<Text variant="subtitle" className="">
{member.bio || t("user_from_team", { user: member.name, team: team.name })}
</Text>
</section>
</div>
@@ -55,15 +66,15 @@ const Team = ({ team }) => {
);
};
const Members = ({ members }) => {
const Members = ({ members }: { members: MembersType }) => {
if (!members || members.length === 0) {
return null;
}
return (
<section className="mx-auto min-w-full lg:min-w-lg max-w-5xl flex flex-wrap gap-x-12 gap-y-6 justify-center">
<section className="flex flex-wrap justify-center max-w-5xl min-w-full mx-auto lg:min-w-lg gap-x-6 gap-y-6">
{members.map((member) => {
return member.user.username !== null && <Member key={member.id} member={member} />;
return member.username !== null && <Member key={member.id} member={member} />;
})}
</section>
);
@@ -73,7 +84,7 @@ const Team = ({ team }) => {
<div>
<Members members={team.members} />
{team.eventTypes.length > 0 && (
<aside className="text-center dark:text-white mt-8">
<aside className="mt-8 text-center dark:text-white">
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
{t("go_back")}
</Button>

View File

@@ -33,7 +33,7 @@ export function Alert(props: AlertProps) {
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
)}
</div>
<div className="ml-3 flex-grow">
<div className="flex-grow ml-3">
<h3 className="text-sm font-medium">{props.title}</h3>
<div className="text-sm">{props.message}</div>
</div>

View File

@@ -36,7 +36,7 @@ export default function Avatar(props: AvatarProps) {
return title ? (
<Tooltip.Tooltip delayDuration={300}>
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
<Tooltip.Content className="p-2 rounded-sm text-sm bg-brand text-white shadow-sm">
<Tooltip.Content className="p-2 text-sm rounded-sm shadow-sm bg-brand text-brandcontrast">
<Tooltip.Arrow />
{title}
</Tooltip.Content>

View File

@@ -12,7 +12,7 @@ export type AvatarGroupProps = {
items: {
image: string;
title?: string;
alt: string;
alt?: string;
}[];
className?: string;
};
@@ -28,19 +28,23 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
return (
<ul className={classNames("flex -space-x-2 overflow-hidden", props.className)}>
{props.items.slice(0, props.truncateAfter).map((item, idx) => (
<li key={idx} className="inline-block">
<Avatar imageSrc={item.image} title={item.title} alt={item.alt} size={props.size} />
</li>
))}
{props.items.slice(0, props.truncateAfter).map((item, idx) => {
if (item.image != null) {
return (
<li key={idx} className="inline-block">
<Avatar imageSrc={item.image} title={item.title} alt={item.alt || ""} size={props.size} />
</li>
);
}
})}
{/*props.items.length > props.truncateAfter && (
<li className="inline-block relative">
<li className="relative inline-block">
<Tooltip.Tooltip delayDuration="300">
<Tooltip.TooltipTrigger className="cursor-default">
<span className="w-16 absolute bottom-1.5 border-2 border-gray-300 flex-inline items-center text-white pt-4 text-2xl top-0 rounded-full block bg-neutral-600">+1</span>
</Tooltip.TooltipTrigger>
{truncatedAvatars.length !== 0 && (
<Tooltip.Content className="p-2 rounded-sm text-sm bg-brand text-white shadow-sm">
<Tooltip.Content className="p-2 text-sm text-white rounded-sm shadow-sm bg-brand">
<Tooltip.Arrow />
<ul>
{truncatedAvatars.map((title) => (

View File

@@ -64,7 +64,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
color === "primary" &&
(disabled
? "border border-transparent bg-gray-400 text-white"
: "border border-transparent dark:text-black text-white bg-brand dark:bg-white hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
: "border border-transparent dark:text-brandcontrast text-brandcontrast bg-brand dark:bg-brand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
color === "secondary" &&
(disabled
? "border border-gray-200 text-gray-400 bg-white"
@@ -90,12 +90,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
},
<>
{StartIcon && (
<StartIcon
className={classNames(
"inline",
size === "icon" ? "w-5 h-5 group-hover:text-black" : "w-5 h-5 mr-2 -ml-1"
)}
/>
<StartIcon className={classNames("inline", size === "icon" ? "w-5 h-5 " : "w-5 h-5 mr-2 -ml-1")} />
)}
{props.children}
{loading && (

View File

@@ -26,7 +26,7 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
return (
<DropdownMenuPrimitive.Content
{...props}
className="z-10 mt-1 text-sm origin-top-right bg-white rounded-sm shadow-lg w-44 ring-1 ring-black ring-opacity-5 focus:outline-none"
className="z-10 w-48 mt-1 text-sm origin-top-right bg-white rounded-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
ref={forwardedRef}>
{children}
</DropdownMenuPrimitive.Content>

View File

@@ -0,0 +1,21 @@
import React from "react";
import { SVGComponent } from "@lib/types/SVGComponent";
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Icon: SVGComponent;
}
export default function LinkIconButton(props: LinkIconButtonProps) {
return (
<div className="-ml-2">
<button
{...props}
type="button"
className="flex items-center px-2 py-1 text-sm font-medium text-gray-700 rounded-sm text-md hover:text-gray-900 hover:bg-gray-200">
<props.Icon className="w-4 h-4 mr-2 text-neutral-500" />
{props.children}
</button>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import classNames from "classnames";
import React from "react";
interface Props extends React.PropsWithChildren<any> {
wide?: boolean;
scroll?: boolean;
noPadding?: boolean;
}
export default function ModalContainer(props: Props) {
return (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
className={classNames(
"inline-block min-w-96 px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:p-6",
{
"sm:max-w-lg sm:w-full ": !props.wide,
"sm:max-w-4xl sm:w-4xl": props.wide,
"overflow-scroll": props.scroll,
"!p-0": props.noPadding,
}
)}>
{props.children}
</div>
</div>
</div>
);
}

View File

@@ -7,7 +7,9 @@ import React, { useEffect, useState } from "react";
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import { useLocale } from "@lib/hooks/useLocale";
import { OpeningHours, DateOverride } from "@lib/types/event-type";
import { WorkingHours } from "@lib/types/schedule";
import Button from "@components/ui/Button";
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
@@ -19,7 +21,7 @@ type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: (timeZone: string) => void;
setAvailability: (schedule: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }) => void;
setAvailability: (schedule: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] }) => void;
};
/**
@@ -103,9 +105,9 @@ export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone
<OpeningHours key={idx} idx={idx} item={item} />
))}
</ul>
<button type="button" onClick={addNewSchedule} className="mt-2 btn-white btn-sm">
<Button type="button" onClick={addNewSchedule} className="mt-2" color="secondary" size="sm">
{t("add_another")}
</button>
</Button>
</div>
</div>
{editSchedule >= 0 && (

View File

@@ -0,0 +1,25 @@
export default function SettingInputContainer({
Input,
Icon,
label,
htmlFor,
}: {
Input: React.ReactNode;
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element;
label: string;
htmlFor?: string;
}) {
return (
<div className="space-y-3">
<div className="block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor={htmlFor} className="flex mt-1 text-sm font-medium text-neutral-700">
<Icon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
{label}
</label>
</div>
<div className="flex-grow w-full">{Input}</div>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import React from "react";
interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
label?: string;
}
/**
* @deprecated Use <TextField addOnLeading={}> to achieve the same effect.
*/
const UsernameInput = React.forwardRef<HTMLInputElement, UsernameInputProps>((props, ref) => (
// todo, check if username is already taken here?
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
{props.label ? props.label : "Username"}
</label>
<div className="flex mt-1 rounded-md shadow-sm">
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{process.env.NEXT_PUBLIC_APP_URL}/{props.label && "team/"}
</span>
<input
ref={ref}
type="text"
name="username"
id="username"
autoComplete="username"
required
{...props}
className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
</div>
));
UsernameInput.displayName = "UsernameInput";
export { UsernameInput };

View File

@@ -34,7 +34,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
}}
className={`
w-10 h-10
bg-brand text-white focus:outline-none px-3 py-1 rounded
bg-brand text-brandcontrast focus:outline-none px-3 py-1 rounded
${activeDays[idx + 1] ? "rounded-r-none" : ""}
${activeDays[idx - 1] ? "rounded-l-none" : ""}
${idx === 0 ? "rounded-l" : ""}

View File

@@ -0,0 +1,28 @@
import { CalendarIcon } from "@heroicons/react/solid";
import React from "react";
import "react-calendar/dist/Calendar.css";
import "react-date-picker/dist/DatePicker.css";
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
import classNames from "@lib/classNames";
type Props = {
date: Date;
onDatesChange?: ((date: Date) => void) | undefined;
className?: string;
};
export const DatePicker = ({ date, onDatesChange, className }: Props) => {
return (
<PrimitiveDatePicker
className={classNames(
"p-1 pl-2 border border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
className
)}
clearIcon={null}
calendarIcon={<CalendarIcon className="w-5 h-5 text-gray-500" />}
value={date}
onChange={onDatesChange}
/>
);
};

View File

@@ -1,24 +1,30 @@
import classNames from "classnames";
import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: ReactNode;
label?: ReactNode;
};
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
return (
<div className="block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor={rest.id} className="flex text-sm font-medium text-neutral-700">
{label}
</label>
</div>
{!!label && (
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
{label}
</label>
</div>
)}
<div className="w-full">
<div className="relative rounded-sm shadow-sm">
<input
{...rest}
ref={ref}
type="number"
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className={classNames(
"block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
rest.className
)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">

View File

@@ -8,7 +8,7 @@ export const PhoneInput = (props: PhoneInputProps) => (
<BasePhoneInput
{...props}
className={classNames(
"shadow-sm rounded-md block w-full py-px px-3 border border-1 border-gray-300 ring-black focus-within:ring-1 focus-within:border-brand dark:border-gray-900 dark:text-white dark:bg-brand",
"shadow-sm rounded-sm block w-full py-px px-3 border border-1 border-gray-300 ring-black focus-within:ring-1 focus-within:border-brand dark:border-black dark:text-white dark:bg-black",
props.className
)}
onChange={() => {

View File

@@ -1,5 +1,7 @@
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
import dayjs, { Dayjs } from "dayjs";
import dayjs, { Dayjs, ConfigType } from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import React, { useCallback, useState } from "react";
import { Controller, useFieldArray } from "react-hook-form";
@@ -11,6 +13,9 @@ import { TimeRange } from "@lib/types/schedule";
import Button from "@components/ui/Button";
import Select from "@components/ui/form/Select";
dayjs.extend(utc);
dayjs.extend(timezone);
/** Begin Time Increments For Select */
const increment = 15;
/**
@@ -43,16 +48,17 @@ type TimeRangeFieldProps = {
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
const [options, setOptions] = useState<Option[]>([]);
const getOption = (time: Date) => ({
value: time.valueOf(),
label: time.toLocaleTimeString("nl-NL", { minute: "numeric", hour: "numeric" }),
// const { i18n } = useLocale();
const getOption = (time: ConfigType) => ({
value: dayjs(time).toDate().valueOf(),
label: dayjs(time).utc().format("HH:mm"),
// .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }),
});
const timeOptions = useCallback((offsetOrLimit: { offset?: number; limit?: number } = {}) => {
const { limit, offset } = offsetOrLimit;
return TIMES.filter((time) => (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset))).map(
(t) => getOption(t.toDate())
(t) => getOption(t)
);
}, []);
@@ -115,7 +121,7 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
};
return (
<fieldset className="flex justify-between py-5 min-h-[86px]">
<fieldset className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row justify-between py-5 min-h-[86px]">
<div className="w-1/3">
<label className="flex items-center space-x-2">
<input

View File

@@ -3,6 +3,8 @@ import { useRef } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button";
export default function SetTimesModal(props) {
const { t } = useLocale();
const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60];
@@ -31,26 +33,26 @@ export default function SetTimesModal(props) {
return (
<div
className="fixed z-50 inset-0 overflow-y-auto"
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<ClockIcon className="h-6 w-6 text-black" />
<div className="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-blue-100 rounded-full sm:mx-0 sm:h-10 sm:w-10">
<ClockIcon className="w-6 h-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("change_bookings_availability")}
</h3>
<div>
@@ -59,7 +61,7 @@ export default function SetTimesModal(props) {
</div>
</div>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("start_time")}</label>
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("start_time")}</label>
<div>
<label htmlFor="startHours" className="sr-only">
{t("hours")}
@@ -72,12 +74,12 @@ export default function SetTimesModal(props) {
maxLength="2"
name="hours"
id="startHours"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="9"
defaultValue={startHours}
/>
</div>
<span className="mx-2 pt-1">:</span>
<span className="pt-1 mx-2">:</span>
<div>
<label htmlFor="startMinutes" className="sr-only">
{t("minutes")}
@@ -91,14 +93,14 @@ export default function SetTimesModal(props) {
maxLength="2"
name="minutes"
id="startMinutes"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="30"
defaultValue={startMinutes}
/>
</div>
</div>
<div className="flex">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("end_time")}</label>
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("end_time")}</label>
<div>
<label htmlFor="endHours" className="sr-only">
{t("hours")}
@@ -111,12 +113,12 @@ export default function SetTimesModal(props) {
maxLength="2"
name="hours"
id="endHours"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="17"
defaultValue={endHours}
/>
</div>
<span className="mx-2 pt-1">:</span>
<span className="pt-1 mx-2">:</span>
<div>
<label htmlFor="endMinutes" className="sr-only">
{t("minutes")}
@@ -130,19 +132,19 @@ export default function SetTimesModal(props) {
step="15"
name="minutes"
id="endMinutes"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="30"
defaultValue={endMinutes}
/>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button onClick={updateStartEndTimesHandler} type="submit" className="btn btn-primary">
<Button onClick={updateStartEndTimesHandler} type="submit">
{t("save")}
</button>
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
</Button>
<Button onClick={props.onExit} type="button" color="secondary" className="mr-2">
{t("cancel")}
</button>
</Button>
</div>
</div>
</div>

View File

@@ -10,6 +10,7 @@ services:
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: "cal-saml"
POSTGRES_PASSWORD: ""
POSTGRES_HOST_AUTH_METHOD: trust
volumes:

27
docs/saml-setup.md Normal file
View File

@@ -0,0 +1,27 @@
# SAML Registration with Identity Providers
This guide explains the settings youd need to use to configure SAML with your Identity Provider. Once this is set up you should get an XML metadata file that should then be uploaded on your Cal.com self-hosted instance.
> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below.
**Assertion consumer service URL / Single Sign-On URL / Destination URL:** [http://localhost:3000/api/auth/saml/callback](http://localhost:3000/api/auth/saml/callback) [Replace this with the URL for your self-hosted Cal instance]
**Entity ID / Identifier / Audience URI / Audience Restriction:** https://saml.cal.com
**Response:** Signed
**Assertion Signature:** Signed
**Signature Algorithm:** RSA-SHA256
**Assertion Encryption:** Unencrypted
**Mapping Attributes / Attribute Statements:**
id -> user.id
email -> user.email
firstName -> user.firstName
lastName -> user.lastName

View File

@@ -25,3 +25,14 @@ The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the pl
6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `<CALENDSO URL>/api/integrations/stripepayment/webhook` as webhook for connected applications.
7. Select all `payment_intent` events for the webhook.
8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file.
## Setting up SAML login
1. Set SAML_DATABASE_URL to a postgres database. Please use a different database than the main Cal instance since the migrations are separate for this database. For example `postgresql://postgres:@localhost:5450/cal-saml`
2. Set SAML_ADMINS to a comma separated list of admin emails from where the SAML metadata can be uploaded and configured.
3. Create a SAML application with your Identity Provider (IdP) using the instructions here - [SAML Setup](../docs/saml-setup.md)
4. Remember to configure access to the IdP SAML app for all your users (who need access to Cal).
5. You will need the XML metadata from your IdP later, so keep it accessible.
6. Log in to one of the admin accounts configured in SAML_ADMINS and then navigate to Settings -> Security.
7. You should see a SAML configuration section, copy and paste the XML metadata from step 5 and click on Save.
8. Your provisioned users can now log into Cal using SAML.

View File

@@ -0,0 +1,35 @@
import dayjs from "dayjs";
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale";
import { useMeQuery } from "@components/Shell";
import Button from "@components/ui/Button";
const TrialBanner = () => {
const { t } = useLocale();
const query = useMeQuery();
const user = query.data;
if (!user || user.plan !== "TRIAL") return null;
const trialDaysLeft = dayjs(user.createdDate)
.add(TRIAL_LIMIT_DAYS + 1, "day")
.diff(dayjs(), "day");
return (
<div
className="hidden p-4 m-4 text-sm font-medium text-center text-gray-600 bg-yellow-200 rounded-md sm:block"
data-testid="trial-banner">
<div className="mb-2 text-left">{t("trial_days_left", { days: trialDaysLeft })}</div>
<Button
href="/api/upgrade"
color="minimal"
className="justify-center w-full border-2 border-gray-600 hover:bg-yellow-100">
{t("upgrade_now")}
</Button>
</div>
);
};
export default TrialBanner;

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState, useRef } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { Alert } from "@components/ui/Alert";
import Badge from "@components/ui/Badge";
import Button from "@components/ui/Button";
export default function SAMLConfiguration({
teamsView,
teamId,
}: {
teamsView: boolean;
teamId: null | undefined | number;
}) {
const [isSAMLLoginEnabled, setIsSAMLLoginEnabled] = useState(false);
const [samlConfig, setSAMLConfig] = useState<string | null>(null);
const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]);
useEffect(() => {
const data = query.data;
setIsSAMLLoginEnabled(data?.isSAMLLoginEnabled ?? false);
setSAMLConfig(data?.provider ?? null);
}, [query.data]);
const mutation = trpc.useMutation("viewer.updateSAMLConfig", {
onSuccess: (data: { provider: string | undefined }) => {
showToast(t("saml_config_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
setSAMLConfig(data?.provider ?? null);
samlConfigRef.current.value = "";
},
onError: () => {
setHasErrors(true);
setErrorMessage(t("saml_configuration_update_failed"));
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
},
});
const deleteMutation = trpc.useMutation("viewer.deleteSAMLConfig", {
onSuccess: () => {
showToast(t("saml_config_deleted_successfully"), "success");
setHasErrors(false); // dismiss any open errors
setSAMLConfig(null);
samlConfigRef.current.value = "";
},
onError: () => {
setHasErrors(true);
setErrorMessage(t("saml_configuration_delete_failed"));
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
},
});
const samlConfigRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
async function updateSAMLConfigHandler(event: React.FormEvent<HTMLElement>) {
event.preventDefault();
const rawMetadata = samlConfigRef.current.value;
mutation.mutate({
rawMetadata: rawMetadata,
teamId,
});
}
async function deleteSAMLConfigHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
event.preventDefault();
deleteMutation.mutate({
teamId,
});
}
const { t } = useLocale();
return (
<>
<hr className="mt-8" />
{isSAMLLoginEnabled ? (
<>
<div className="mt-6">
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">
{t("saml_configuration")}
<Badge className="text-xs ml-2" variant={samlConfig ? "success" : "gray"}>
{samlConfig ? t("enabled") : t("disabled")}
</Badge>
{samlConfig ? (
<>
<Badge className="text-xs ml-2" variant={"success"}>
{samlConfig ? samlConfig : ""}
</Badge>
</>
) : null}
</h2>
</div>
{samlConfig ? (
<div className="mt-2 flex">
<Dialog>
<DialogTrigger asChild>
<Button
color="warn"
type="button"
onClick={(e) => {
e.stopPropagation();
}}>
{t("delete_saml_configuration")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("delete_saml_configuration")}
confirmBtnText={t("confirm_delete_saml_configuration")}
cancelBtnText={t("cancel")}
onConfirm={deleteSAMLConfigHandler}>
{t("delete_saml_configuration_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
) : (
<p className="mt-1 text-sm text-gray-500">{!samlConfig ? t("saml_not_configured_yet") : ""}</p>
)}
<p className="mt-1 text-sm text-gray-500">{t("saml_configuration_description")}</p>
<form className="mt-3 divide-y divide-gray-200 lg:col-span-9" onSubmit={updateSAMLConfigHandler}>
{hasErrors && <Alert severity="error" title={errorMessage} />}
<textarea
data-testid="saml_config"
ref={samlConfigRef}
name="saml_config"
id="saml_config"
required={true}
rows={10}
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
placeholder={t("saml_configuration_placeholder")}
/>
<div className="flex justify-end py-8">
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("save")}
</button>
</div>
<hr className="mt-4" />
</form>
</>
) : null}
</>
);
}

View File

@@ -1,9 +1,8 @@
import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { StripeCardElementChangeEvent } from "@stripe/stripe-js";
import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js";
import stripejs, { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js";
import { useRouter } from "next/router";
import { stringify } from "querystring";
import React, { useState } from "react";
import { SyntheticEvent } from "react";
import React, { SyntheticEvent, useEffect, useState } from "react";
import { PaymentData } from "@ee/lib/stripe/server";
@@ -12,10 +11,10 @@ import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button";
const CARD_OPTIONS = {
const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
iconStyle: "solid" as const,
classes: {
base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-brand dark:text-white dark:border-gray-900 focus-within:ring-black focus-within:border-brand sm:text-sm",
base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-black focus-within:ring-black focus-within:border-black sm:text-sm",
},
style: {
base: {
@@ -29,7 +28,7 @@ const CARD_OPTIONS = {
},
},
},
};
} as const;
type Props = {
payment: {
@@ -47,18 +46,23 @@ type States =
| { status: "ok" };
export default function PaymentComponent(props: Props) {
const { t } = useLocale();
const { t, i18n } = useLocale();
const router = useRouter();
const { name, date } = router.query;
const [state, setState] = useState<States>({ status: "idle" });
const stripe = useStripe();
const elements = useElements();
const { isDarkMode } = useDarkMode();
useEffect(() => {
elements?.update({ locale: i18n.language as StripeElementLocale });
}, [elements, i18n.language]);
if (isDarkMode) {
CARD_OPTIONS.style.base.color = "#fff";
CARD_OPTIONS.style.base.iconColor = "#fff";
CARD_OPTIONS.style.base["::placeholder"].color = "#fff";
CARD_OPTIONS.style!.base!.color = "#fff";
CARD_OPTIONS.style!.base!.iconColor = "#fff";
CARD_OPTIONS.style!.base!["::placeholder"]!.color = "#fff";
}
const handleChange = async (event: StripeCardElementChangeEvent) => {

View File

@@ -0,0 +1,95 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React, { useState, useEffect } from "react";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar";
import { DatePicker } from "@components/ui/form/DatePicker";
import Select from "@components/ui/form/Select";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
dayjs.extend(utc);
interface Props {
team?: inferQueryOutput<"viewer.teams.get">;
member?: inferQueryOutput<"viewer.teams.get">["members"][number];
}
export default function TeamAvailabilityModal(props: Props) {
const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
);
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
}, [utils, selectedTimeZone, selectedDate]);
return (
<div className="flex flex-row max-h-[500px] min-h-[500px] space-x-8">
<div className="w-64 p-5 pr-0 space-y-5 min-w-64">
<div className="flex">
<Avatar
imageSrc={getPlaceholderAvatar(props.member?.avatar, props.member?.name as string)}
alt={props.member?.name || ""}
className="rounded-full w-14 h-14"
/>
<div className="inline-block pt-1 ml-3">
<span className="text-lg font-bold text-neutral-700">{props.member?.name}</span>
<span className="block -mt-1 text-sm text-gray-400">{props.member?.email}</span>
</div>
</div>
<div className="flex flex-col">
<span className="font-bold text-gray-600">Date</span>
<DatePicker
date={selectedDate.toDate()}
onDatesChange={(newDate) => {
setSelectedDate(dayjs(newDate));
}}
/>
</div>
<div>
<span className="font-bold text-gray-600">Timezone</span>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
classNamePrefix="react-select"
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
/>
</div>
<div>
<span className="font-bold text-gray-600">Slot Length</span>
<Select
options={[
{ value: 15, label: "15 minutes" },
{ value: 30, label: "30 minutes" },
{ value: 60, label: "60 minutes" },
]}
isSearchable={false}
classNamePrefix="react-select"
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={{ value: frequency, label: `${frequency} minutes` }}
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/>
</div>
</div>
{props.team && props.member && (
<TeamAvailabilityTimes
className="overflow-scroll"
teamId={props.team.id}
memberId={props.member.id}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import dayjs from "dayjs";
import React, { useState, useEffect, CSSProperties } from "react";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar";
import { DatePicker } from "@components/ui/form/DatePicker";
import Select from "@components/ui/form/Select";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
interface Props {
team?: inferQueryOutput<"viewer.teams.get">;
}
export default function TeamAvailabilityScreen(props: Props) {
const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
);
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTimeZone, selectedDate]);
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
const member = props.team?.members?.[index];
if (!member) return <></>;
return (
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
<TeamAvailabilityTimes
teamId={props.team?.id as number}
memberId={member.id}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}
HeaderComponent={
<div className="flex items-center mb-6">
<Avatar
imageSrc={getPlaceholderAvatar(member?.avatar, member?.name as string)}
alt={member?.name || ""}
className="w-10 h-10 mt-1 rounded-full min-w-10 min-h-10"
/>
<div className="inline-block pt-1 ml-3 overflow-hidden">
<span className="text-lg font-bold truncate text-neutral-700">{member?.name}</span>
<span className="block -mt-1 text-sm text-gray-400 truncate">{member?.email}</span>
</div>
</div>
}
/>
</div>
);
};
return (
<div className="flex flex-col flex-1 bg-white border rounded-sm border-neutral-200">
<div className="flex w-full p-4 space-x-5 border-b border-gray-200">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-700">Date</span>
<DatePicker
date={selectedDate.toDate()}
className="p-1.5"
onDatesChange={(newDate) => {
setSelectedDate(dayjs(newDate));
}}
/>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-700">Timezone</span>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
classNamePrefix="react-select"
className="w-full border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
/>
</div>
<div className="hidden sm:block">
<span className="text-sm font-medium text-neutral-700">Slot Length</span>
<Select
options={[
{ value: 15, label: "15 minutes" },
{ value: 30, label: "30 minutes" },
{ value: 60, label: "60 minutes" },
]}
isSearchable={false}
classNamePrefix="react-select"
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={{ value: frequency, label: `${frequency} minutes` }}
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/>
</div>
</div>
<div className="flex flex-1 h-full">
<AutoSizer>
{({ height, width }) => (
<List
itemSize={240}
itemCount={props.team?.members?.length ?? 0}
className="List"
height={height}
layout="horizontal"
width={width}>
{Item}
</List>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import classNames from "classnames";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import React from "react";
import { ITimezone } from "react-timezone-select";
import getSlots from "@lib/slots";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
interface Props {
teamId: number;
memberId: number;
selectedDate: Dayjs;
selectedTimeZone: ITimezone;
frequency: number;
HeaderComponent?: React.ReactNode;
className?: string;
}
dayjs.extend(utc);
export default function TeamAvailabilityTimes(props: Props) {
const { data, isLoading } = trpc.useQuery(
[
"viewer.teams.getMemberAvailability",
{
teamId: props.teamId,
memberId: props.memberId,
dateFrom: props.selectedDate.toString(),
dateTo: props.selectedDate.add(1, "day").toString(),
timezone: `${props.selectedTimeZone.toString()}`,
},
],
{
refetchOnWindowFocus: false,
}
);
const times = !isLoading
? getSlots({
frequency: props.frequency,
inviteeDate: props.selectedDate,
workingHours: data?.workingHours || [],
minimumBookingNotice: 0,
})
: [];
return (
<div className={classNames("flex-grow p-5 pl-0 min-w-60", props.className)}>
{props.HeaderComponent}
{isLoading && times.length === 0 && <Loader />}
{!isLoading && times.length === 0 && (
<div className="flex flex-col items-center justify-center pt-4">
<span className="text-sm text-gray-500">No Available Slots</span>
</div>
)}
{times.map((time) => (
<div key={time.format()} className="flex flex-row items-center">
<a
className="flex-grow block py-2 mb-2 mr-3 font-medium text-center bg-white border rounded-sm min-w-48 dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border-brand dark:border-transparent hover:bg-brand hover:text-brandcontrast dark:hover:border-black dark:hover:text-white dark:hover:bg-black"
data-testid="time">
{time.format("HH:mm")}
</a>
</div>
))}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { loadStripe, Stripe } from "@stripe/stripe-js";
import { Stripe } from "@stripe/stripe-js";
import { loadStripe } from "@stripe/stripe-js/pure";
import { stringify } from "querystring";
import { Maybe } from "@trpc/server";

View File

@@ -1,16 +1,20 @@
import { PaymentType } from "@prisma/client";
import { PaymentType, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { JsonValue } from "type-fest";
import { v4 as uuidv4 } from "uuid";
import { CalendarEvent } from "@lib/calendarClient";
import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail";
import EventPaymentMail from "@lib/emails/EventPaymentMail";
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager";
import { getErrorFromUnknown } from "@lib/errors";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { createPaymentLink } from "./client";
export type PaymentInfo = {
link?: string | null;
reason?: string | null;
id?: string | null;
};
export type PaymentData = Stripe.Response<Stripe.PaymentIntent> & {
stripe_publishable_key: string;
stripeAccount: string;
@@ -34,7 +38,7 @@ export async function handlePayment(
price: number;
currency: string;
},
stripeCredential: { key: JsonValue },
stripeCredential: { key: Prisma.JsonValue },
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
@@ -60,7 +64,11 @@ export async function handlePayment(
data: {
type: PaymentType.STRIPE,
uid: uuidv4(),
bookingId: booking.id,
booking: {
connect: {
id: booking.id,
},
},
amount: selectedEventType.price,
fee: paymentFee,
currency: selectedEventType.currency,
@@ -69,20 +77,21 @@ export async function handlePayment(
data: Object.assign({}, paymentIntent, {
stripe_publishable_key,
stripeAccount: stripe_user_id,
}) as PaymentData as unknown as JsonValue,
}) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue,
externalId: paymentIntent.id,
},
});
const mail = new EventPaymentMail(
createPaymentLink({
paymentUid: payment.uid,
name: booking.user?.name,
date: booking.startTime.toISOString(),
}),
evt
);
await mail.sendEmail();
await sendAwaitingPaymentEmail({
...evt,
paymentInfo: {
link: createPaymentLink({
paymentUid: payment.uid,
name: booking.user?.name,
date: booking.startTime.toISOString(),
}),
},
});
return payment;
}
@@ -97,7 +106,7 @@ export async function refund(
success: boolean;
refunded: boolean;
externalId: string;
data: JsonValue;
data: Prisma.JsonValue;
type: PaymentType;
}[];
},
@@ -107,7 +116,7 @@ export async function refund(
const payment = booking.payment.find((e) => e.success && !e.refunded);
if (!payment) return;
if (payment.type != PaymentType.STRIPE) {
if (payment.type !== PaymentType.STRIPE) {
await handleRefundError({
event: calEvent,
reason: "cannot refund non Stripe payment",
@@ -153,11 +162,51 @@ export async function refund(
async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
try {
await new EventOrganizerRefundFailedMail(opts.event, opts.reason, opts.paymentId).sendEmail();
} catch (e) {
console.error("Error while sending refund error email", e);
await sendOrganizerPaymentRefundFailedEmail({
...opts.event,
paymentInfo: { reason: opts.reason, id: opts.paymentId },
});
}
const userType = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
metadata: true,
},
});
type UserType = Prisma.UserGetPayload<typeof userType>;
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
} else {
/* We fallback to finding the customer by email (which is not optimal) */
const customersReponse = await stripe.customers.list({
email: user.email,
limit: 1,
});
if (customersReponse.data[0]?.id) {
customerId = customersReponse.data[0].id;
}
}
return customerId;
}
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
const customerId = await getStripeCustomerId(user);
if (!customerId) {
console.warn("No stripe customer found for user:" + user.email);
return null;
}
//delete stripe customer
const deletedCustomer = await stripe.customers.del(customerId);
return deletedCustomer.id;
}
export default stripe;

View File

@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import { getSession } from "@lib/auth";
import { BASE_URL } from "@lib/config/constants";
import prisma from "@lib/prisma";
const client_id = process.env.STRIPE_CLIENT_ID;
@@ -27,7 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const redirect_uri = encodeURI(process.env.BASE_URL + "/api/integrations/stripepayment/callback");
const redirect_uri = encodeURI(BASE_URL + "/api/integrations/stripepayment/callback");
const stripeConnectParams = {
client_id,
scope: "read_write",

View File

@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import stripe from "@ee/lib/stripe/server";
import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
@@ -23,6 +23,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
email: true,
name: true,
metadata: true,
},
});
@@ -31,26 +32,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
message: "User email not found",
});
/**
* TODO: We need to find a better way to get our users customer id from Stripe,
* since the email is not an unique field in Stripe and we don't save them
* in our DB as of now.
**/
const customersReponse = await stripe.customers.list({
email: user?.email || "",
limit: 1,
});
const customerId = await getStripeCustomerId(user);
const [customer] = customersReponse.data;
if (!customer?.id)
if (!customerId)
return res.status(404).json({
message: "Stripe customer id not found",
});
const return_url = `${process.env.BASE_URL}/settings/billing`;
const stripeSession = await stripe.billingPortal.sessions.create({
customer: customer.id,
customer: customerId,
return_url,
});

View File

@@ -4,10 +4,11 @@ import Stripe from "stripe";
import stripe from "@ee/lib/stripe/server";
import { CalendarEvent } from "@lib/calendarClient";
import { HttpError } from "@lib/core/http/error";
import { IS_PRODUCTION } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error";
import { getErrorFromUnknown } from "@lib/errors";
import EventManager from "@lib/events/EventManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { Ensure } from "@lib/types/utils";
@@ -30,6 +31,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
booking: {
update: {
paid: true,
confirmed: true,
},
},
},
@@ -56,6 +58,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
@@ -90,12 +93,12 @@ async function handlePaymentSuccess(event: Stripe.Event) {
if (booking.location) evt.location = booking.location;
if (booking.confirmed) {
const eventManager = new EventManager(user.credentials);
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
await prisma.booking.update({
where: {
id: payment.bookingId,
id: booking.id,
},
data: {
references: {
@@ -104,49 +107,34 @@ async function handlePaymentSuccess(event: Stripe.Event) {
},
});
}
throw new HttpCode({
statusCode: 200,
message: `Booking with id '${booking.id}' was paid and confirmed.`,
});
}
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"payment_intent.succeeded": handlePaymentSuccess,
"customer.subscription.deleted": async (event) => {
const object = event.data.object as Stripe.Subscription;
const customerId = typeof object.customer === "string" ? object.customer : object.customer.id;
const customer = (await stripe.customers.retrieve(customerId)) as Stripe.Customer;
if (typeof customer.email !== "string") {
throw new Error(`Couldn't find customer email for ${customerId}`);
}
await prisma.user.update({
where: {
email: customer.email,
},
data: {
plan: "FREE",
},
});
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpError({ statusCode: 405, message: "Method Not Allowed" });
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
throw new HttpError({ statusCode: 400, message: "Missing stripe-signature" });
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new HttpError({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
// console.log("payload", payload);
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
@@ -154,14 +142,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (handler) {
await handler(event);
} else {
console.warn(`Unhandled Stripe Webhook event type ${event.type}`);
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: process.env.NODE_ENV === "production" ? undefined : err.stack,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}

View File

@@ -0,0 +1,66 @@
import { useRouter } from "next/router";
import { useMemo, useState } from "react";
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
import Shell, { useMeQuery } from "@components/Shell";
import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar";
export function TeamAvailabilityPage() {
const router = useRouter();
const [errorMessage, setErrorMessage] = useState("");
const me = useMeQuery();
const isFreeUser = me.data?.plan === "FREE";
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
refetchOnWindowFocus: false,
onError: (e) => {
setErrorMessage(e.message);
},
});
// prevent unnecessary re-renders due to shell queries
const TeamAvailability = useMemo(() => {
return <TeamAvailabilityScreen team={team} />;
}, [team]);
return (
<Shell
backPath={!errorMessage ? `/settings/teams/${team?.id}` : undefined}
heading={!isFreeUser && team?.name}
flexChildrenContainer
subtitle={team && !isFreeUser && "Your team's availability at a glance"}
HeadingLeftIcon={
team &&
!isFreeUser && (
<Avatar
size={12}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="mt-1"
/>
)
}>
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
{isLoading && <Loader />}
{isFreeUser ? (
<Alert
className="-mt-24 border"
severity="warning"
title="This is a pro feature. Upgrade to pro to see your team's availability."
/>
) : (
TeamAvailability
)}
</Shell>
);
}
export default TeamAvailabilityPage;

View File

@@ -14,6 +14,7 @@ const config: Config.InitialOptions = {
"^@components(.*)$": "<rootDir>/components$1",
"^@lib(.*)$": "<rootDir>/lib$1",
"^@server(.*)$": "<rootDir>/server$1",
"^@ee(.*)$": "<rootDir>/ee$1",
},
};

View File

@@ -1,34 +0,0 @@
const opts = {
// launch headless on CI, in browser locally
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
collectCoverage: false, // not possible in Next.js 12
executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH,
};
console.log("⚙️ Playwright options:", JSON.stringify(opts, null, 4));
module.exports = {
verbose: true,
preset: "jest-playwright-preset",
transform: {
"^.+\\.ts$": "ts-jest",
},
testMatch: ["<rootDir>/playwright/**/*(*.)@(spec|test).[jt]s?(x)"],
testEnvironmentOptions: {
"jest-playwright": {
browsers: ["chromium" /*, 'firefox', 'webkit'*/],
exitOnPageError: false,
launchType: "LAUNCH",
launchOptions: {
headless: opts.headless,
executablePath: opts.executablePath,
},
contextOptions: {
recordVideo: {
dir: "playwright/videos",
},
},
collectCoverage: opts.collectCoverage,
},
},
};

View File

@@ -1,151 +1,114 @@
import { Person } from "ics";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getIntegrationName } from "@lib/integrations";
import { CalendarEvent } from "./calendarClient";
import { stripHtml } from "./emails/helpers";
import { BASE_URL } from "./config/constants";
import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar";
const translator = short();
export default class CalEventParser {
protected calEvent: CalendarEvent;
// The odd indentation in this file is necessary because otherwise the leading tabs will be applied into the event description.
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
}
export const getWhat = (calEvent: CalendarEvent) => {
return `
${calEvent.language("what")}:
${calEvent.type}
`;
};
/**
* Returns a link to reschedule the given booking.
*/
public getRescheduleLink(): string {
return process.env.BASE_URL + "/reschedule/" + this.getUid();
}
export const getWhen = (calEvent: CalendarEvent) => {
return `
${calEvent.language("invitee_timezone")}:
${calEvent.attendees[0].timeZone}
`;
};
/**
* Returns a link to cancel the given booking.
*/
public getCancelLink(): string {
return process.env.BASE_URL + "/cancel/" + this.getUid();
}
/**
* Returns a unique identifier for the given calendar event.
*/
public getUid(): string {
return this.calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
}
/**
* Returns a footer section with links to change the event (as HTML).
*/
public getChangeEventFooterHtml(): string {
return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
"need_to_make_a_change"
)} <a href="${this.getCancelLink()}" style="color: #161e2e;">${this.calEvent.language(
"cancel"
)}</a> ${this.calEvent
.language("or")
.toLowerCase()} <a href="${this.getRescheduleLink()}" style="color: #161e2e;">${this.calEvent.language(
"reschedule"
)}</a></p>`;
}
/**
* Returns a footer section with links to change the event (as plain text).
*/
public getChangeEventFooter(): string {
return stripHtml(this.getChangeEventFooterHtml());
}
/**
* Returns an extended description with all important information (as HTML).
*
* @protected
*/
public getRichDescriptionHtml(): string {
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
return (
`
<strong>${this.calEvent.language("event_type")}:</strong><br />${this.calEvent.type}<br />
<strong>${this.calEvent.language("invitee_email")}:</strong><br /><a href="mailto:${
this.calEvent.attendees[0].email
}">${this.calEvent.attendees[0].email}</a><br />
` +
(this.getLocation()
? `<strong>${this.calEvent.language("location")}:</strong><br />${this.getLocation()}<br />
`
: "") +
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />${
this.calEvent.attendees[0].timeZone
}<br />
<strong>${this.calEvent.language("additional_notes")}:</strong><br />${this.getDescriptionText()}<br />` +
this.getChangeEventFooterHtml()
);
}
/**
* Conditionally returns the event's location. When VideoCallData is set,
* it returns the meeting url. Otherwise, the regular location is returned.
* For Daily video calls returns the direct link
* @protected
*/
protected getLocation(): string | null | undefined {
const isDaily = this.calEvent.location === "integrations:daily";
if (this.calEvent.videoCallData) {
return this.calEvent.videoCallData.url;
}
if (isDaily) {
return process.env.BASE_URL + "/call/" + this.getUid();
}
return this.calEvent.location;
}
/**
* Returns the event's description text. If VideoCallData is set, it prepends
* some video call information before the text as well.
*
* @protected
*/
protected getDescriptionText(): string | null | undefined {
if (this.calEvent.videoCallData) {
export const getWho = (calEvent: CalendarEvent) => {
const attendees = calEvent.attendees
.map((attendee) => {
return `
${this.calEvent.language("integration_meeting_id", {
integrationName: getIntegrationName(this.calEvent.videoCallData.type),
meetingId: this.calEvent.videoCallData.id,
})}
${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password}
${this.calEvent.description}`;
}
return this.calEvent.description;
${attendee?.name || calEvent.language("guest")}
${attendee.email}
`;
})
.join("");
const organizer = `
${calEvent.organizer.name} - ${calEvent.language("organizer")}
${calEvent.organizer.email}
`;
return `
${calEvent.language("who")}:
${organizer + attendees}
`;
};
export const getAdditionalNotes = (calEvent: CalendarEvent) => {
return `
${calEvent.language("additional_notes")}:
${calEvent.description}
`;
};
export const getLocation = (calEvent: CalendarEvent) => {
let providerName = calEvent.location ? getIntegrationName(calEvent.location) : "";
if (calEvent.location && calEvent.location.includes("integrations:")) {
const location = calEvent.location.split(":")[1];
providerName = location[0].toUpperCase() + location.slice(1);
}
/**
* Returns an extended description with all important information (as plain text).
*
* @protected
*/
public getRichDescription(): string {
return stripHtml(this.getRichDescriptionHtml());
if (calEvent.videoCallData) {
return calEvent.videoCallData.url;
}
/**
* Returns a calendar event with rich description.
*/
public asRichEvent(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescriptionHtml();
eventCopy.location = this.getLocation();
return eventCopy;
if (calEvent.additionInformation?.hangoutLink) {
return calEvent.additionInformation.hangoutLink;
}
/**
* Returns a calendar event with rich description as plain text.
*/
public asRichEventPlain(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescription();
eventCopy.location = this.getLocation();
return eventCopy;
return providerName || calEvent.location || "";
};
export const getManageLink = (calEvent: CalendarEvent) => {
return `
${calEvent.language("need_to_reschedule_or_cancel")}
${getCancelLink(calEvent)}
`;
};
export const getUid = (calEvent: CalendarEvent): string => {
return calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
};
export const getCancelLink = (calEvent: CalendarEvent): string => {
return BASE_URL + "/cancel/" + getUid(calEvent);
};
export const getRichDescription = (calEvent: CalendarEvent, attendee?: Person) => {
// Only the original attendee can make changes to the event
// Guests cannot
if (attendee && attendee === calEvent.attendees[0]) {
return `
${getWhat(calEvent)}
${getWhen(calEvent)}
${getWho(calEvent)}
${calEvent.language("where")}:
${getLocation(calEvent)}
${getAdditionalNotes(calEvent)}
`.trim();
}
}
return `
${getWhat(calEvent)}
${getWhen(calEvent)}
${getWho(calEvent)}
${calEvent.language("where")}:
${getLocation(calEvent)}
${getAdditionalNotes(calEvent)}
${getManageLink(calEvent)}
`.trim();
};

View File

@@ -1,5 +1,5 @@
import { IdProvider } from "@radix-ui/react-id";
import { Provider } from "next-auth/client";
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import type { AppProps as NextAppProps } from "next/app";
import React, { ComponentProps, ReactNode } from "react";
@@ -44,9 +44,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
<TelemetryProvider value={createTelemetryClient()}>
<IdProvider>
<DynamicIntercomProvider>
<Provider session={session || undefined}>
<SessionProvider session={session || undefined}>
<CustomI18nextProvider {...props}>{props.children}</CustomI18nextProvider>
</Provider>
</SessionProvider>
</DynamicIntercomProvider>
</IdProvider>
</TelemetryProvider>

View File

@@ -1,6 +1,7 @@
import { IdentityProvider } from "@prisma/client";
import { compare, hash } from "bcryptjs";
import { DefaultSession } from "next-auth";
import { getSession as getSessionInner, GetSessionOptions } from "next-auth/client";
import { Session } from "next-auth";
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
@@ -12,16 +13,7 @@ export async function verifyPassword(password: string, hashedPassword: string) {
return isValid;
}
type DefaultSessionUser = NonNullable<DefaultSession["user"]>;
type CalendsoSessionUser = DefaultSessionUser & {
id: number;
username: string;
};
export interface Session extends DefaultSession {
user?: CalendsoSessionUser;
}
export async function getSession(options: GetSessionOptions): Promise<Session | null> {
export async function getSession(options: GetSessionParams): Promise<Session | null> {
const session = await getSessionInner(options);
// that these are equal are ensured in `[...nextauth]`'s callback
@@ -39,4 +31,11 @@ export enum ErrorCode {
IncorrectTwoFactorCode = "incorrect-two-factor-code",
InternalServerError = "internal-server-error",
NewPasswordMatchesOld = "new-password-matches-old",
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
}
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
[IdentityProvider.CAL]: "Cal",
[IdentityProvider.GOOGLE]: "Google",
[IdentityProvider.SAML]: "SAML",
};

View File

@@ -1,11 +1,18 @@
import { Availability } from "@prisma/client";
import dayjs, { ConfigType } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { Schedule, TimeRange } from "./types/schedule";
import { Schedule, TimeRange, WorkingHours } from "./types/schedule";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
// sets the desired time in current date, needs to be current date for proper DST translation
export const defaultDayRange: TimeRange = {
start: new Date(new Date().setHours(9, 0, 0, 0)),
end: new Date(new Date().setHours(17, 0, 0, 0)),
start: new Date(new Date().setUTCHours(9, 0, 0, 0)),
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
};
export const DEFAULT_SCHEDULE: Schedule = [
@@ -45,3 +52,75 @@ export function getAvailabilityFromSchedule(schedule: Schedule): Availability[]
return availability;
}, [] as Availability[]);
}
export const MINUTES_IN_DAY = 60 * 24;
export const MINUTES_DAY_END = MINUTES_IN_DAY - 1;
export const MINUTES_DAY_START = 0;
/**
* Allows "casting" availability (days, startTime, endTime) given in UTC to a timeZone or utcOffset
*/
export function getWorkingHours(
relativeTimeUnit: {
timeZone?: string;
utcOffset?: number;
},
availability: { days: number[]; startTime: ConfigType; endTime: ConfigType }[]
) {
// clearly bail when availability is not set, set everything available.
if (!availability.length) {
return [
{
days: [0, 1, 2, 3, 4, 5, 6],
// shorthand for: dayjs().startOf("day").tz(timeZone).diff(dayjs.utc().startOf("day"), "minutes")
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END,
},
];
}
const utcOffset = relativeTimeUnit.utcOffset ?? dayjs().tz(relativeTimeUnit.timeZone).utcOffset();
const workingHours = availability.reduce((workingHours: WorkingHours[], schedule) => {
// Get times localised to the given utcOffset/timeZone
const startTime =
dayjs.utc(schedule.startTime).get("hour") * 60 +
dayjs.utc(schedule.startTime).get("minute") -
utcOffset;
const endTime =
dayjs.utc(schedule.endTime).get("hour") * 60 + dayjs.utc(schedule.endTime).get("minute") - utcOffset;
// add to working hours, keeping startTime and endTimes between bounds (0-1439)
const sameDayStartTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, startTime));
const sameDayEndTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, endTime));
if (sameDayStartTime !== sameDayEndTime) {
workingHours.push({
days: schedule.days,
startTime: sameDayStartTime,
endTime: sameDayEndTime,
});
}
// check for overflow to the previous day
if (startTime < MINUTES_DAY_START || endTime < MINUTES_DAY_START) {
workingHours.push({
days: schedule.days.map((day) => day - 1),
startTime: startTime + MINUTES_IN_DAY,
endTime: Math.min(endTime + MINUTES_IN_DAY, MINUTES_DAY_END),
});
}
// else, check for overflow in the next day
else if (startTime > MINUTES_DAY_END || endTime > MINUTES_DAY_END) {
workingHours.push({
days: schedule.days.map((day) => day + 1),
startTime: Math.max(startTime - MINUTES_IN_DAY, MINUTES_DAY_START),
endTime: endTime - MINUTES_IN_DAY,
});
}
return workingHours;
}, []);
workingHours.sort((a, b) => a.startTime - b.startTime);
return workingHours;
}

View File

@@ -1,748 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { Credential, Prisma, SelectedCalendar } from "@prisma/client";
import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
import { Auth, calendar_v3, google } from "googleapis";
import { TFunction } from "next-i18next";
import { Event, EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
import { VideoCallData } from "@lib/videoClient";
import CalEventParser from "./CalEventParser";
import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter";
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
import prisma from "./prisma";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
const googleAuth = (credential: Credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web;
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
const googleCredentials = credential.key as Auth.Credentials;
myGoogleAuth.setCredentials(googleCredentials);
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
const isExpired = () => myGoogleAuth.isTokenExpiring();
const refreshAccessToken = () =>
myGoogleAuth
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
.refreshToken(googleCredentials.refresh_token)
.then((res: GetTokenResponse) => {
const token = res.res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: googleCredentials as Prisma.InputJsonValue,
},
})
.then(() => {
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
});
})
.catch((err) => {
console.error("Error refreshing google token", err);
return myGoogleAuth;
});
return {
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
};
};
function handleErrorsJson(response: Response) {
if (!response.ok) {
response.json().then((e) => console.error("O365 Error", e));
throw Error(response.statusText);
}
return response.json();
}
function handleErrorsRaw(response: Response) {
if (!response.ok) {
response.text().then((e) => console.error("O365 Error", e));
throw Error(response.statusText);
}
return response.text();
}
type O365AuthCredentials = {
expiry_date: number;
access_token: string;
refresh_token: string;
};
const o365Auth = (credential: Credential) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = (refreshToken: string) => {
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
// FIXME types - IDK how to type this TBH
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id: process.env.MS_GRAPH_CLIENT_ID,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret: process.env.MS_GRAPH_CLIENT_SECRET,
}),
})
.then(handleErrorsJson)
.then((responseBody) => {
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: o365AuthCredentials,
},
})
.then(() => o365AuthCredentials.access_token);
});
};
return {
getToken: () =>
!isExpired(o365AuthCredentials.expiry_date)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
};
};
export type Person = { name: string; email: string; timeZone: string };
export interface EntryPoint {
entryPointType?: string;
uri?: string;
label?: string;
pin?: string;
accessCode?: string;
meetingCode?: string;
passcode?: string;
password?: string;
}
export interface AdditionInformation {
conferenceData?: ConferenceData;
entryPoints?: EntryPoint[];
hangoutLink?: string;
}
export interface CalendarEvent {
type: string;
title: string;
startTime: string;
endTime: string;
description?: string | null;
team?: {
name: string;
members: string[];
};
location?: string | null;
organizer: Person;
attendees: Person[];
conferenceData?: ConferenceData;
language: TFunction;
additionInformation?: AdditionInformation;
/** If this property exist it we can assume it's a reschedule/update */
uid?: string | null;
videoCallData?: VideoCallData;
}
export interface ConferenceData {
createRequest: calendar_v3.Schema$CreateConferenceRequest;
}
export interface IntegrationCalendar extends Partial<SelectedCalendar> {
primary?: boolean;
name?: string;
}
type BufferedBusyTime = { start: string; end: string };
export interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<Event>;
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
deleteEvent(uid: string): Promise<unknown>;
getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<BufferedBusyTime[]>;
listCalendars(): Promise<IntegrationCalendar[]>;
}
const MicrosoftOffice365Calendar = (credential: Credential): CalendarApiAdapter => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {
return {
subject: event.title,
body: {
contentType: "HTML",
content: event.description,
},
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees.map((attendee) => ({
emailAddress: {
address: attendee.email,
name: attendee.name,
},
type: "required",
})),
location: event.location ? { displayName: event.location } : undefined,
};
};
const integrationType = "office365_calendar";
function listCalendars(): Promise<IntegrationCalendar[]> {
return auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendars", {
method: "get",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
})
.then(handleErrorsJson)
.then((responseBody: { value: OfficeCalendar[] }) => {
return responseBody.value.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No Id",
integration: integrationType,
name: cal.name ?? "No calendar name",
primary: cal.isDefaultCalendar ?? false,
};
return calendar;
});
})
);
}
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
const filter = `?startdatetime=${encodeURIComponent(dateFrom)}&enddatetime=${encodeURIComponent(
dateTo
)}`;
return auth
.getToken()
.then((accessToken) => {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId)
.filter(Boolean);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return Promise.resolve([]);
}
return (
selectedCalendarIds.length === 0
? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
).then((ids) => {
const requests = ids.map((calendarId, id) => ({
id,
method: "GET",
headers: {
Prefer: 'outlook.timezone="Etc/GMT"',
},
url: `/me/calendars/${calendarId}/calendarView${filter}`,
}));
type BatchResponse = {
responses: SubResponse[];
};
type SubResponse = {
body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] };
};
return fetch("https://graph.microsoft.com/v1.0/$batch", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({ requests }),
})
.then(handleErrorsJson)
.then((responseBody: BatchResponse) =>
responseBody.responses.reduce(
(acc: BufferedBusyTime[], subResponse) =>
acc.concat(
subResponse.body.value.map((evt) => {
return {
start: evt.start.dateTime + "Z",
end: evt.end.dateTime + "Z",
};
})
),
[]
)
);
});
})
.catch((err) => {
console.log(err);
return Promise.reject([]);
});
},
createEvent: (event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
})
.then(handleErrorsJson)
.then((responseBody) => ({
...responseBody,
disableConfirmationEmail: true,
}))
),
deleteEvent: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
}).then(handleErrorsRaw)
),
updateEvent: (uid: string, event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
listCalendars,
};
};
const GoogleCalendar = (credential: Credential): CalendarApiAdapter => {
const auth = googleAuth(credential);
const integrationType = "google_calendar";
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
(selectedCalendarIds.length === 0
? calendar.calendarList
.list()
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
},
(err, apires) => {
if (err) {
reject(err);
}
// @ts-ignore FIXME
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
}
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
createEvent: (event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = event.location;
}
if (event.conferenceData && event.location === "integrations:google:meet") {
payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: "primary",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err || !event?.data) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
// @ts-ignore FIXME
return resolve(event.data);
}
);
})
),
updateEvent: (uid: string, event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = event.location;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
requestBody: payload,
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
deleteEvent: (uid: string) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
listCalendars: () =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list()
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: integrationType,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
};
return calendar;
}) || []
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
};
};
function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null {
switch (credential.type) {
case "google_calendar":
return GoogleCalendar(credential);
case "office365_calendar":
return MicrosoftOffice365Calendar(credential);
case "caldav_calendar":
// FIXME types wrong & type casting should not be needed
return new CalDavCalendar(credential) as never as CalendarApiAdapter;
case "apple_calendar":
// FIXME types wrong & type casting should not be needed
return new AppleCalendar(credential) as never as CalendarApiAdapter;
}
return null;
}
/**
* @deprecated
*/
const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] =>
withCredentials
.map((cred) => {
switch (cred.type) {
case "google_calendar":
return GoogleCalendar(cred);
case "office365_calendar":
return MicrosoftOffice365Calendar(cred);
case "caldav_calendar":
return new CalDavCalendar(cred);
case "apple_calendar":
return new AppleCalendar(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
})
.flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
const getBusyCalendarTimes = (
withCredentials: Credential[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
) =>
Promise.all(
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
).then((results) => {
return results.reduce((acc, availability) => acc.concat(availability), []);
});
/**
*
* @param withCredentials
* @deprecated
*/
const listCalendars = (withCredentials: Credential[]) =>
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
);
const createEvent = async (
credential: Credential,
calEvent: CalendarEvent,
noMail: boolean | null = false
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const uid: string = parser.getUid();
/*
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
* We need HTML there. Google Calendar understands newlines and Apple Calendar cannot show HTML, so no HTML should
* be used for Google and Apple Calendar.
*/
const richEvent: CalendarEvent = parser.asRichEventPlain();
let success = true;
const creationResult = credential
? await calendars([credential])[0]
.createEvent(richEvent)
.catch((e) => {
log.error("createEvent failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
if (!creationResult) {
return {
type: credential.type,
success,
uid,
originalEvent: calEvent,
};
}
const metadata: AdditionInformation = {};
if (creationResult) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = creationResult.hangoutLink;
metadata.conferenceData = creationResult.conferenceData;
metadata.entryPoints = creationResult.entryPoints;
}
calEvent.additionInformation = metadata;
if (!noMail) {
const organizerMail = new EventOrganizerMail(calEvent);
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
uid,
createdEvent: creationResult,
originalEvent: calEvent,
};
};
const updateEvent = async (
credential: Credential,
calEvent: CalendarEvent,
noMail: boolean | null = false,
bookingRefUid: string | null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const uid = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain();
let success = true;
const updatedResult =
credential && bookingRefUid
? await calendars([credential])[0]
.updateEvent(bookingRefUid, richEvent)
.catch((e) => {
log.error("updateEvent failed", e, calEvent);
success = false;
return undefined;
})
: null;
if (!updatedResult) {
return {
type: credential.type,
success,
uid,
originalEvent: calEvent,
};
}
if (!noMail) {
const organizerMail = new EventOrganizerRescheduledMail(calEvent);
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
uid,
updatedEvent: updatedResult,
originalEvent: calEvent,
};
};
const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
if (credential) {
return calendars([credential])[0].deleteEvent(uid);
}
return Promise.resolve({});
};
export {
getBusyCalendarTimes,
createEvent,
updateEvent,
deleteEvent,
listCalendars,
getCalendarAdapterOrNull,
};

4
lib/config/constants.ts Normal file
View File

@@ -0,0 +1,4 @@
export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_URL}`;
export const WEBSITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://cal.com";
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
export const TRIAL_LIMIT_DAYS = 14;

View File

@@ -45,7 +45,7 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
if (resizeRatio <= 0.75) {
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
return getCroppedImg(canvas.toDataURL("image/jpeg"), {
return getCroppedImg(canvas.toDataURL("image/png"), {
width: canvas.width,
height: canvas.height,
x: 0,
@@ -53,5 +53,5 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
});
}
return canvas.toDataURL("image/jpeg");
return canvas.toDataURL("image/png");
}

View File

@@ -1,172 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import EventMail from "./EventMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventAttendeeMail extends EventMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language(
"your_meeting_has_been_booked"
)}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
<col span="1" style="width: 40%;">
<col span="1" style="width: 60%;">
</colgroup>
<tr>
<td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td>
</tr>
<tr>
<td>${this.calEvent.language("when")}</td>
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
this.calEvent.attendees[0].timeZone
})</td>
</tr>
<tr>
<td>${this.calEvent.language("who")}</td>
<td>
${this.calEvent.team?.name || this.calEvent.organizer.name}<br />
<small>
${this.calEvent.organizer.email && !this.calEvent.team ? this.calEvent.organizer.email : ""}
${this.calEvent.team ? this.calEvent.team.members.join(", ") : ""}
</small>
</td>
</tr>
<tr>
<td>${this.calEvent.language("where")}</td>
<td>${this.getLocation()}</td>
</tr>
<tr>
<td>${this.calEvent.language("notes")}</td>
<td>${this.calEvent.description}</td>
</tr>
</table>
` +
this.getAdditionalBody() +
"<br />" +
`
<hr />
` +
this.getAdditionalFooter() +
`
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.calEvent.additionInformation?.hangoutLink) {
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
}
if (
this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => {
return `
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `${locations}`;
}
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return ``;
}
protected getAdditionalFooter(): string {
return this.parser.getChangeEventFooterHtml();
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: this.getInviteeStart().format("LT dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
/**
* Returns the inviteeStart value used at multiple points.
*
* @private
*/
protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View File

@@ -1,55 +0,0 @@
import EventAttendeeMail from "./EventAttendeeMail";
export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<div>
${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},<br />
<br />
${this.calEvent.language("event_type_has_been_rescheduled_on_time_date", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
time: this.getInviteeStart().format("h:mma"),
timeZone: this.calEvent.attendees[0].timeZone,
date:
`${this.calEvent.language(this.getInviteeStart().format("dddd, ").toLowerCase())}` +
`${this.calEvent.language(this.getInviteeStart().format("LL").toLowerCase())}`,
})}<br />
` +
this.getAdditionalFooter() +
`
</div>
`
);
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("rescheduled_event_type_with_organizer", {
eventType: this.calEvent.type,
organizerName: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
}

View File

@@ -1,118 +0,0 @@
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import CalEventParser from "../CalEventParser";
import { CalendarEvent } from "../calendarClient";
import { serverConfig } from "../serverConfig";
import { stripHtml } from "./helpers";
export default abstract class EventMail {
calEvent: CalendarEvent;
parser: CalEventParser;
/**
* An EventMail always consists of a CalendarEvent
* that stores the data of the event (like date, title, uid etc).
*
* @param calEvent
*/
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
this.parser = new CalEventParser(calEvent);
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected abstract getHtmlRepresentation(): string;
/**
* Returns the email text in a plain text representation
* by stripping off the HTML tags.
*
* @protected
*/
protected getPlainTextRepresentation(): string {
return stripHtml(this.getHtmlRepresentation());
}
/**
* Returns the payload object for the nodemailer.
* @protected
*/
protected abstract getNodeMailerPayload(): Record<string, unknown>;
/**
* Sends the email to the event attendant and returns a Promise.
*/
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
/**
* Gathers the required provider information from the config.
*
* @protected
*/
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
/**
* Can be used to include additional HTML or plain text
* content into the mail body. Leave it to an empty
* string if not desired.
*
* @protected
*/
protected getAdditionalBody(): string {
return "";
}
protected abstract getLocation(): string;
/**
* Prints out the desired information when an error
* occured while sending the mail.
* @param error
* @protected
*/
protected abstract printNodeMailerError(error: Error): void;
/**
* Returns a link to reschedule the given booking.
*
* @protected
*/
protected getRescheduleLink(): string {
return this.parser.getRescheduleLink();
}
/**
* Returns a link to cancel the given booking.
*
* @protected
*/
protected getCancelLink(): string {
return this.parser.getCancelLink();
}
}

View File

@@ -1,234 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent } from "ics";
import { Person } from "@lib/calendarClient";
import EventMail from "./EventMail";
import { stripHtml } from "./helpers";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerMail extends EventMail {
/**
* Returns the instance's event as an iCal event in string representation.
* @protected
*/
protected getiCalEventAsString(): string | undefined {
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)),
startInputType: "utc",
productId: "calendso/ics",
title: this.calEvent.language("organizer_ics_event_title", {
eventType: this.calEvent.type,
attendeeName: this.calEvent.attendees[0].name,
}),
description:
this.calEvent.description +
stripHtml(this.getAdditionalBody()) +
stripHtml(this.getAdditionalFooter()),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
protected getBodyHeader(): string {
return this.calEvent.language("new_event_scheduled");
}
protected getAdditionalFooter(): string {
return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
"need_to_make_a_change"
)} <a href=${process.env.BASE_URL + "/bookings"} style="color: #161e2e;">${this.calEvent.language(
"manage_my_bookings"
)}</a></p>`;
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
${this.getImage()}
<h1 style="font-weight: 500; color: #161e2e;">${this.getBodyHeader()}</h1>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
<col span="1" style="width: 40%;">
<col span="1" style="width: 60%;">
</colgroup>
<tr>
<td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td>
</tr>
<tr>
<td>${this.calEvent.language("when")}</td>
<td>${this.getOrganizerStart().format("dddd, LL")}<br>${this.getOrganizerStart().format("h:mma")} (${
this.calEvent.organizer.timeZone
})</td>
</tr>
<tr>
<td>${this.calEvent.language("who")}</td>
<td>${this.calEvent.attendees[0].name}<br /><small><a href="mailto:${
this.calEvent.attendees[0].email
}">${this.calEvent.attendees[0].email}</a></small></td>
</tr>
<tr>
<td>${this.calEvent.language("where")}</td>
<td>${this.getLocation()}</td>
</tr>
<tr>
<td>${this.calEvent.language("notes")}</td>
<td>${this.calEvent.description}</td>
</tr>
</table>
` +
this.getAdditionalBody() +
"<br />" +
`
<hr />
` +
this.getAdditionalFooter() +
`
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.calEvent.additionInformation?.hangoutLink) {
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
}
if (
this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => {
return `
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `${locations}`;
}
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return ``;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: this.getSubject(),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected getSubject(): string {
return this.calEvent.language("new_event_subject", {
attendeeName: this.calEvent.attendees[0].name,
date: this.getOrganizerStart().format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
/**
* Returns the organizerStart value used at multiple points.
*
* @private
*/
protected getOrganizerStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
}
}

View File

@@ -1,71 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { CalendarEvent } from "@lib/calendarClient";
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerRefundFailedMail extends EventOrganizerMail {
reason: string;
paymentId: string;
constructor(calEvent: CalendarEvent, reason: string, paymentId: string) {
super(calEvent);
this.reason = reason;
this.paymentId = paymentId;
}
protected getBodyHeader(): string {
return this.calEvent.language("a_refund_failed");
}
protected getBodyText(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `${this.calEvent.language("refund_failed", {
eventType: this.calEvent.type,
userName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
})} ${this.calEvent.language("check_with_provider_and_user", {
userName: this.calEvent.attendees[0].name,
})}<br>${this.calEvent.language("error_message", { errorMessage: this.reason })}<br>PaymentId: '${
this.paymentId
}'`;
}
protected getAdditionalBody(): string {
return "";
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #9b0125"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return this.calEvent.language("refund_failed_subject", {
userName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
}

View File

@@ -1,54 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerRequestMail extends EventOrganizerMail {
protected getBodyHeader(): string {
return this.calEvent.language("event_awaiting_approval");
}
protected getBodyText(): string {
return this.calEvent.language("check_bookings_page_to_confirm_or_reject");
}
protected getAdditionalBody(): string {
return `<a href="${process.env.BASE_URL}/bookings">${this.calEvent.language(
"confirm_or_reject_booking"
)}</a>`;
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #01579b"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return this.calEvent.language("new_event_request", {
attendeeName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
}

View File

@@ -1,27 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail {
protected getBodyHeader(): string {
return this.calEvent.language("still_waiting_for_approval");
}
protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return this.calEvent.language("event_is_still_waiting", {
attendeeName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
}

View File

@@ -1,74 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import EventOrganizerMail from "./EventOrganizerMail";
export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<div>
${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},<br />
<br />
${this.calEvent.language("event_has_been_rescheduled")}<br />
<br />
<strong>${this.calEvent.language("event_type")}:</strong><br />
${this.calEvent.type}<br />
<br />
<strong>${this.calEvent.language("invitee_email")}:</strong><br />
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
<br />` +
this.getAdditionalBody() +
(this.calEvent.location
? `
<strong>${this.calEvent.language("location")}:</strong><br />
${this.calEvent.location}<br />
<br />
`
: "") +
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>${this.calEvent.language("additional_notes")}:</strong><br />
${this.calEvent.description}
` +
this.getAdditionalFooter() +
`
</div>
`
);
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: this.calEvent.language("rescheduled_event_type_with_attendee", {
attendeeName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
}

View File

@@ -1,168 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { CalendarEvent } from "@lib/calendarClient";
import EventMail from "./EventMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventPaymentMail extends EventMail {
paymentLink: string;
constructor(paymentLink: string, calEvent: CalendarEvent) {
super(calEvent);
this.paymentLink = paymentLink;
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_awaiting_payment")}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language(
"emailed_you_and_any_other_attendees"
)}</p>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
<col span="1" style="width: 40%;">
<col span="1" style="width: 60%;">
</colgroup>
<tr>
<td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td>
</tr>
<tr>
<td>${this.calEvent.language("when")}</td>
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
this.calEvent.attendees[0].timeZone
})</td>
</tr>
<tr>
<td>${this.calEvent.language("who")}</td>
<td>${this.calEvent.organizer.name}<br /><small>${this.calEvent.organizer.email}</small></td>
</tr>
<tr>
<td>${this.calEvent.language("where")}</td>
<td>${this.getLocation()}</td>
</tr>
<tr>
<td>${this.calEvent.language("notes")}Notes</td>
<td>${this.calEvent.description}</td>
</tr>
</table>
` +
this.getAdditionalBody() +
"<br />" +
`
<hr />
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.calEvent.additionInformation?.hangoutLink) {
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
}
if (
this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => {
return `
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `${locations}`;
}
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return `<a href="${this.paymentLink}">${this.calEvent.language("pay_now")}</a>`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("awaiting_payment", {
eventType: this.calEvent.type,
organizerName: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error);
}
/**
* Returns the inviteeStart value used at multiple points.
*
* @private
*/
protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View File

@@ -1,102 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import EventMail from "./EventMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventRejectionMail extends EventMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #ba2525"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_request_rejected")}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
<hr />
` +
`
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("rejected_event_type_with_organizer", {
eventType: this.calEvent.type,
organizer: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
/**
* Returns the inviteeStart value used at multiple points.
*
* @protected
*/
protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
return "";
}
}

View File

@@ -1,43 +0,0 @@
import EventAttendeeMail from "./EventAttendeeMail";
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventAttendeeMail extends EventAttendeeMail {
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getAdditionalBody(): string {
if (!this.calEvent.videoCallData) {
return "";
}
const meetingPassword = this.calEvent.videoCallData.password;
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
if (meetingId && meetingPassword) {
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_password")}:</strong> ${
this.calEvent.videoCallData.password
}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
this.calEvent.videoCallData.url
}">${this.calEvent.videoCallData.url}</a><br />
`;
}
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
this.calEvent.videoCallData.url
}">${this.calEvent.videoCallData.url}</a><br />
`;
}
}

View File

@@ -1,41 +0,0 @@
import EventOrganizerMail from "./EventOrganizerMail";
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail {
/**
* Adds the video call information to the mail body
* and calendar event description.
*
* @protected
*/
protected getAdditionalBody(): string {
if (!this.calEvent.videoCallData) {
return "";
}
const meetingPassword = this.calEvent.videoCallData.password;
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
if (meetingPassword && meetingId) {
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_password")}:</strong> ${this.calEvent.videoCallData.password}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
this.calEvent.videoCallData.url
}</a><br />
`;
}
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
this.calEvent.videoCallData.url
}</a><br />
`;
}
}

View File

@@ -1,40 +0,0 @@
import Handlebars from "handlebars";
import { TFunction } from "next-i18next";
export type VarType = {
language: TFunction;
user: {
name: string | null;
};
link: string;
};
export type MessageTemplateTypes = {
messageTemplate: string;
subjectTemplate: string;
vars: VarType;
};
export type BuildTemplateResult = {
subject: string;
message: string;
};
export const buildMessageTemplate = ({
messageTemplate,
subjectTemplate,
vars,
}: MessageTemplateTypes): BuildTemplateResult => {
const buildMessage = Handlebars.compile(messageTemplate);
const message = buildMessage(vars);
const buildSubject = Handlebars.compile(subjectTemplate);
const subject = buildSubject(vars);
return {
subject,
message,
};
};
export default buildMessageTemplate;

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