493 Commits
v1.0 ... 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
Alex Johansson
f3c95fa3de fix i18n flicker on auth pages (#1183)
* fix flicker on `/auth/login`

* fix flicker on logout

* fix flicker on error

* fix i18n flicker on signup
2021-11-16 18:12:08 +01:00
Lola
669b7798db Enable Recording in Daily Video Calls (#1141)
* ⬆️ Bump tailwindcss from 2.2.14 to 2.2.15

Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 2.2.14 to 2.2.15.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v2.2.14...v2.2.15)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* updating cal will provide a zoom meeting url

* updating cal will provide a zoom meeting url

* modifying how daily emails send

* modifying how daily emails send

* daily table

* migration updates

* rebasing updates

* updating Daily references to a new table

* merge updates, adding Daily references to book/events.ts

* updated video email templates to remove Daily specific references

* updating the events.ts and refactoring in the event manager

* removing the package-lock

* updating some of the internal Daily notes

* removing handle errors raw from the Daily video client

* prettier formatting fixes

* Added the Daily location to calendar events and updated Cal video references to Daily.co video

* updating references to create in event manager to check for Daily video

* adding a daily interface in the event manager

* adding daily to the location labels

* resolving yarn merge conflicts

* removing prettier auto and refactoring integrations: daily in the event manager

* removing changes to estlintrc.json

* updating message when daily video call meeting has not started

* updated modal time buffer

* changed video calls to Cal colors

* removing change to undoing change to lodash/merge on the event manager

* removing change on the event-types to match the main repo

* updating the border color in daily video calls

* updating cal will provide a zoom meeting url

* updating cal will provide a zoom meeting url

* modifying how daily emails send

* modifying how daily emails send

* daily table

* migration updates

* rebasing updates

* updating Daily references to a new table

* merge updates, adding Daily references to book/events.ts

* updated video email templates to remove Daily specific references

* updating the events.ts and refactoring in the event manager

* removing the package-lock

* updating some of the internal Daily notes

* removing handle errors raw from the Daily video client

* prettier formatting fixes

* Added the Daily location to calendar events and updated Cal video references to Daily.co video

* updating references to create in event manager to check for Daily video

* adding a daily interface in the event manager

* adding daily to the location labels

* resolving yarn merge conflicts

* removing prettier auto and refactoring integrations: daily in the event manager

* removing changes to estlintrc.json

* updated modal time buffer

* updates to enable recording

* removed the console log line for debugging int he DailyVideoAdapter

* removed the env copy created here

* updating readme and chaging Daily Scale Plan variable to true

* merge changes

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lola-Ojabowale <lola.ojabowale@gmail.com>
2021-11-16 14:12:10 +00:00
Syed Ali Shahbaz
4e01b13133 Feature/cal 677 brand color in settingsprofile (#1158)
* added CSS variable --brand-color

* added CustomBranding component

* prisma update for brand color

* added brandcolor to user context in viewer.me

* conflict resolution

* added brandColor input and mutation

* custom brand color to availability

* brandColor added to BookingPage

* fixed availability, booking for team and added customBranding to success

* brandColor added to cancel/uid

* requested changes

* lint fix

* further changes

* lint fix
2021-11-16 14:21:46 +05:30
Omar López
11e7779a58 Allows nameless users to Signout and update profile (#1181) 2021-11-15 18:08:04 -07:00
Alex Johansson
7de35dc4e5 set engines version to >=14 (#1178)
* add `16.x` to `engines`

node.16 is LTS now

* Update package.json

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-11-16 00:20:12 +00:00
Alex Johansson
0a34512e3b availability timezone fix (#1180) 2021-11-15 20:32:10 +00:00
Bailey Pumfleet
9fd078c59d Revert "automerge and approve crowdin (#1167)" (#1174) 2021-11-15 18:47:20 +00:00
Alex Johansson
11269229ae add happy path test for booking an event (#1177) 2021-11-15 15:03:04 +00:00
Bailey Pumfleet
6b171a6f87 Manually reorder event types (#1142)
* Add event type reordering

* Add migration for position field

* hack on a hack

* can edit

* fix ordering

* Remove console.log

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

Co-authored-by: KATT <alexander@n1s.se>
2021-11-15 12:25:49 +00:00
Peer Richelsen
6fa980f801 added cal.crowdin.com/cal to readme 2021-11-13 21:24:22 +00:00
Alex van Andel
fd2adae10f Tweak my-4 to mb-4 (#1164)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-11-13 09:53:01 +00:00
github-actions[bot]
f0ff048f4d New Crowdin translations by Github Action (#1168)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-13 09:36:07 +00:00
Alex Johansson
7998b6248d automerge and approve crowdin (#1167)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-11-11 12:05:59 +00:00
github-actions[bot]
6e100a6559 New Crowdin translations by Github Action (#1165)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-11-11 11:32:57 +00:00
Alex Johansson
d4f8030b6b only create event on first calendar integration (#1155)
* only create event on first calendar integration

* fix it

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-11-11 05:50:56 +00:00
Alex van Andel
bf659c0b16 Fixed #1015 - Teams user registration is broken (#1090)
* Fixed #1015 - Teams user registration is broken

* Type fixes for avilability form in onboarding

* Re adds missing strings

* Updates user availability in one query

Tested and working correctly

* Fixes seeder and tests

Co-authored-by: Omar López <zomars@me.com>
2021-11-11 05:44:53 +00:00
Ciarán Hanrahan
16fba702fb typography fixes (#1163) 2021-11-10 23:18:39 +00:00
github-actions[bot]
87b2ecec26 New Crowdin translations by Github Action (#1162)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-10 14:24:41 +00:00
github-actions[bot]
990b106c8b New Crowdin translations by Github Action (#1161)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-10 14:20:14 +00:00
github-actions[bot]
aaa48372b1 New Crowdin translations by Github Action (#1154)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-10 14:02:43 +00:00
Bailey Pumfleet
7c6dd7a73b Change input fields to rounded-sm (#1159) 2021-11-10 11:52:42 +00:00
Alex van Andel
8664d217c9 Feature/availability page revamp (#1032)
* Refactored Schedule component

* Merge branch 'main' into feature/availability-page-revamp

* wip

* Turned value into number, many other TS tweaks

* NodeJS 16x works 100% on my local, but out of scope for this already massive PR

* Fixed TS errors in viewer.tsx and schedule/index.ts

* Reverted next.config.js

* Fixed minor remnant from moving types to @lib/types

* schema comment

* some changes to form handling

* add comments

* Turned ConfigType into number; which seems to be the value preferred by tRPC

* Fixed localized time display during onboarding

* Update components/ui/form/Schedule.tsx

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

* Added showToast to indicate save success

* Converted number to Date, and also always establish time based on current date

* prevent height flickering of availability

by removing mb-2 of input field

* availabilty: re-added mb-2 but added min-height

* Quite a few bugs discovered, but this seems functional

Co-authored-by: KATT <alexander@n1s.se>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-11-10 11:16:32 +00:00
Mihai C
559ccb8ca7 hotfix: zoom location on emails (#1153)
* fix: zoom location on emails

* test: fix

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-11-09 16:27:33 +00:00
github-actions[bot]
43fa4f6497 New Crowdin translations by Github Action (#1147)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-09 15:18:59 +00:00
Peer Richelsen
55a7cc928f removed api docs button from integrations (#1149) 2021-11-09 10:07:02 +00:00
Peer Richelsen
4ce9f9110b fixed darkmode for booking page (#1151) 2021-11-08 23:06:50 +00:00
Peer Richelsen
528c620aa7 fixed dark mode for booking (#1150) 2021-11-08 21:57:32 +00:00
Alex Johansson
df687009bd fix onboarding login glitch (#1118) 2021-11-08 14:10:02 +00:00
Bailey Pumfleet
9befd4abb9 Swap VS Code title bar colours 2021-11-08 13:12:58 +00:00
Syed Ali Shahbaz
a404ca847c updated event title message (#1132)
* updated event title message

* 4 arguments replaced by an object

* translations

* requested changes

* further requested changes

* test fix and other minor changes

* lint fix
2021-11-08 16:34:12 +05:30
Peer Richelsen
0ef6d8b452 added key_strings for /integrations (#1144) 2021-11-07 15:52:48 +00:00
Peer Richelsen
b49553c960 added who string (#1143) 2021-11-06 19:28:30 +00:00
github-actions[bot]
86cb961fa8 New Crowdin translations by Github Action (#1140)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-06 12:16:28 +00:00
github-actions[bot]
0e5a7cdd55 New Crowdin translations by Github Action (#1139)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-05 20:36:18 +00:00
Alex Johansson
823abfb3da Delete dependabot.yml (#1137)
replacing dependabot with [depfu](depfu.com)
2021-11-05 16:52:35 +00:00
Mihai C
debef8119e Rejected bookings should be displayed in cancelled bookings tab (#1100)
* fix: rejected bookings should be displayed in cancelled bookings tab

* fix: add migration to update status of rejected bookings

* unrelated fix

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-11-04 22:24:15 +00:00
Peer Richelsen
773e9ac57e replaced black color with new brand variable to make styling easier (#1125) 2021-11-04 14:30:37 +00:00
dependabot[bot]
0e9c50a58d ⬆️ Bump tailwindcss from 2.2.16 to 2.2.19 (#1128)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 2.2.16 to 2.2.19.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v2.2.16...v2.2.19)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-11-04 14:00:42 +00:00
Peer Richelsen
401016772e added missing roboto weights (#1130) 2021-11-04 13:56:02 +00:00
Peer Richelsen
3c5a84b7b4 removed lexend, upgraded cal sans, added roboto from local instead of google fonts (#1121) 2021-11-04 00:25:47 +00:00
Syed Ali Shahbaz
ac3569b78e removed duplicate unconfirmed status on mobile view (#1119) 2021-11-03 20:37:37 +05:30
Alex Johansson
0ee523643d upgrade to next.js 12 (#1111) 2021-11-03 14:02:17 +00:00
dependabot[bot]
b82f2be391 ⬆️ Bump @typescript-eslint/parser from 4.32.0 to 4.33.0 (#1117)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 4.32.0 to 4.33.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v4.33.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-03 13:25:40 +00:00
dependabot[bot]
ae09e220d0 ⬆️ Bump @types/lodash from 4.14.175 to 4.14.176 (#1114)
Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.14.175 to 4.14.176.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash)

---
updated-dependencies:
- dependency-name: "@types/lodash"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-03 13:17:04 +00:00
dependabot[bot]
aafbe626fc ⬆️ Bump @hookform/resolvers from 2.8.2 to 2.8.3 (#1104)
Bumps [@hookform/resolvers](https://github.com/react-hook-form/resolvers) from 2.8.2 to 2.8.3.
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v2.8.2...v2.8.3)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-03 10:58:15 +00:00
Alex Johansson
a002b194da Fix onboarding OAuth callback glitch (#1079) 2021-11-03 10:47:52 +00:00
dependabot[bot]
d147772d91 ⬆️ Bump @radix-ui/react-switch from 0.1.0 to 0.1.1 (#1106)
Bumps [@radix-ui/react-switch](https://github.com/radix-ui/primitives) from 0.1.0 to 0.1.1.
- [Release notes](https://github.com/radix-ui/primitives/releases)
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-switch"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-02 17:30:33 +00:00
dependabot[bot]
8e669cf21e ⬆️ Bump @heroicons/react from 1.0.4 to 1.0.5 (#1107)
Bumps [@heroicons/react](https://github.com/tailwindlabs/heroicons) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/tailwindlabs/heroicons/releases)
- [Commits](https://github.com/tailwindlabs/heroicons/commits)

---
updated-dependencies:
- dependency-name: "@heroicons/react"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-02 17:24:12 +00:00
dependabot[bot]
1a361283ed ⬆️ Bump @trpc/* from 9.10.1 to 9.12.0 (#1105)
Co-authored-by: KATT <alexander@n1s.se>
2021-11-02 17:18:22 +00:00
Alex Johansson
d4b70162e8 fix locale being empty string (#1112) 2021-11-02 17:09:22 +00:00
Alex Johansson
6318972aa1 temporarily rollback to next.js 11 (#1113) 2021-11-02 18:59:36 +02:00
Alex van Andel
256305eb6b Changed 'jp' to 'ja' (#1110) 2021-11-02 16:15:57 +00:00
Alex Johansson
265c634db9 upgrade to Next.js 12 (#1109) 2021-11-02 14:19:40 +00:00
Alex Johansson
5a25e6daee ignore .js files in type checker (#1108)
makes stuff like #1085 not fail
2021-11-02 16:04:25 +02:00
Alex Johansson
68e062c100 Revert "⬆️ Bump @radix-ui/react-dialog from 0.1.0 to 0.1.1 (#1097)" (#1099)
This reverts commit 15e3b28a23.
2021-11-01 14:43:44 +00:00
dependabot[bot]
15e3b28a23 ⬆️ Bump @radix-ui/react-dialog from 0.1.0 to 0.1.1 (#1097)
Bumps [@radix-ui/react-dialog](https://github.com/radix-ui/primitives) from 0.1.0 to 0.1.1.
- [Release notes](https://github.com/radix-ui/primitives/releases)
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dialog"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:27:28 +00:00
dependabot[bot]
d7be47ed10 ⬆️ Bump @types/async from 3.2.8 to 3.2.9 (#1095)
Bumps [@types/async](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/async) from 3.2.8 to 3.2.9.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/async)

---
updated-dependencies:
- dependency-name: "@types/async"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:13:05 +00:00
dependabot[bot]
c1fb56b4b7 ⬆️ Bump superjson from 1.7.5 to 1.8.0 (#1096)
Bumps [superjson](https://github.com/blitz-js/superjson) from 1.7.5 to 1.8.0.
- [Release notes](https://github.com/blitz-js/superjson/releases)
- [Commits](https://github.com/blitz-js/superjson/compare/v1.7.5...v1.8.0)

---
updated-dependencies:
- dependency-name: superjson
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:04:21 +00:00
dependabot[bot]
8a87cb4f3b ⬆️ Bump eslint-plugin-react from 7.26.0 to 7.26.1 (#1098)
Bumps [eslint-plugin-react](https://github.com/yannickcr/eslint-plugin-react) from 7.26.0 to 7.26.1.
- [Release notes](https://github.com/yannickcr/eslint-plugin-react/releases)
- [Changelog](https://github.com/yannickcr/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yannickcr/eslint-plugin-react/compare/v7.26.0...v7.26.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:58:20 +00:00
dependabot[bot]
6464cee5a1 ⬆️ Bump react-query from 3.24.4 to 3.30.0 (#1084)
Bumps [react-query](https://github.com/tannerlinsley/react-query) from 3.24.4 to 3.30.0.
- [Release notes](https://github.com/tannerlinsley/react-query/releases)
- [Commits](https://github.com/tannerlinsley/react-query/compare/v3.24.4...v3.30.0)

---
updated-dependencies:
- dependency-name: react-query
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 10:28:57 +00:00
github-actions[bot]
8cb42c44b7 New Crowdin translations by Github Action (#1094)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-11-01 10:00:40 +00:00
Syed Ali Shahbaz
4f75b94d88 mobile view improvement added (#1093)
* mobile view improvement added

* improved max-w
2021-11-01 14:25:43 +05:30
Peer Richelsen
ad46fc121d added jp (japanese) (#1085) 2021-10-31 21:31:21 +00:00
github-actions[bot]
863fc2e5cc New Crowdin translations by Github Action (#1088)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-31 19:12:59 +00:00
Alex van Andel
b7435b5b93 Fixed orphaning team event types (#1086) 2021-10-31 10:41:42 +00:00
Peer Richelsen
307856f8e6 added more weight to lexend fallback (#1087) 2021-10-31 01:09:27 +01:00
dependabot[bot]
6635363521 ⬆️ Bump @types/node from 16.10.2 to 16.11.5 (#1046)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 16.10.2 to 16.11.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-30 20:48:05 +00:00
dependabot[bot]
88f10e0586 ⬆️ Bump @hookform/resolvers from 2.8.1 to 2.8.2 (#1083) 2021-10-30 20:58:58 +01:00
dependabot[bot]
9475c5836d ⬆️ Bump playwright from 1.15.2 to 1.16.2 (#1081)
Bumps [playwright](https://github.com/Microsoft/playwright) from 1.15.2 to 1.16.2.
- [Release notes](https://github.com/Microsoft/playwright/releases)
- [Commits](https://github.com/Microsoft/playwright/compare/v1.15.2...v1.16.2)

---
updated-dependencies:
- dependency-name: playwright
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-30 18:10:00 +00:00
dependabot[bot]
a83717363f ⬆️ Bump @daily-co/daily-js from 0.16.0 to 0.21.0 (#1076) 2021-10-30 16:28:46 +00:00
github-actions[bot]
317e3d2ade New Crowdin translations by Github Action (#1074)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-30 17:23:38 +01:00
Alex Johansson
a0c2e57891 suspense follow-up fixes (#1080) 2021-10-30 17:03:49 +01:00
Alex Johansson
1790aeb577 refactor /integrations with <Suspense /> (#1078)
* suspense

* iframe embeds

* calendar list container

* rename things as a container

* use list container on onboarding

* fix

* rm code

* newer alpha

* make it work in react 17

* fix

* fix

* make components handle error state through `QueryCell`

* fix constant

* fix type error

* type error

* type fixes

* fix package.lock

* fix webhook invalidate

* fix mt

* fix typo

* pr comment
2021-10-30 15:54:21 +00:00
Syed Ali Shahbaz
78523f7a57 margin fix (#1077) 2021-10-29 21:27:35 +05:30
Omar López
cc25a772a1 Adds missing webhook user relationship (#1070) 2021-10-29 08:13:51 -06:00
Syed Ali Shahbaz
5291dade42 fixed small screen breaking (#1075)
* fixed small screen breaking

* fixed lint err
2021-10-29 18:29:23 +05:30
Syed Ali Shahbaz
ed4587b3af reduces breakpoint max-w (#1072) 2021-10-29 15:33:37 +05:30
Alex van Andel
eefb829f75 Fixes #1021 - correctly replace integrations:google:meet (#1071) 2021-10-29 08:21:32 +00:00
github-actions[bot]
2feed85a1a New Crowdin translations by Github Action (#1048)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-29 09:16:36 +01:00
Alex van Andel
a5eb3fce28 Allow confirming COLLECTIVE types (#1069) 2021-10-29 01:50:52 +01:00
Alex van Andel
91fca7477d Fixed opt-in layout plus some tweaks (#1067) 2021-10-28 23:06:37 +00:00
Mihai C
98829d23d3 fix: type errors and translate ee pages (#1050)
* fix: type errors and translate ee pages

* fix: translation key for composed string

* type fixes

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-28 22:58:26 +00:00
Alex Johansson
dddb494071 rewrite webhooks to trpc (#1065) 2021-10-28 22:52:39 +00:00
Alex van Andel
41382caa6c Fixed invalid types of i18n locales (string[]) (#1068)
* Fixed invalid types of i18n locales (string[])

* Remaining typefixes done
2021-10-28 22:45:58 +00:00
Omar López
265b76083a Fixes integrations api endpoint (#1066) 2021-10-28 22:30:42 +01:00
Alex van Andel
94f3ae1c64 Added Team with 'pro' and 'free' as members (#1063)
* Added Team with 'pro' and 'free' as members

* Teams fundamentally changes event-type behaviour and requires seperate users
2021-10-28 20:36:45 +01:00
Alex Johansson
5af159cf4e fix loading flicker on /bookings (#1062) 2021-10-28 16:12:30 +00:00
Mihai C
f91de82daf feat: add infinite scroll on bookings tabs (#1059)
* feat: add infinite scroll on bookings tabs

* bookings page infinite scroll PR comments (#1060)

* check if `InteractionObserver` is supported

* revert query cell and use bespoke behaviour

* Update pages/bookings/[status].tsx

Co-authored-by: Mihai C <34626017+mihaic195@users.noreply.github.com>

* load more button

* make inview as a callback

Co-authored-by: Mihai C <34626017+mihaic195@users.noreply.github.com>

* mt-6

* fix: translation strings and remove unnecessary stuff

Co-authored-by: Alex Johansson <alexander@n1s.se>
2021-10-28 15:02:22 +00:00
Omar López
e38086b8fe Refactors video integrations (#1037)
* Fixes error types

* Type fixes

* Refactors video meeting handling

* More type fixes

* Type fixes

* More fixes

* Makes language non optional

* Adds missing translations

* Apply suggestions from code review

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

* Feedback

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
2021-10-26 10:17:24 -06:00
Bailey Pumfleet
eabb096e14 Ensure you can only delete your own credentials 2021-10-26 16:21:08 +01:00
Alex van Andel
605586ea1b Next turned on allowJs again.. (#1049) 2021-10-26 17:33:35 +03:00
github-actions[bot]
b6b307605b New Crowdin translations by Github Action (#986)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-26 14:44:34 +01:00
Lola
9879f9910a Add Modals for Daily Video Calls and updates to Video Call colors (#1027)
* ⬆️ Bump tailwindcss from 2.2.14 to 2.2.15

Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 2.2.14 to 2.2.15.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v2.2.14...v2.2.15)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* updating cal will provide a zoom meeting url

* updating cal will provide a zoom meeting url

* modifying how daily emails send

* modifying how daily emails send

* daily table

* migration updates

* rebasing updates

* updating Daily references to a new table

* merge updates, adding Daily references to book/events.ts

* updated video email templates to remove Daily specific references

* updating the events.ts and refactoring in the event manager

* removing the package-lock

* updating some of the internal Daily notes

* removing handle errors raw from the Daily video client

* prettier formatting fixes

* Added the Daily location to calendar events and updated Cal video references to Daily.co video

* updating references to create in event manager to check for Daily video

* adding a daily interface in the event manager

* adding daily to the location labels

* resolving yarn merge conflicts

* removing prettier auto and refactoring integrations: daily in the event manager

* removing changes to estlintrc.json

* updating message when daily video call meeting has not started

* updated modal time buffer

* changed video calls to Cal colors

* removing change to undoing change to lodash/merge on the event manager

* removing change on the event-types to match the main repo

* updating the border color in daily video calls

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lola-Ojabowale <lola.ojabowale@gmail.com>
2021-10-26 14:10:55 +01:00
dependabot[bot]
22682aa54d ⬆️ Bump ts-jest from 27.0.5 to 27.0.7 (#1040)
Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 27.0.5 to 27.0.7.
- [Release notes](https://github.com/kulshekhar/ts-jest/releases)
- [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/kulshekhar/ts-jest/compare/v27.0.5...v27.0.7)

---
updated-dependencies:
- dependency-name: ts-jest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-25 19:09:43 +00:00
Syed Ali Shahbaz
baba307a9f Feature/cal 605 add webhook test check during webhook (#1035)
* starting point

* lint fix

* add mock placeholder

* simplified a bit

* add some placeholder ui

* err handling

* multiple fixes

* post rebase fixes

* removed extra webhook enabled button

* finishing touches

* added translations

* removed debug remnants

* requested changes

Co-authored-by: KATT <alexander@n1s.se>
2021-10-25 21:45:52 +05:30
Alex Johansson
9842aaaf6a cron fix again - missing \ (#1045) 2021-10-25 16:05:36 +00:00
Alex Johansson
9efa429294 fix cron jobs env/secrets (#1043) 2021-10-25 15:42:16 +00:00
Alex Johansson
a9df3b9ad0 Move cron jobs into GitHub actions (#1006) 2021-10-25 18:16:42 +03:00
Mihai C
8d6fec79d3 feat: add translations for emails and type error fixes overall (#994)
* feat: add translations for forgot password email and misc

* fix: type fixes

* feat: translate invitation email

* fix: e2e tests

* fix: lint

* feat: type fixes and i18n for emails

* Merge main

* fix: jest import on server path

* Merge

* fix: playwright tests

* fix: lint

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-25 14:05:21 +01:00
Mihai C
356d470e16 fix: lowercase email on signup (#1039) 2021-10-25 09:29:54 +00:00
Mihai C
1043b31cc7 fix: lowercase email when loggin in (#1038) 2021-10-25 14:25:28 +05:30
Peer Richelsen
69a54d10df added docker team link 2021-10-23 14:33:49 +01:00
Alex van Andel
1649d41dd5 Adds useUnknownInCatchVariables and disallows .js in tsconfig (#1033) 2021-10-22 21:08:04 +01:00
dependabot[bot]
d780e39241 ⬆️ Bump typescript from 4.4.3 to 4.4.4 (#1025)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.4.3 to 4.4.4.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v4.4.3...v4.4.4)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-22 02:35:47 +02:00
dependabot[bot]
3eaf48fac2 ⬆️ Bump @radix-ui/react-dropdown-menu from 0.1.0 to 0.1.1 (#1022)
Bumps [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) from 0.1.0 to 0.1.1.
- [Release notes](https://github.com/radix-ui/primitives/releases)
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-21 16:34:47 +00:00
dependabot[bot]
3f1066e1c3 ⬆️ Bump @trpc/* from 9.9.1 to 9.10.1 (#1023)
Co-authored-by: KATT <alexander@n1s.se>
2021-10-21 16:26:49 +00:00
dependabot[bot]
c42dce2fdb ⬆️ Bump postcss from 8.3.8 to 8.3.11 (#1024)
Bumps [postcss](https://github.com/postcss/postcss) from 8.3.8 to 8.3.11.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.3.8...8.3.11)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-21 18:21:01 +02:00
Peer Richelsen
d6a5d1f3da fixed layout for non-english languages (#1004) 2021-10-20 22:50:32 +00:00
Syed Ali Shahbaz
a6eed6ffcc Enhancement/webhooks redesign (#1005) 2021-10-20 18:23:15 +00:00
Alex Johansson
c28d800aa9 fix i18n flicker on booking pages (#1013) 2021-10-20 16:00:11 +00:00
Alex Johansson
b8e8319b23 await on open change (#1016)
https://github.com/calendso/calendso/pull/988#discussion_r732908578
2021-10-20 15:54:52 +00:00
dependabot[bot]
d63a180bb2 ⬆️ Bump react-timezone-select from 1.1.11 to 1.1.12 (#1010)
Bumps [react-timezone-select](https://github.com/ndom91/react-timezone-select) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/ndom91/react-timezone-select/releases)
- [Commits](https://github.com/ndom91/react-timezone-select/commits)

---
updated-dependencies:
- dependency-name: react-timezone-select
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-20 15:48:16 +00:00
Omar López
85d7122e43 Fixes Apple Calendar onboarding and type fixes (#988)
* Type fixes

* Type fixes

* Attemp to prevent unknown error in prod

* Type fixes

* Type fixes for onboarding

* Extracts ConnectIntegration

* Extracts IntegrationListItem

* Extracts CalendarsList

* Uses CalendarList on onboarding

* Removes deprecated Alert

* Extracts DisconnectIntegration

* Extracts CalendarSwitch

* Extracts ConnectedCalendarsList

* Extracted connectedCalendar logic for reuse

* Extracted SubHeadingTitleWithConnections

* Type fixes

* Fetched connected calendars in onboarding

* Refreshes data on when adding/removing calendars on onboarding

* Removed testing code

* Type fixes

* Feedback

* Moved integration helpers

* I was sleepy

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-20 15:42:40 +00:00
dependabot[bot]
bd99a06765 ⬆️ Bump @radix-ui/react-slider from 0.1.0 to 0.1.1 (#1009)
Bumps [@radix-ui/react-slider](https://github.com/radix-ui/primitives) from 0.1.0 to 0.1.1.
- [Release notes](https://github.com/radix-ui/primitives/releases)
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-slider"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-20 15:37:02 +00:00
Alex Johansson
9e16007c05 add e2e test retries (#1011) 2021-10-20 15:18:50 +01:00
dependabot[bot]
02c62c18ef ⬆️ Bump @typescript-eslint/eslint-plugin from 4.32.0 to 4.33.0 (#1008)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 4.32.0 to 4.33.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v4.33.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-20 15:33:08 +02:00
dependabot[bot]
40377215f6 ⬆️ Bump react-hook-form from 7.17.4 to 7.17.5 (#1001)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.17.4 to 7.17.5.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.17.4...v7.17.5)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-20 10:52:20 +00:00
dependabot[bot]
45ecf0c49c ⬆️ Bump babel-plugin-istanbul from 6.0.0 to 6.1.1 (#1002)
Bumps [babel-plugin-istanbul](https://github.com/istanbuljs/babel-plugin-istanbul) from 6.0.0 to 6.1.1.
- [Release notes](https://github.com/istanbuljs/babel-plugin-istanbul/releases)
- [Changelog](https://github.com/istanbuljs/babel-plugin-istanbul/blob/master/CHANGELOG.md)
- [Commits](https://github.com/istanbuljs/babel-plugin-istanbul/compare/v6.0.0...v6.1.1)

---
updated-dependencies:
- dependency-name: babel-plugin-istanbul
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-20 10:47:00 +00:00
Anton Podviaznikov
ade88700fc load @next/bundle-analyzer conditionally if env was set (#999) 2021-10-20 09:08:58 +00:00
Alex Johansson
362e8114a0 Update dependabot.yml (#998)
Allow us to get a few more dependabot options to merge
2021-10-20 09:59:22 +01:00
dependabot[bot]
a9b6b5f066 ⬆️ Bump @tailwindcss/forms from 0.3.3 to 0.3.4 (#997)
Bumps [@tailwindcss/forms](https://github.com/tailwindlabs/tailwindcss-forms) from 0.3.3 to 0.3.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss-forms/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss-forms/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss-forms/compare/v0.3.3...v0.3.4)

---
updated-dependencies:
- dependency-name: "@tailwindcss/forms"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-20 00:11:35 +00:00
Omar López
06822a2c57 Prevents production build error with bundle analyzer (#996) 2021-10-19 17:51:59 -06:00
Ciarán Hanrahan
1447251c83 changes to bookings and sidebar dropdown (#991) 2021-10-19 12:38:05 +00:00
Bailey Pumfleet
687af03cc3 Improve iframe embed UI (#990) 2021-10-19 11:35:52 +01:00
Alex Johansson
9e69029943 add e2e testing on webhooks and booking happy-path (#936) 2021-10-18 22:07:06 +01:00
Bailey Pumfleet
86d292838c Fix typo in Google font URL 2021-10-18 14:17:48 +01:00
github-actions[bot]
b753d9e5e3 New Crowdin translations by Github Action (#971)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Mihai C <34626017+mihaic195@users.noreply.github.com>
2021-10-18 09:11:41 +00:00
Alex Johansson
d8dac426eb refactor webhooks UI (#982) 2021-10-18 08:02:25 +01:00
Alex van Andel
12f6065d84 Added @types/react-phone-number-input (#983) 2021-10-17 22:10:41 +01:00
Peer Richelsen
415d5fe8bd fix type error in <Shell> Userdropdown (#980)
* removed settings from bottom nav

* truncate bottom nav

* fixed type error in Shell
2021-10-17 13:13:24 +01:00
Peer Richelsen
4d5b5663c0 removing settings from bottom nav for all languages, truncate length (#979)
* removed settings from bottom nav

* truncate bottom nav
2021-10-17 11:36:25 +00:00
Peer Richelsen
656d58b94f moved embed and webhooks from settings into /integrations (#978) 2021-10-17 10:35:25 +01:00
Alex Johansson
c146231b31 speed up e2e tests by reusing cookies (#976) 2021-10-16 17:11:37 -07:00
Alex Johansson
f08a2271fe fix: check-changed-files-job always failing on main (#969) 2021-10-16 09:35:53 -06:00
Alex van Andel
22c4d29db5 Chore: Resolves TS errors related to FileReader/ImageUploader (#965)
* Resolves TS errors related to FileReader/ImageUploader

* Also tackle cropImage errors

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-16 10:42:28 +00:00
Alex Johansson
a67813ee77 if you touch it you fix it (#967)
* wip

* add another

* check

* add ci job

* fix ci

* fix

* maybe

* maybs

* attempt

* test

* maybe

* another attempt

* try v2

* align with normal build

* this should break build

* Revert "this should break build"

This reverts commit 1ba44d18a1d8737aa6232db2d9b9081e892e6ca2.

* tweaks

* prevent breaking on main

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-16 10:57:21 +01:00
Justin Mitchell
a32e002fd7 Removes Google calendar scope (#871)
Removes https://www.googleapis.com/auth/calendar scope from Google Calendar as Google will not approve the integration with it requested. Cal.com does not currently utilize this permission either as shown in /pages/api/integrations/googlecalendar/add.ts

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-10-15 23:17:56 +01:00
Omar López
3641d5e46e [CAL-493] Implements tRCP on event types (#923)
* Removes unused component

* Refactors useLocale

We don't need to pass the locale prop everywhere

* Event type fixes

* Extracts CreateNewEventDialog

* Implements tRCP for event types
2021-10-15 20:07:00 +01:00
Alex van Andel
c01004b470 Chore: Remove whereAndSelect and fix typescript of main PrismaClient adapter (#966) 2021-10-15 19:31:57 +01:00
github-actions[bot]
78182db99c New Crowdin translations by Github Action (#964)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-15 16:24:04 +01:00
Anton Podviaznikov
5ffee8646e use style property for the button in the dialog instead of adding css class manually (#938)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-15 15:21:22 +00:00
Alex van Andel
ce8e9c126b I18n's the i18n language dropdown & weekday using Intl (#955)
* I18n's the i18n language dropdown & weekday using Intl

* Some type fixes

* Trigger locale changes instantly (#958)

* Trigger locale changes instantly

* Restored types

* Capitalize languages across the board

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-15 12:32:09 +01:00
github-actions[bot]
b5e176a87e New Crowdin translations by Github Action (#959)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-10-15 11:56:11 +01:00
Mihai C
c4e2b6b458 more strings extractions (#963) 2021-10-15 10:53:42 +00:00
Ciarán Hanrahan
e1f4ba06d8 ui updates to integrations page (#962) 2021-10-15 12:01:49 +02:00
Peer Richelsen
12f72e0283 added meta tags to daily call (#961) 2021-10-15 00:08:14 +01:00
Alex Johansson
59e25ad04e remove unnecessary fetches of api (#960)
* skip repeating fetch of user on session changes
* fix some issues on integrations
2021-10-14 16:00:50 -06:00
Omar López
c2c37b701e Zomars/fixes trpc typo (#957) 2021-10-14 19:22:01 +00:00
Alex Johansson
2ce2bb1ca8 Show loading spinner on <Shell /> until i18n is loaded (#946)
* show loading spinner if i18n isn't loaded

* loading spinner tweaks

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-10-14 19:10:44 +00:00
Mihai C
b74dbdca9f fix: missing static files (#954)
* fix: missing static files

* fix: rename file
2021-10-14 14:44:00 +00:00
github-actions[bot]
60db5823c7 New Crowdin translations by Github Action (#951)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-14 14:35:36 +00:00
Mihai C
f955ccdef9 i18n: continued (#949)
* more extractions

* fix: failing build
2021-10-14 14:24:21 +00:00
Peer Richelsen
55d77993af added korean to i18n config (#953) 2021-10-14 14:10:53 +00:00
Peer Richelsen
f1eae5fe77 added translations for navigation (#952) 2021-10-14 13:58:17 +00:00
Mihai C
a711670a70 fix prisma error on event-type update (#950)
* fix prisma error on event-type update

* fix hidden event-type update
2021-10-14 14:40:24 +01:00
github-actions[bot]
f4d2f3b3d1 New Crowdin translations by Github Action (#948)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-14 14:02:30 +01:00
github-actions[bot]
109cc51e7a New Crowdin translations by Github Action (#947)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2021-10-14 13:55:34 +01:00
Mihai C
1783dae121 add es-419 (#944) 2021-10-14 12:26:11 +00:00
Peer Richelsen
3b844583c9 added lexend as fallback and new version of cal sans (#850) 2021-10-14 11:21:16 +00:00
github-actions[bot]
a71d97a4ad New Crowdin translations by Github Action (#910) 2021-10-14 11:11:47 +00:00
Omar López
0861d7cc61 Ends the war between tRPC and next-i18next (#939)
* Ends the war between tRPC and next-i18next

* Locale fixes

* Linting

* Linting

* trpc i18n (not working) (#942)

* simplify i18n handler and remove redundant(?) fn check

* split up viewer to a "logged in only" and "public"

* wip -- skip first render

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

* Linting

* I18n fixes

* We don't need serverSideTranslations in every page anymore

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
2021-10-14 13:57:49 +03:00
Omar López
26f20e2397 Revert "use base url (incl. app.cal.com) for iframes to support safari (#940)" (#941)
This reverts commit e615347790.
2021-10-14 00:33:43 +01:00
Peer Richelsen
e615347790 use base url (incl. app.cal.com) for iframes to support safari (#940) 2021-10-13 23:10:19 +01:00
Alex van Andel
cf92deb145 Removes Headless UI from the sidenav (#935)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-13 14:00:40 +00:00
Alex Johansson
dc4331ed17 rewrite existing cypress tests to playwright (#931) 2021-10-13 14:26:56 +01:00
Anton Podviaznikov
5c4f4bfd90 use toast instea modal for info messages (#932)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-13 13:13:00 +01:00
Alex Johansson
ec6b897191 integration page follow ups (#912)
### Internals

- Replace `lodash.*` packages with plain `lodash` & replace `lodash.*` imports with `lodash/` - should have no impact on bundle size and opens up for us to use all of lodash
- Update `viewer.me` to cherry-pick what we actually need on that query to avoid leaking extra context info
- Update `getIntegrations` to never include `.key`-property to avoid leaking 

### Visual

- Update calendars so `primary` is displayed last
- Update connected calendars so they are in ascending order in which you connected them
2021-10-13 13:35:25 +02:00
Mihai C
9e2f8de313 chore: i18n/extract strings (#934) 2021-10-13 11:49:15 +01:00
Mihai C
bee41b242b chore: extract more strings (#933) 2021-10-13 10:34:55 +01:00
Alex van Andel
4ce4b141b4 Fixes Google Meet showing up during Daily or Zoom events (#930) 2021-10-13 10:13:46 +01:00
Anton Podviaznikov
f9e315d10a renamed "paid plan" to "pro" (#921) 2021-10-12 19:51:46 +00:00
Anton Podviaznikov
0871f5edf8 Refactor two modals (#861)
* refactor one modal into dialog. save profile
2021-10-12 20:43:22 +01:00
Omar López
4b05c56a0d Fixes custom input overflow (#920)
* Fixes custom input overflow

* Truncates also placeholder

* Adds title attributes in booking notes and event descriptions
2021-10-12 18:59:52 +00:00
Omar López
64a01d33ba Fixes custom input dialog (#916) 2021-10-12 19:54:37 +01:00
Omar López
9539d26ac7 Fixes location in payment page (#918)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-12 14:54:42 +00:00
Alex Johansson
c94231f777 fix db-up / db-nuke command (#917)
`db-nuke` wasn't properly deleting the docker container
2021-10-12 14:21:57 +00:00
Omar López
cfd70172f0 Refactors useLocale (#908)
* Removes unused component

* Refactors useLocale

We don't need to pass the locale prop everywhere

* Fixes syntax error

* Adds warning for missing localeProps

* Simplify i18n utils

* Update components/I18nLanguageHandler.tsx

Co-authored-by: Mihai C <34626017+mihaic195@users.noreply.github.com>

* Type fixes

Co-authored-by: Mihai C <34626017+mihaic195@users.noreply.github.com>
2021-10-12 13:11:33 +00:00
Peer Richelsen
7dd6fdde7a full width list idems on mobile (#915) 2021-10-12 13:50:43 +01:00
Peer Richelsen
6f204ca521 more layout changes to /integrations (#914)
* added truncate to list
2021-10-12 12:06:12 +00:00
Peer Richelsen
392c8e8da4 minor layout changes to /integrations (#913) 2021-10-12 11:39:02 +00:00
Alex van Andel
2fd25acc3c Phasing out prisma db push (#911) 2021-10-12 10:51:59 +00:00
Omar López
a73187d46b Makes fields with default values not null (#903)
* Makes fields with default values non-optional

* Removes PeriodType enum for now

* Adds missing migrations

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-12 11:09:39 +01:00
Alex Johansson
c3dc18643e /integrations facelift (#858) 2021-10-12 11:35:44 +02:00
Syed Ali Shahbaz
7dc4a55319 Bugfix CAL-566 guest emails missing (#905)
* removed empty dep from useCallback of bookingHandler

* added guestEmails as the dep instead of no dep

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-12 08:29:12 +00:00
Mihai C
f27b0b3cad fix: availability creation and expand/collapse settings (#882)
* fix: availability creation and expand/collapse settings

* additional fixes

* Update pages/api/availability/eventtype.ts

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

* update branch

* fix: more fixes

Co-authored-by: Alex Johansson <alexander@n1s.se>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Mihai Colceriu <colceriumi@gmail.com>
2021-10-12 08:17:25 +00:00
Alex van Andel
683713e73f Fixes event type deletion (#906) 2021-10-11 20:56:03 +01:00
Bailey Pumfleet
69c808333e Fix URL field in event type page 2021-10-11 15:15:28 +01:00
Bailey Pumfleet
ff5b2b1668 Hotfix for PR #900: Remove unnecessary span tag 2021-10-11 14:53:14 +01:00
Bailey Pumfleet
6f219fc98c Fix for PR #900 where button tag is removed 2021-10-11 14:49:55 +01:00
Tiago Cruz
82f11b5121 Translations (#900)
* Translations

* Variables to Translated Words

* Fix Translations

* Fix Title to Create Events Types

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-11 14:42:43 +01:00
Alex Johansson
8cbf880af6 add locale to trpc context (#897) 2021-10-11 12:03:50 +00:00
Peer Richelsen
7488f29dc9 fixed event-type layout (#898)
* fixed event-type layout

* added props.centered to shell for centered layouts
2021-10-11 09:34:43 +00:00
Alex Johansson
0927b86831 Revert "Makes fields with default values non-optional (#892)" (#899)
This reverts commit bcf20914d3.
2021-10-11 10:30:09 +01:00
Peer Richelsen
9ef4815ffb wip 2021-10-11 09:32:58 +01:00
Omar López
bcf20914d3 Makes fields with default values non-optional (#892) 2021-10-10 17:48:21 +00:00
Peer Richelsen
fdd4bd2e14 changed sans font to roboto (#895) 2021-10-10 10:17:49 +00:00
Ciarán Hanrahan
e83980a999 UI updates to event types (#894) 2021-10-10 10:46:20 +01:00
Peer Richelsen
b794469c05 added cal logo to daily call (#888)
* added cal logo to daily call
2021-10-09 15:15:07 +00:00
Alex van Andel
014b74be8c Fixes custom-inputs clearing on save (#890) 2021-10-09 15:48:26 +01:00
Ciarán Hanrahan
f03a2c2a1a onboarding changes (#889) 2021-10-09 14:34:13 +01:00
Mihai C
35dd3f088c fix: move cancelled upcoming booking to cancelled tab (#883)
* fix: move cancelled upcoming booking to cancelled tab

* fix: lint

* Update server/routers/viewer.tsx

Co-authored-by: Mihai Colceriu <colceriumi@gmail.com>

* fix: also for past bookings

Co-authored-by: Alex Johansson <alexander@n1s.se>
2021-10-08 18:58:37 +03:00
Femi Odugbesan
015b7c18af fix #582: send user back to onboarding after adding integration (#635)
* fix #582: send user back to onboarding after adding integration if incomplete

* use more accurate, descriptive typings

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-10-08 14:10:57 +01:00
Mihai C
2c9b301b77 Feat/i18n crowdin (#752)
* feat: add crowdin and supported languages

* fix: main branch name

* feat: test crowdin integration

* feat: add crowdin config skeleton

* feat: update crowdin.yml

* fix: remove ro translation

* test: en translation

* test: en translation

* New Crowdin translations by Github Action (#735)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* test: en translation

* fix: separate upload/download workflows

* wip

* New Crowdin translations by Github Action (#738)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* wip

* wip

* wip

* wip

* wip

* typo

* wip

* wip

* update crowdin config

* update

* chore: support i18n de,es,fr,it,pt,ru,ro,en

* chore: extract i18n strings

* chore: extract booking components strings for i18n

* wip

* extract more strings

* wip

* fallback to getServerSideProps for now

* New Crowdin translations by Github Action (#874)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* fix: minor fixes on the datepicker

* fix: add dutch lang

* fix: linting issues

* fix: string

* fix: update GHA

* cleanup trpc

* fix linting

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-08 14:43:48 +03:00
Bailey Pumfleet
33a683d4b0 Add fix for create event type modal (#878)
Co-authored-by: Alex Johansson <alexander@n1s.se>
2021-10-07 22:49:53 +00:00
Alex Johansson
32567c8e80 fix readme heading (#879) 2021-10-07 21:59:56 +01:00
Lola
adee3fd211 Daily video calls (#542)
* ⬆️ Bump tailwindcss from 2.2.14 to 2.2.15

Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 2.2.14 to 2.2.15.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v2.2.14...v2.2.15)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* updating cal will provide a zoom meeting url

* updating cal will provide a zoom meeting url

* modifying how daily emails send

* modifying how daily emails send

* daily table

* migration updates

* daily table

* rebasing updates

* updating Daily references to a new table

* updating internal notes

* merge updates, adding Daily references to book/events.ts

* updated video email templates to remove Daily specific references

* updating the events.ts and refactoring in the event manager

* removing the package-lock

* changing calendso video powered by Daily.co to cal video powered by Daily.co

* updating some of the internal Daily notes

* added a modal for when the call/ link is invalid

* removing handle errors raw from the Daily video client

* prettier formatting fixes

* Added the Daily location to calendar events and updated Cal video references to Daily.co video

* updating references to create in event manager to check for Daily video

* fixing spacing on the cancel booking modal and adding Daily references in the event manager

* formatting fixes

* updating the readme file

* adding a daily interface in the event manager

* adding daily to the location labels

* added a note to cal event parser

* resolving yarn merge conflicts

* updating dailyReturn to DailyReturnType

* removing prettier auto and refactoring integrations: daily in the event manager

* removing changes to estlintrc.json

* updating read me formatting

* indent space for Daily ReadMe section

* resolving the merge conflicts in the yarn file

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lola-Ojabowale <lola.ojabowale@gmail.com>
2021-10-07 17:12:39 +01:00
Omar López
58de920951 Refactors custom input form & dialog (#853) 2021-10-07 15:43:20 +00:00
Omar López
30f97117e8 Revert "Revert "Feature/cal 274 add webhooks (#628)" (#854)" (#876)
This reverts commit 6868474c92.

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-07 15:14:47 +00:00
Alex Johansson
a9ee2ef9ae add playwright for e2e testing (#873) 2021-10-07 17:07:57 +02:00
Anton Podviaznikov
c62e1a0eeb Add more typescript types (#867) 2021-10-07 09:47:33 +01:00
Peer Richelsen
effbd44f48 added add to home screen banner for login in screens on mobile (#865) 2021-10-06 15:05:01 +01:00
Heaust Azure
521be467a4 resolved typescript problems for success.tsx, useTheme.tsx and event.ts (#786)
* resolved typescript problems for success.tsx, useTheme.tsx and event.ts

* remove NextRouter inferred type

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

* remove router query inferred type

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

* URIcomponent change ternary

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

* infer types for event type

* completed requested changes

* Update pages/success.tsx | change context type to proper

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

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-10-05 23:53:24 +01:00
Omar López
95b49a5995 Several type fixes (#855)
* Several type fixes

* Update ee/lib/stripe/client.ts

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

* Typo

* Refactors createPaymentLink

* Simplify calendarClietn type

Co-authored-by: Alex Johansson <alexander@n1s.se>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-10-05 16:46:48 -06:00
Anton Podviaznikov
4474e9dd74 Update api docs with /api/me (#862)
* add /api/me route to the docs

* change wording
2021-10-05 23:40:03 +01:00
Anton Podviaznikov
d272f32ee3 add zoom as location only if integration enabled, minor readme change (#860)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-10-05 19:14:11 +00:00
Alex Johansson
5254297944 bump @trpc/* (#859)
```bash
npx npm-check-updates --filter /@trpc/ -u  && yarn && git add .
```
2021-10-05 10:37:06 -06:00
Alex Johansson
d97da42950 make yarn dx work on Windows (#840)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-05 10:05:48 +00:00
Omar López
6868474c92 Revert "Feature/cal 274 add webhooks (#628)" (#854)
This reverts commit 4c07faefe7.
2021-10-05 02:30:00 +01:00
Syed Ali Shahbaz
4c07faefe7 Feature/cal 274 add webhooks (#628)
* added prisma models and migration, minor webhook init --WIP

* --WIP

* --WIP

* added radix-checkbox and other webhook additions --WIP

* added API connections and other modifications --WIP

* --WIP

* replaced checkbox with toggle --WIP

* updated to use Dialog instead of modal --WIP

* fixed API and other small fixes -WIP

* created a dummy hook for test --WIP

* replaced static hook with dynamic hooks

* yarn lock conflict quickfix

* added cancel event hook and other minor additions --WIP

* minor improvements --WIP

* added more add-webhook flow items--WIP

* updated migration to have alter table for eventType

* many ui/ux fixes, logic fixes and action fixes --WIP

* bugfix for incorrect webhook filtering

* some more fixes, edit webhook --WIP

* removed redundant checkbox

* more bugfixes and edit-webhook flow --WIP

* more build and lint fixes

* --WIP

* more fixes and added toast notif --WIP

* --updated iconButton

* clean-up

* fixed enabled check in edit webhook

* another fix

* fixed edit webhook bug

* added await to payload lambda

* wrapped payload call in promise

* fixed cancel/uid CTA alignment

* --requested changes --removed eventType relationship

* Adds missing migration

* Fixes missing daysjs plugin and type fixes

* Adds failsafe for webhooks

* Adds missing dayjs utc plugins

* Fixed schema and migrations

* Updates webhooks query

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2021-10-04 17:40:52 -06:00
Peer Richelsen
785058558c added disabled email input box to settings, contact help@cal.com (#852) 2021-10-04 22:13:46 +01:00
Alex Johansson
abe4f38a5e <QueryCell /> type inference improvement (#849)
* asserts that `query.data` is never `null` when using `<QueryCell />`
2021-10-04 10:48:40 +01:00
Peer Richelsen
f70d92df7e added edit icon to event-type title input to make editing more obvious (#848) 2021-10-03 13:32:04 +01:00
Alex van Andel
89e5da15df Fixes weekStart & automatic theme adjust checkbox (#847) 2021-10-02 20:16:51 +00:00
Kelvin F. Alfaro
1662c9cf91 Correct License banner instructions. (#846)
* Update common.json

* Banner instructions were incorrect. 

Removed the I from "I Agree". License banner indicates setting NEXT_PUBLIC_LICENSE_CONSENT env var to "I Agree". Env var NEXT_PUBLIC_LICENSE_CONSENT set to only accept "agree"

* Update common.json

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
2021-10-02 20:37:10 +01:00
Chris
33273b18d3 Use id to look up user (#843) 2021-10-02 17:53:13 +01:00
Alex Johansson
eb93e778bd simplify /bookings/[status] logic (#845) 2021-10-02 13:29:26 +00:00
Omar López
342ea3e5d2 Adds specfific empty copy by status (#842)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-10-02 12:08:35 +01:00
Alex van Andel
e12c879242 Bugfix/image upload fuzzy (#844) 2021-10-02 12:04:47 +01:00
Alex Johansson
6547ef1e86 make cypress more fail-safe (#836) 2021-10-01 16:37:11 +01:00
Alex Johansson
4879479981 add <QueryCell /> to remove bespoke loading/error/empty/success logic (#835) 2021-10-01 15:12:47 +00:00
Alex Johansson
5bed09218a add some query client defaults (#838) 2021-10-01 15:33:14 +01:00
Alex Johansson
08f83dd85c fix login sometimes having to login twice (#839) 2021-10-01 13:20:28 +01:00
Peer Richelsen
3d3e99272a replaced another location.hostname, deleted DonateBanner (#834)
* replaced another location.hostname, deleted DonateBanner

* wip
2021-10-01 09:44:53 +00:00
Peer Richelsen
860db6c959 added license banner (#833)
* added license banner and NEXT_PUBLIC_LICENSE_CONSENT in .env.example. agree to hide the banner

* minor changes to license banner

* added /ee/ to tailwind purge config

* wip
2021-10-01 00:42:08 +01:00
Alex Johansson
30163f0a78 refactor availability times form using react-hook-form (#824)
* use toast

* use `Button`

* make fn

* rewrite with react-hook-form

* add comment

* fix deps

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-09-30 22:37:52 +01:00
Alex Johansson
60298f6eeb resize image before storing (#831)
* resize image fn

* wip

* simplify transform

* comment

* fix

* rm log

* allow pass-through for non-base-64

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-09-30 21:37:29 +01:00
Peer Richelsen
e33962686e minor changes to booking page and navtabs (#832) 2021-09-30 20:59:34 +01:00
Alex Johansson
c80992aa1c move withTRPC-HOC to _app.tsx (#822) 2021-09-30 06:28:30 -06:00
Peer Richelsen
8ac56fbf4a bring back h-screen in user page (#829) 2021-09-30 11:41:13 +00:00
Peer Richelsen
378cf25521 turned teams page into flex from grid to center it (#828)
* turned teams page into flex from grid to center it, removed h-screen from user page

* new icon button

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2021-09-30 12:31:04 +01:00
Omar López
dc6841e761 Bookings UI improvements (#826) 2021-09-30 11:46:39 +01:00
Bailey Pumfleet
033c4835f7 Fix: Remove 0s from team page (#827)
* Remove 0s from team page

* damn it
2021-09-30 10:24:02 +01:00
Omar López
a04336ba06 Added booking tabs, type fixing and refactoring (#825)
* More type fixes

* More type fixes

* Type fixes

* Adds inputMode to email fields

* Added booking tabs

* Adds aditional notes to bookings
2021-09-29 22:33:18 +01:00
Syed Ali Shahbaz
5318047794 fixed reload after disband team (#823) 2021-09-29 18:04:47 +01:00
Bailey Pumfleet
079a920c2c CTA on success page (#818) 2021-09-29 15:36:29 +00:00
Alex van Andel
a30381f229 Lowercase username try #3 (#821)
3 times is a charm.. can't believe this took 3 tries.
2021-09-29 15:58:25 +01:00
Omar López
e7314257c3 Adds possibilty to analyze client and server bundle size (#677) 2021-09-29 15:36:58 +01:00
Mihai C
4a07c27da5 fix: alert severity (#814)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-09-29 11:33:40 +00:00
Peer Richelsen
d7df292296 replaced favicons (#817) 2021-09-29 10:57:19 +00:00
dependabot[bot]
3014df61cc ⬆️ Bump tailwindcss from 2.2.15 to 2.2.16 (#796)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 2.2.15 to 2.2.16.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v2.2.15...v2.2.16)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-09-29 11:28:27 +01:00
Mihai C
8f6689cfc1 chore: add some dx configs (#650)
* chore: add some dx configs

* fix: update configs

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-09-29 09:41:38 +00:00
Omar López
7a8ad8381e Fixes error when user cancels stripe integration (#813)
* Fixes customer portal return ur

* Attempt to fix portal CSRF issue

* Fixes CSRF issue with Stripe redirect

* Fixes error when user cancels stripe integration
2021-09-28 22:27:06 +01:00
Alex Johansson
f8a4f81991 fix stripe customer.subscription.deleted-webhook (#811) 2021-09-28 19:48:45 +00:00
Alex Johansson
e684824c79 yarn lint --fix (#812) 2021-09-28 20:12:48 +01:00
Malte Delfs
3b71c86b1e prevent cancellation of past events (#568)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Peer_Rich <peeroke@gmail.com>
2021-09-28 18:23:50 +01:00
Omar López
230c82e316 Zomars/fixes stripe portal redirect (#808) 2021-09-28 17:21:45 +01:00
Peer Richelsen
b8c4dfb9e1 minor style changes to billing page 2021-09-28 15:07:33 +01:00
Alex Johansson
a4fbe7b2b4 downgrade users when trial ends (#767)
* wip

* wip

* wip

* wtf

* should be all the logic

* comment

* fix receiver name

* safeguard a bit more

* downgrade users cron job

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-09-28 14:00:19 +01:00
Bailey Pumfleet
0372289fe6 Unblock / on robots.txt 2021-09-28 13:21:30 +01:00
Bailey Pumfleet
5478c135d1 Set tabIndex for forgot password & add loader (#807) 2021-09-28 10:43:15 +00:00
Bailey Pumfleet
a3abdac33b Update billing tab UI (#806) 2021-09-28 11:27:46 +01:00
Alex Johansson
7779c098dc confirming event gives no visual (#803) 2021-09-28 09:16:02 +00:00
Omar López
dd9f801872 cal 485 prevent users from changing their username to premium ones (#799)
* Makes userRequired middleware

* Prevent users from changing usernames to premium ones

* refactor on zomars' branch (#801)

* rename `profile` -> `mutation`

* `createProtectedRouter()` helper

* move profile mutation to `viewer.`

* simplify checkUsername

* Auto scrolls to error when there is one

* Renames username helpers

* Follows db convention

Co-authored-by: Alex Johansson <alexander@n1s.se>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-09-28 09:57:30 +01:00
Omar López
f23e4f2b9d Fixes customer portal return url (#802) 2021-09-28 09:04:30 +01:00
Alex Johansson
dcea723ea4 confirming event gives no visual feedback (#804) 2021-09-27 18:11:52 -06:00
Peer Richelsen
58dde562a3 switching to stripes customer portal (#772) 2021-09-27 23:57:23 +01:00
Alex Johansson
9f2cfffce4 fix loading spinner flicker (#800) 2021-09-27 23:42:04 +01:00
Alex van Andel
dc7b084bdf Remove Moment dependency by changing implementation of DateRangePicker to something leaner (#798)
* chore: extracted all DateRangePicker logic from event-types/[type].tsx

* Minor alignment fixes + Date.now() instead of 1970.

* Removes react-dates, implements wojtekmaj/react-daterange-picker
2021-09-27 22:12:55 +01:00
Syed Ali Shahbaz
78c78a6981 added relative position to placeholder parent (#797) 2021-09-27 18:59:50 +01:00
dependabot[bot]
35c450d5ef ⬆️ Bump jest from 27.2.1 to 27.2.2 (#785)
Bumps [jest](https://github.com/facebook/jest) from 27.2.1 to 27.2.2.
- [Release notes](https://github.com/facebook/jest/releases)
- [Changelog](https://github.com/facebook/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/jest/compare/v27.2.1...v27.2.2)

---
updated-dependencies:
- dependency-name: jest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-09-27 17:04:32 +00:00
Alex Johansson
649e79bdc7 statically render profile pages (#615) 2021-09-27 17:09:19 +01:00
Alex Johansson
34300650e4 add tRPC (#614)
* add trpc

* trpc specific

* fix deps

* lint fix

* upgrade prisma

* nativeTypes

* nope, not needed

* fix app propviders

* Revert "upgrade prisma"

This reverts commit e6f2d2542a01ec82c80aa2fe367ae12c68ded1a5.

* rev

* up trpc

* simplify

* wip - bookings page with trpc

* bookings using trpc

* fix `Shell` props

* call it viewerRouter instead

* cleanuop

* ssg helper

* fix lint

* fix types

* skip

* add `useRedirectToLoginIfUnauthenticated`

* exhaustive-deps

* fix callbackUrl

* rewrite `/availability` using trpc

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-09-27 14:47:55 +00:00
Peer Richelsen
0938f6f4b2 added z-index for event types dropdown (#791) 2021-09-27 10:19:11 +00:00
Alex van Andel
c22beb698c Fixes the Radix UI warning for consistent IDs (#774)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-09-26 21:55:27 +00:00
Omar López
7ab49acebe Fixes eventype form (#777)
* Type fixes

* Uses all integrations and session fixes on getting started page

* eventtype form fixes

* Update pages/event-types/[type].tsx

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-09-26 21:49:16 +00:00
Alex Johansson
b23c032a4c Update .kodiak.toml (#782)
maybe this can stop @PeerRich & @baileypumfleet to use the force squash? 

everything you open will be automatically approved so you can use "Auto-merge" at least when you yolo stuff

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-09-26 22:45:08 +01:00
Alex Johansson
22b050b9e7 yarn lint --fix (#783) 2021-09-26 14:08:40 +00:00
Peer Richelsen
6d2f89fc32 Cale to Cal 2021-09-26 15:04:01 +01:00
Peer Richelsen
ee5cc32936 renamed PoweredByCalendso 2021-09-25 17:03:08 +01:00
Peer Richelsen
b125ece57b gave powered by absolute path 2021-09-25 16:55:13 +01:00
Chris
97cb7bdc83 Show raw secret during two-factor setup (#775) 2021-09-25 16:53:12 +01:00
Alex van Andel
515c548acd Fixes theme flickering on booking & availability select page (#771) 2021-09-24 23:11:30 +01:00
Eduardo M
727793af02 Fix link ignoring app url on even-type (#773) 2021-09-24 23:09:30 +01:00
Omar López
9e7cb2c0b8 CAL-469 Adds intercom dynamically (#762)
* Adds react-use-intercom

* Adds intercom env var

* Loads intercom dynamically if env is set

* CAL-473 Fixes client-side routing for authed pages

* Moves intercom code to ee

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-09-24 21:02:03 +01:00
Peer Richelsen
889b3f36ae fixed mobile booking layout for long descriptions 2021-09-24 18:03:09 +01:00
Omar López
50367c236a Prevents a 404 page for payments (#770) 2021-09-24 17:45:35 +01:00
Peer Richelsen
63930c1817 Fix/event type button alignment (#765)
* fixed event-type buttons on mobile, added more font-cal classes

* remove 1-on-1 on mobile event types

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-09-24 15:11:15 +00:00
Alex Johansson
6a4d6c7eba remove npm-run-all (#768) 2021-09-24 15:07:31 +00:00
Peer Richelsen
a8ec6e7060 replaced window.location with NEXT_PUBLIC_APP_URL (#769)
* replaced window.location with process.env.NEXT_PUBLIC_APP_URL, removed empty classNames

* wip
2021-09-24 09:03:28 -06:00
Alex Johansson
f709972f86 ui bug: alert looking funky (#766)
* tweak alert text to be growing
* fix alert looking funky
* get rid of `typeof window` dirt
2021-09-24 06:52:00 -06:00
Peer Richelsen
bb1da8150f fixed event-type buttons on mobile, added more font-cal classes (#764) 2021-09-24 11:28:57 +00:00
Peer Richelsen
c152e43b82 minor changes to event type edit input 2021-09-24 12:23:06 +01:00
Peer Richelsen
521bb4069a Merge branch 'main' of github.com:calendso/calendso 2021-09-24 11:38:18 +01:00
Alex van Andel
2b2fde179a Fixes #701: User's page shows Team events with broken links (#761) 2021-09-24 10:23:08 +00:00
Omar López
420daec147 CAL-473 Fixes client-side routing for authed pages (#763) 2021-09-24 11:16:46 +01:00
Peer Richelsen
f5a7ed2e36 minor changes to powered by badge 2021-09-23 23:56:13 +01:00
Peer Richelsen
d3d6778c60 Merge branch 'main' of github.com:calendso/calendso 2021-09-23 23:53:04 +01:00
Peer Richelsen
8ad685653c new apple calendar and stripe icon 2021-09-23 23:50:18 +01:00
Bailey Pumfleet
9e785c01fd The tiniest linting fix ever 2021-09-23 23:31:16 +01:00
Alex van Andel
ad3a06384f Fixes a crash caused by too many redraws (#759) 2021-09-23 23:30:09 +01:00
Peer Richelsen
b741559dbc added more font-cal classes 2021-09-23 21:48:27 +01:00
Peer Richelsen
235e74440c hotfix for powered by badge (#758)
* minor fix to powered by

* fixed powered by logo
2021-09-23 21:19:11 +01:00
Peer Richelsen
6dfd3f4aba minor fix to powered by (#757) 2021-09-23 21:05:24 +01:00
Omar López
2c50781084 Makes every day available for events that don't have availability set (#756)
* Abstracts MinutesField

* Adds missing Minimum booking notice

* Refactoring

* Fixes int field sent as string

* Sorts slots by time

* Fixes availability page

* Fixes available days

* Type fixes

* More availability bugfixes

* Makes every day available for events that don't have availability set

* Type fixes
2021-09-23 12:03:07 -06:00
Omar López
bcacc1d166 More availability fixes (#755)
* Abstracts MinutesField

* Adds missing Minimum booking notice

* Refactoring

* Fixes int field sent as string

* Sorts slots by time

* Fixes availability page

* Fixes available days

* Type fixes

* More availability bugfixes
2021-09-23 18:18:29 +01:00
Alex Johansson
790ed3e6b1 fix: branding is always disabled for pro (#754)
* add jwt secret

* fix `isBrandingHidden`

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-09-23 15:25:34 +00:00
Mihai C
7574c322c4 Revert "GHA for crowdin (WIP) (#731)" (#751)
This reverts commit 168db02e1f.
2021-09-23 15:28:50 +01:00
Mihai C
168db02e1f GHA for crowdin (WIP) (#731)
* feat: add crowdin and supported languages

* fix: main branch name

* feat: test crowdin integration
2021-09-23 14:22:49 +00:00
Omar López
cb4a1e031e Fixes user event availability page (#749)
* Abstracts MinutesField

* Adds missing Minimum booking notice

* Refactoring

* Fixes int field sent as string

* Sorts slots by time

* Fixes availability page

* Fixes available days
2021-09-23 15:08:44 +01:00
Alex Johansson
4d2e556d7d fix CI e2e tests (#748) 2021-09-23 14:11:58 +01:00
Nico
2bc4678ef0 Fixed bug that old credentials were used to create a zoom booking (#729)
Co-authored-by: nicolas <privat@nicolasjessen.de>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-09-23 10:35:39 +01:00
Bailey Pumfleet
bb3362f2ef Add JWT secret to next-auth.js (#730) 2021-09-23 10:02:53 +01:00
Mihai C
82e7e51fca Setup i18n and locale detection (#712)
* feat: setup translations

* feat: i18n setup

* Update pages/settings/profile.tsx

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

* fix: abstract locale hook

* fix: set default locale if preferred locale is not supported

* Revert "fix: set default locale if preferred locale is not supported"

This reverts commit e2a3d81371ee02a033520058a1d7d61cffeffc94.

* fix: set default locale if preferred locale is not supported

* fix: use 1 namespace and remove unnecessary logs

* fix: yarn.lock

* fix: linting errors

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
2021-09-23 09:49:17 +01:00
Nico
3764a9d462 Bugfix/include zoom location (#715)
* Store video data in event location; fixed several types

* fixed malformed id

* Insert Zoom data when updating as well

* Add columns to store (video) meetings

* Store meeting data

* fixed type

* Use stored videoCallData

* Store location in field as well

* Use meta field for booking reference

* Introduced meta field in code

* Revert "Introduced meta field in code"

This reverts commit 535baccee3d87e3e793e84c4b91f8cad0e09063f.

* Revert "Use meta field for booking reference"

This reverts commit 174c252f672bcc3e461c8b3b975ac7541066d6a8.

* Linting fixes

Co-authored-by: nicolas <privat@nicolasjessen.de>
Co-authored-by: Peer_Rich <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-09-22 23:43:10 +01:00
Peer Richelsen
4f964533cf closes #726 (#728) 2021-09-22 21:38:20 +00:00
Peer Richelsen
51752bd2bd added Cal Sans (#709)
* added Cal Sans

* Delete EmptyScreen.tsx
2021-09-22 21:23:19 +00:00
Omar López
d194878bb2 Suggestion: let prettier sort imports order (#673)
* Suggestion: let prettier sort imports order

# Conflicts:
#	yarn.lock

* AUTO SORT ALL THE IMPORTS

* Linting

* Fixes test
2021-09-22 13:52:38 -06:00
Omar López
3add84a279 Adds Stripe integration (#717)
* Adds Stripe integration

* Moves Stripe instrucctions to ee

* Adds NEXT_PUBLIC_APP_URL variable

* Adds fallback for NEXT_PUBLIC_APP_URL

* Throws error objects instead

* Improved error handling

* Removes deprecated method

* Bug fixing

* Payment refactoring

* PaymentPage fixes

* Fixes preview links

* More preview link fixes

* Fixes client links

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2021-09-22 19:36:13 +01:00
Peer Richelsen
43563bc8d5 made it easier to see if there are more times available to scroll (#722)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2021-09-22 14:44:38 +00:00
Peer Richelsen
81a3d82ce7 readme: added gif demo of product vs screenshot 2021-09-22 15:36:19 +01:00
Peer Richelsen
0b74ef35d2 updated README shields 2021-09-22 15:25:42 +01:00
Peer Richelsen
a280739f01 MIT to AGPLv3 2021-09-22 15:22:36 +01:00
Omar López
e1f1386332 Feat disable guests for events (#719)
* Abstracts CheckboxField

* Allows disabling the guests field while booking

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-09-22 12:04:32 +01:00
Chris
1c2998fc13 Ensure users cannot delete teams they don’t own (#720)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-09-22 11:43:32 +01:00
Heaust Azure
8eb3a31af4 Default to slug when Full Name isn't set (#721)
When Full Name isn't set and a new team is created,
The part of the now drop-down add new event type (previously button)
that's supposed to be personal or non-team
appears as an empty div.
This commit makes it default to slug / username in such a case.
2021-09-22 11:43:08 +01:00
Femi Odugbesan
a047177e72 Fix/duplicate events on onboarding (#716) 2021-09-22 08:25:33 +01:00
Omar López
d4f29464f2 Fixes deployed previews (#714) 2021-09-21 21:18:41 +01:00
Omar López
48bc4c64f4 [CAL-463] Validates required checkboxes (#713) 2021-09-21 20:42:44 +01:00
Peer Richelsen
3c089af58a new EmptyScreen component, using it in /bookings (#708)
* new EmptyScreen component, using it in /bookings

* Linting fixes

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2021-09-21 11:36:29 +01:00
550 changed files with 38019 additions and 15344 deletions

View File

@@ -1,3 +0,0 @@
{
"presets": ["next/babel"]
}

View File

@@ -1,8 +1,35 @@
# DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
# Set this value to 'agree' to accept our license:
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
#
# Summary of terms:
# - The codebase has to stay open source, whether it was modified or not
# - You can not repackage or sell the codebase
# - Acquire a commercial license to remove these terms by emailing: license@cal.com
NEXT_PUBLIC_LICENSE_CONSENT=''
# DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
# 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
GOOGLE_API_CREDENTIALS='secret'
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
@@ -19,6 +46,10 @@ MS_GRAPH_CLIENT_SECRET=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
#Used for the Daily integration
DAILY_API_KEY=
DAILY_SCALE_PLAN=''
# E-mail settings
# Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to
@@ -37,6 +68,17 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
# ApiKey for cronjobs
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
# Application Key for symmetric encryption and decryption
# must be 32 bytes for AES256 encryption algorithm
CALENDSO_ENCRYPTION_KEY=
# Intercom Config
NEXT_PUBLIC_INTERCOM_APP_ID=

1
.eslintignore Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -25,9 +25,12 @@
},
"overrides": [
{
"files": ["cypress/**/*.js"],
"files": ["playwright/**/*.{js,jsx,tsx,ts}"],
"rules": {
"no-undef": "off"
"no-undef": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-implicit-any": "off"
}
}
],

View File

@@ -1,18 +1,19 @@
---
name: Bug report
about: Report any issues with the platform
title: ''
title: ""
labels: bug
assignees: ''
assignees: ""
---
Found a bug? Please fill out the sections below. 👍
### Issue Summary
A summary of the issue. This needs to be a clear detailed-rich summary.
A summary of the issue. This needs to be a clear detailed-rich summary.
### Steps to Reproduce
1. (for example) Went to ...
2. Clicked on...
3. ...
@@ -20,6 +21,7 @@ A summary of the issue. This needs to be a clear detailed-rich summary.
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
### Technical details
* Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
* Node.js version
* Anything else that you think could be an issue.
- Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
- Node.js version
- Anything else that you think could be an issue.

View File

@@ -1,36 +1,41 @@
---
name: Feature request
about: Suggest a feature or idea
title: ''
labels: enhancement
assignees: ''
title: ""
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?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)

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,9 +0,0 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
commit-message:
prefix: "⬆️"
open-pull-requests-limit: 1

View File

@@ -1,18 +1,23 @@
name: Build
on: [push]
name: Check types
on:
pull_request:
branches:
- main
jobs:
build:
name: Build on Node ${{ matrix.node }} and ${{ matrix.os }}
types:
name: Check types
runs-on: ${{ matrix.os }}
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
@@ -22,11 +27,4 @@ jobs:
- 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 test
- run: yarn build
- run: yarn check-changed-files

View File

@@ -0,0 +1,23 @@
name: Cron - bookingReminder
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
- cron: "0,15,30,45 * * * *"
jobs:
cron:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/bookingReminder \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail

View File

@@ -0,0 +1,23 @@
name: Cron - downgradeUsers
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
- cron: "0,15,30,45 * * * *"
jobs:
cron:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/downgradeUsers \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail

25
.github/workflows/crowdin.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Crowdin Action
on:
push:
branches:
- main
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: crowdin action
uses: crowdin/github-action@1.4.2
with:
upload_translations: true
download_translations: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -1,17 +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
NODE_ENV: test
BASE_URL: http://localhost:3000
# GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
JWT_SECRET: secret
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
@@ -38,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
@@ -50,14 +65,31 @@ jobs:
uses: actions/cache@v2
with:
path: ${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-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 build
- run: yarn prisma migrate deploy
- run: yarn db-seed
- run: yarn start &
- run: npx wait-port 3000 --timeout 10000
- run: yarn cypress run
- run: yarn test
- run: yarn build
- name: Cache playwright binaries
uses: actions/cache@v2
id: playwright-cache
with:
path: |
~/Library/Caches/ms-playwright
~/.cache/ms-playwright
**/node_modules/playwright
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
- name: Install playwright deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps
- run: yarn test-playwright
- name: Upload videos
if: ${{ always() }}
@@ -65,5 +97,6 @@ jobs:
with:
name: videos
path: |
cypress/videos
cypress/screenshots
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

11
.gitignore vendored
View File

@@ -11,6 +11,11 @@
# testing
/coverage
.nyc_output
playwright/videos
playwright/screenshots
playwright/artifacts
playwright/results
# next.js
/.next/
@@ -33,6 +38,7 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
.env.*
# vercel
.vercel
@@ -51,6 +57,5 @@ yarn-error.log*
# Local History for Visual Studio Code
.history/
cypress/videos
cypress/screenshots
# Typescript
tsconfig.tsbuildinfo

View File

@@ -4,4 +4,5 @@ version = 1
autoupdate_label = "♻️ autoupdate"
[approve]
auto_approve_usernames = ["dependabot"]
auto_approve_usernames = ["dependabot", "PeerRich", "baileypumfleet"]

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
# used in tandem with package.json engine to only enable yarn
engine-strict=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
14.17

View File

@@ -3,4 +3,13 @@ node_modules
public
**/**/node_modules
**/**/.next
**/**/public
**/**/public
*.lock
*.log
.gitignore
.npmignore
.prettierignore
.DS_Store
.eslintignore

View File

@@ -7,4 +7,6 @@ module.exports = {
semi: true,
printWidth: 110,
arrowParens: "always",
importOrder: ["^@ee/(.*)$", "^@lib/(.*)$", "^@components/(.*)$", "^@(server|trpc)/(.*)$", "^[./]"],
importOrderSeparation: true,
};

1
.vercelignore Normal file
View File

@@ -0,0 +1 @@
.github

View File

@@ -4,6 +4,7 @@
"esbenp.prettier-vscode", // prettier plugin
"dbaeumer.vscode-eslint", // eslint plugin
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
"heybourn.headwind" // automatically sort tailwind classes in predictable order, kinda like "prettier for tailwind"
"heybourn.headwind", // automatically sort tailwind classes in predictable order, kinda like "prettier for tailwind",
"stripe.vscode-stripe" // stripe VSCode extension
]
}

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@@ -5,5 +5,6 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.run": "onSave"
"eslint.run": "onSave",
"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>
@@ -13,7 +13,7 @@
<a href="https://cal.com"><strong>Learn more »</strong></a>
<br />
<br />
<a href="https://join.slack.com/t/calendso/shared_invite/zt-mem978vn-RgOEELhA5bcnoGONxDCiHw">Slack</a>
<a href="https://cal.com/slack">Slack</a>
·
<a href="https://cal.com">Website</a>
·
@@ -26,17 +26,18 @@
<a href="https://www.producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
<a href="https://github.com/calendso/calendso/stargazers"><img src="https://img.shields.io/github/stars/calendso/calendso" alt="Github Stars"></a>
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
<img src="https://img.shields.io/github/license/calendso/calendso" alt="License">
<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>
<!-- ABOUT THE PROJECT -->
## About The Project
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/133429837-69ac8554-4c9c-43f9-90dd-c3337002d8ff.png">
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/134363898-4b29e18f-3e61-42b7-95bc-10891056249d.gif">
# Scheduling infrastructure for absolutely everyone.
@@ -82,17 +83,20 @@ Here is what you need to be able to run Cal.
You will also need Google API credentials. You can get this from the [Google API Console](https://console.cloud.google.com/apis/dashboard). More details on this can be found below under the [Obtaining the Google API Credentials section](#Obtaining-the-Google-API-Credentials).
### Development Setup
## 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
```
@@ -143,7 +147,7 @@ yarn dx
5. Set up the database using the Prisma schema (found in `prisma/schema.prisma`)
```sh
npx prisma db push
npx prisma migrate deploy
```
6. Run (in development mode)
```sh
@@ -154,10 +158,19 @@ 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.
### E2E-Testing
```bash
# In first terminal
yarn dx
# In second terminal
yarn test-playwright
```
### Upgrading from earlier versions
1. Pull the current version:
@@ -207,6 +220,8 @@ yarn dx
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
@@ -240,7 +255,7 @@ Contributions are what make the open source community such an amazing place to b
2. In the search box, type calendar and select the Google Calendar API search result.
3. Enable the selected API.
4. Next, go to the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) from the side pane. Select the app type (Internal or External) and enter the basic app details on the first page.
5. In the second page on Scopes, select Add or Remove Scopes. Search for Calendar.event and select the scope with scope value `.../auth/calendar.events`, `.../auth/calendar.readonly`, `.../auth/calendar` and select Update.
5. In the second page on Scopes, select Add or Remove Scopes. Search for Calendar.event and select the scope with scope value `.../auth/calendar.events`, `.../auth/calendar.readonly` and select Update.
6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
8. Select Web Application as the Application Type.
@@ -255,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
@@ -268,16 +283,23 @@ Contributions are what make the open source community such an amazing place to b
7. Click "Create".
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
10. Also add the redirect URL given above as a whitelist URL and enable "Subdomain check". Make sure, it says "saved" below the form.
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
12. Click "Done".
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
## 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.
<!-- LICENSE -->
## License
Distributed under the MIT License. See `LICENSE` for more information.
Distributed under the AGPLv3 License. See `LICENSE` for more information.
<!-- ACKNOWLEDGEMENTS -->

View File

@@ -7,20 +7,20 @@ info:
email: support@cal.com
license:
name: MIT License
url: 'https://opensource.org/licenses/MIT'
url: "https://opensource.org/licenses/MIT"
version: 1.0.0
termsOfService: 'https://cal.com/terms'
termsOfService: "https://cal.com/terms"
server:
url: 'http://localhost:{port}'
url: "http://localhost:{port}"
description: Local Development Server
variables:
port:
default: '3000'
default: "3000"
tags:
- name: Authentication
description: 'Auth routes, powered by Next-Auth.js'
description: "Auth routes, powered by Next-Auth.js"
externalDocs:
url: 'http://next-auth.js.org/'
url: "http://next-auth.js.org/"
- name: Availability
description: Checking and setting user availability
- name: Booking
@@ -38,15 +38,15 @@ paths:
summary: Displays the sign in page
tags:
- Authentication
'/api/auth/signin/:provider':
"/api/auth/signin/:provider":
post:
description: Starts an OAuth signin flow for the specified provider. The POST submission requires CSRF token from /api/auth/csrf.
summary: Starts an OAuth signin flow for the specified provider
tags:
- Authentication
'/api/auth/callback/:provider':
"/api/auth/callback/:provider":
get:
description: 'Handles returning requests from OAuth services during sign in. For OAuth 2.0 providers that support the state option, the value of the state parameter is checked against the one that was generated when the sign in flow was started - this uses a hash of the CSRF token which MUST match for both the POST and GET calls during sign in.'
description: "Handles returning requests from OAuth services during sign in. For OAuth 2.0 providers that support the state option, the value of the state parameter is checked against the one that was generated when the sign in flow was started - this uses a hash of the CSRF token which MUST match for both the POST and GET calls during sign in."
summary: Handles returning requests from OAuth services
tags:
- Authentication
@@ -103,26 +103,26 @@ paths:
summary: Reset a user's password
tags:
- Authentication
'/api/availability/{user}?dateFrom={dateFrom}&dateTo={dateTo}':
"/api/availability/{user}?dateFrom={dateFrom}&dateTo={dateTo}":
get:
description: 'Gets the busy times for a particular user, by username.'
description: "Gets the busy times for a particular user, by username."
summary: Gets the busy times for a user
tags:
- Availability
responses:
'200':
"200":
description: OK
content:
application/json:
schema:
type: array
description: ''
description: ""
minItems: 1
uniqueItems: true
x-examples:
example-1:
- start: 'Fri, 03 Sep 2021 17:00:00 GMT'
end: 'Fri, 03 Sep 2021 17:40:00 GMT'
- start: "Fri, 03 Sep 2021 17:00:00 GMT"
end: "Fri, 03 Sep 2021 17:40:00 GMT"
items:
type: object
properties:
@@ -135,7 +135,7 @@ paths:
required:
- start
- end
'500':
"500":
description: Internal Server Error
parameters:
- schema:
@@ -163,13 +163,13 @@ paths:
tags:
- Availability
responses:
'200':
"200":
description: OK
content:
application/json:
schema:
type: array
description: ''
description: ""
minItems: 1
uniqueItems: true
items:
@@ -221,7 +221,7 @@ paths:
externalId: c_feunmui1m8el5o1oo885fu48k8@group.calendar.google.com
integration: google_calendar
name: 1.0 Launch
'500':
"500":
description: Internal Server Error
post:
description: Adds a selected calendar for the user.
@@ -229,7 +229,7 @@ paths:
tags:
- Availability
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -238,7 +238,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
requestBody:
content:
@@ -256,7 +256,7 @@ paths:
tags:
- Availability
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -265,7 +265,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
requestBody:
content:
@@ -284,7 +284,7 @@ paths:
tags:
- Availability
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -305,7 +305,7 @@ paths:
type: string
bufferMins:
type: string
description: ''
description: ""
/api/availability/eventtype:
post:
description: Adds a new event type for the user.
@@ -339,7 +339,7 @@ paths:
type: array
items: {}
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -369,7 +369,7 @@ paths:
customInputs:
type: array
items: {}
'500':
"500":
description: Internal Server Error
patch:
description: Updates an event type for the user.
@@ -403,7 +403,7 @@ paths:
type: array
items: {}
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -433,7 +433,7 @@ paths:
customInputs:
type: array
items: {}
'500':
"500":
description: Internal Server Error
delete:
description: Deletes an event type for the user.
@@ -441,16 +441,16 @@ paths:
tags:
- Availability
responses:
'200':
"200":
description: OK
content:
application/json:
schema:
type: object
properties: {}
'500':
"500":
description: Internal Server Error
'/api/book/{user}':
"/api/book/event":
post:
description: Creates a booking in the user's calendar.
summary: Creates a booking for a user
@@ -480,12 +480,19 @@ paths:
guests:
type: array
items: {}
users:
type: array
items: {}
user:
type: string
notes:
type: string
location:
type: string
paymentUid:
type: string
responses:
'204':
"204":
description: No Content
content:
application/json:
@@ -494,7 +501,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
content:
application/json:
@@ -528,7 +535,7 @@ paths:
confirmed:
type: string
responses:
'204':
"204":
description: No Content
content:
application/json:
@@ -537,7 +544,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
/api/integrations:
get:
@@ -546,12 +553,12 @@ paths:
tags:
- Integrations
responses:
'200':
"200":
description: OK
content:
application/json:
schema:
description: ''
description: ""
type: object
x-examples:
example-1:
@@ -562,7 +569,7 @@ paths:
id: 83
type: google_calendar
key:
scope: 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events'
scope: "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
token_type: Bearer
expiry_date: 1630838974808
access_token: ya29.a0ARrdaM89R686rUyBBluTuD69oQ6WIIjjMa2xjJ0qe_5u-9ShDL09KNN1mCYoks3NP54FUMzYKmqTzb8nzCJX9jlNKP7X7-gukO4--HUyfOUbFHlHbfQ2Ei05F8AQn_xS0E_awhDgyn2anvrvEw72U3_65Zi4v6Y
@@ -660,7 +667,7 @@ paths:
- description
required:
- pageProps
'500':
"500":
description: Internal Server Error
content:
application/json:
@@ -683,7 +690,7 @@ paths:
id:
type: number
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -692,7 +699,7 @@ paths:
properties:
message:
type: string
'401':
"401":
description: Unauthorized
content:
application/json:
@@ -701,7 +708,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
content:
application/json:
@@ -773,7 +780,7 @@ paths:
theme:
type: string
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -782,7 +789,7 @@ paths:
properties:
message:
type: string
'401':
"401":
description: Unauthorized
content:
application/json:
@@ -791,7 +798,41 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
content:
application/json:
schema:
type: object
properties:
message:
type: string
/api/me:
get:
description: Gets current user's profile.
summary: Gets current user's profile.
tags:
- User
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
message:
type: string
"401":
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
message:
type: string
"500":
description: Internal Server Error
content:
application/json:
@@ -812,7 +853,7 @@ paths:
schema:
type: object
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -821,7 +862,7 @@ paths:
properties:
message:
type: string
'401':
"401":
description: Unauthorized
content:
application/json:
@@ -830,7 +871,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
content:
application/json:
@@ -865,7 +906,7 @@ paths:
properties:
teamId:
type: string
'/api/{team}':
"/api/{team}":
delete:
description: Deletes a team
summary: Deletes a team
@@ -873,9 +914,9 @@ paths:
- Teams
parameters: []
responses:
'204':
"204":
description: No Content
'401':
"401":
description: Unauthorized
content:
application/json:
@@ -884,7 +925,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
content:
application/json:
@@ -900,7 +941,7 @@ paths:
in: path
required: true
description: The team which you wish to modify
'/api/{team}/invite':
"/api/{team}/invite":
post:
description: Invites someone to a team.
summary: Invites someone to a team
@@ -926,7 +967,7 @@ paths:
in: path
required: true
description: The team which you wish to send the invite for
'/api/{team}/membership':
"/api/{team}/membership":
get:
description: Lists the members of a team.
summary: Lists members of a team
@@ -934,7 +975,7 @@ paths:
- Teams
parameters: []
responses:
'200':
"200":
description: OK
content:
application/json:
@@ -944,7 +985,7 @@ paths:
members:
type: array
items: {}
'401':
"401":
description: Unauthorized
content:
application/json:
@@ -953,7 +994,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
content:
application/json:
@@ -976,14 +1017,14 @@ paths:
userId:
type: number
responses:
'200':
"200":
description: OK
content:
application/json:
schema:
type: object
properties: {}
'401':
"401":
description: Unauthorized
content:
application/json:
@@ -992,7 +1033,7 @@ paths:
properties:
message:
type: string
'500':
"500":
description: Internal Server Error
content:
application/json:
@@ -1009,7 +1050,7 @@ paths:
required: true
description: The team which you wish to list members of
servers:
- url: 'https://app.cal.com'
- url: "https://app.cal.com"
description: Production
components:
securitySchemes: {}

View File

@@ -1,22 +0,0 @@
import { useRouter } from "next/router";
import Link from "next/link";
import React, { Children } from "react";
const ActiveLink = ({ children, activeClassName, ...props }) => {
const { asPath } = useRouter();
const child = Children.only(children);
const childClassName = child.props.className || "";
const className =
asPath === props.href || asPath === props.as
? `${childClassName} ${activeClassName}`.trim()
: childClassName;
return <Link {...props}>{React.cloneElement(child, { className })}</Link>;
};
ActiveLink.defaultProps = {
activeClassName: "active",
} as Partial<Props>;
export default ActiveLink;

View File

@@ -0,0 +1,51 @@
import { XIcon } from "@heroicons/react/outline";
import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
export default function AddToHomescreen() {
const { t } = useLocale();
const [closeBanner, setCloseBanner] = useState(false);
if (typeof window !== "undefined") {
if (window.matchMedia("(display-mode: standalone)").matches) {
return null;
}
}
return !closeBanner ? (
<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 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="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">
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z" />
<path d="M24 7h2v21h-2z" />
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
</svg>
</span>
<p className="ml-3 text-xs font-medium text-white">
<span className="inline">{t("add_to_homescreen")}</span>
</p>
</div>
<div className="flex-shrink-0 order-2 sm:order-3 sm:ml-2">
<button
onClick={() => setCloseBanner(true)}
type="button"
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="w-6 h-6 text-white" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</div>
) : null;
}

View File

@@ -0,0 +1,30 @@
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
import NavTabs from "./NavTabs";
export default function BookingsShell({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const tabs = [
{
name: t("upcoming"),
href: "/bookings/upcoming",
},
{
name: t("past"),
href: "/bookings/past",
},
{
name: t("cancelled"),
href: "/bookings/cancelled",
},
];
return (
<>
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
<main>{children}</main>
</>
);
}

View File

@@ -0,0 +1,9 @@
import { Suspense, SuspenseProps } from "react";
/**
* Wrapper around `<Suspense />` which will render the `fallback` when on server
* Can be simply replaced by `<Suspense />` once React 18 is ready.
*/
export const ClientSuspense = (props: SuspenseProps) => {
return <>{typeof window !== "undefined" ? <Suspense {...props} /> : props.fallback}</>;
};

View File

@@ -0,0 +1,40 @@
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;
};
export default BrandColor;

View File

@@ -1,12 +1,12 @@
import React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { ReactNode } from "react";
type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
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>
@@ -25,19 +25,25 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
);
type DialogHeaderProps = {
title: React.ReactElement | string;
subtitle: React.ReactElement | string;
title: React.ReactNode;
subtitle?: React.ReactNode;
};
export function DialogHeader({ title, subtitle }: DialogHeaderProps) {
export function DialogHeader(props: DialogHeaderProps) {
return (
<div className="mb-8">
<h3 className="text-gray-900 text-lg font-bold leading-6" id="modal-title">
{title}
<h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
{props.title}
</h3>
<div>
<p className="text-gray-400 text-sm">{subtitle}</p>
</div>
{props.subtitle && <div className="text-sm text-gray-400">{props.subtitle}</div>}
</div>
);
}
export function DialogFooter(props: { children: ReactNode }) {
return (
<div>
<div className="flex justify-end mt-5 space-x-2">{props.children}</div>
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { GiftIcon } from "@heroicons/react/outline";
export default function DonateBanner() {
if (location.hostname.endsWith(".cal.com")) {
return null;
}
return (
<>
<div className="h-12" />
<div className="fixed inset-x-0 bottom-0">
<div className="bg-blue-600">
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
<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-blue-600">
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
</span>
<p className="ml-3 font-medium text-white truncate">
<span className="md:hidden">Support the ongoing development</span>
<span className="hidden md:inline">
You&apos;re using the free self-hosted version. Support the ongoing development by making
a donation.
</span>
</p>
</div>
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
<a
target="_blank"
rel="noreferrer"
href="https://cal.com/donate"
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50">
Donate
</a>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import { SVGComponent } from "@lib/types/SVGComponent";
export default function EmptyScreen({
Icon,
headline,
description,
}: {
Icon: SVGComponent;
headline: string;
description: string;
}) {
return (
<>
<div className="min-h-80 border border-dashed rounded-sm flex justify-center items-center flex-col my-6">
<div className="bg-white w-[72px] h-[72px] flex justify-center items-center rounded-full">
<Icon className="inline-block w-10 h-10 bg-white" />
</div>
<div className="max-w-[420px] text-center">
<h2 className="text-lg font-medium mt-6 mb-1">{headline}</h2>
<p className="text-sm leading-6 text-gray-600">{description}</p>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { trpc } from "@lib/trpc";
export function useViewerI18n() {
return trpc.useQuery(["viewer.i18n"], {
staleTime: Infinity,
});
}
/**
* Auto-switches locale client-side to the logged in user's preference
*/
const I18nLanguageHandler = (): null => {
const { i18n } = useTranslation("common");
const locale = useViewerI18n().data?.locale;
useEffect(() => {
if (locale && i18n.language && i18n.language !== locale) {
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
}
}, [locale, i18n]);
return null;
};
export default I18nLanguageHandler;

View File

@@ -1,215 +1,164 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import Cropper from "react-easy-crop";
import { useCallback, useRef, useState } from "react";
import Slider from "./Slider";
export default function ImageUploader({ target, id, buttonMsg, handleAvatarChange, imageRef }) {
const imageFileRef = useRef<HTMLInputElement>();
const [imageDataUrl, setImageDataUrl] = useState<string>();
const [croppedAreaPixels, setCroppedAreaPixels] = useState();
const [rotation] = useState(1);
import { Area, getCroppedImg } from "@lib/cropImage";
import { useFileReader } from "@lib/hooks/useFileReader";
import { useLocale } from "@lib/hooks/useLocale";
import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@components/Dialog";
import Slider from "@components/Slider";
import Button from "@components/ui/Button";
type ImageUploaderProps = {
id: string;
buttonMsg: string;
handleAvatarChange: (imageSrc: string) => void;
imageSrc?: string;
target: string;
};
interface FileEvent<T = Element> extends FormEvent<T> {
target: EventTarget & T;
}
// This is separate to prevent loading the component until file upload
function CropContainer({
onCropComplete,
imageSrc,
}: {
imageSrc: string;
onCropComplete: (croppedAreaPixels: Area) => void;
}) {
const { t } = useLocale();
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [imageLoaded, setImageLoaded] = useState(false);
const [isImageShown, setIsImageShown] = useState(false);
const [shownImage, setShownImage] = useState<string>();
const [imageUploadModalOpen, setImageUploadModalOpen] = useState(false);
const openUploaderModal = () => {
imageRef ? (setIsImageShown(true), setShownImage(imageRef)) : setIsImageShown(false);
setImageUploadModalOpen(!imageUploadModalOpen);
};
const closeImageUploadModal = () => {
setImageUploadModalOpen(false);
};
async function ImageUploadHandler() {
const img = await readFile(imageFileRef.current.files[0]);
setImageDataUrl(img);
CropHandler();
}
const readFile = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(reader.result), false);
reader.readAsDataURL(file);
});
};
const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);
const CropHandler = () => {
setCrop({ x: 0, y: 0 });
setZoom(1);
setImageLoaded(true);
};
const handleZoomSliderChange = ([value]) => {
const handleZoomSliderChange = (value: number) => {
value < 1 ? setZoom(1) : setZoom(value);
};
const createImage = (url) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", (error) => reject(error));
image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
function getRadianAngle(degreeValue) {
return (degreeValue * Math.PI) / 180;
}
async function getCroppedImg(imageSrc, pixelCrop, rotation = 0) {
const image = await createImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const maxSize = Math.max(image.width, image.height);
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea;
canvas.height = safeArea;
// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2);
ctx.rotate(getRadianAngle(rotation));
ctx.translate(-safeArea / 2, -safeArea / 2);
// draw rotated image and store data.
ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
const data = ctx.getImageData(0, 0, safeArea, safeArea);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image with correct offsets for x,y crop values.
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
// As Base64 string
return canvas.toDataURL("image/jpeg");
}
const showCroppedImage = useCallback(async () => {
try {
const croppedImage = await getCroppedImg(imageDataUrl, croppedAreaPixels, rotation);
setIsImageShown(true);
setShownImage(croppedImage);
setImageLoaded(false);
handleAvatarChange(croppedImage);
closeImageUploadModal();
} catch (e) {
console.error(e);
}
}, [croppedAreaPixels, rotation]);
return (
<div className="flex justify-center items-center">
<button
type="button"
className="ml-4 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;"
onClick={openUploaderModal}>
{buttonMsg}
</button>
{imageUploadModalOpen && (
<div
className="fixed z-10 inset-0 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="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
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-sm px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
Upload {target}
</h3>
</div>
</div>
<div className="mb-4">
<div className="cropper mt-6 flex flex-col justify-center items-center p-8 bg-gray-50">
{!imageLoaded && (
<div className="flex justify-start items-center bg-gray-500 max-h-20 h-20 w-20 rounded-full">
{!isImageShown && (
<p className="sm:text-xs text-sm text-white w-full text-center">No {target}</p>
)}
{isImageShown && (
<img className="h-20 w-20 rounded-full" src={shownImage} alt={target} />
)}
</div>
)}
{imageLoaded && (
<div className="crop-container max-h-40 h-40 w-40 rounded-full">
<div className="relative h-40 w-40 rounded-full">
<Cropper
image={imageDataUrl}
crop={crop}
zoom={zoom}
aspect={1 / 1}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
label="Slide to zoom, drag to reposition"
changeHandler={handleZoomSliderChange}
/>
</div>
)}
<label
htmlFor={id}
className="mt-8 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;">
Choose a file...
</label>
<input
onChange={ImageUploadHandler}
ref={imageFileRef}
type="file"
id={id}
name={id}
placeholder="Upload image"
className="mt-4 pointer-events-none opacity-0 absolute"
accept="image/*"
/>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="button" className="btn btn-primary" onClick={showCroppedImage}>
Save
</button>
<button onClick={closeImageUploadModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</div>
</div>
</div>
)}
<div className="w-40 h-40 rounded-full crop-container max-h-40">
<div className="relative w-40 h-40 rounded-full">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onCropComplete={(croppedArea, croppedAreaPixels) => onCropComplete(croppedAreaPixels)}
onZoomChange={setZoom}
/>
</div>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
label={t("slide_zoom_drag_instructions")}
changeHandler={handleZoomSliderChange}
/>
</div>
);
}
export default function ImageUploader({
target,
id,
buttonMsg,
handleAvatarChange,
...props
}: ImageUploaderProps) {
const { t } = useLocale();
const [imageSrc, setImageSrc] = useState<string | null>();
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>();
const [{ result }, setFile] = useFileReader({
method: "readAsDataURL",
});
useEffect(() => {
setImageSrc(props.imageSrc);
}, [props.imageSrc]);
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
setFile(e.target.files[0]);
};
const showCroppedImage = useCallback(
async (croppedAreaPixels) => {
try {
const croppedImage = await getCroppedImg(
result as string /* result is always string when using readAsDataUrl */,
croppedAreaPixels
);
setImageSrc(croppedImage);
handleAvatarChange(croppedImage);
} catch (e) {
console.error(e);
}
},
[result, handleAvatarChange]
);
return (
<Dialog
onOpenChange={
(opened) => !opened && setFile(null) // unset file on close
}>
<DialogTrigger asChild>
<div className="flex items-center">
<Button color="secondary" type="button" className="py-1 text-xs">
{buttonMsg}
</Button>
</div>
</DialogTrigger>
<DialogContent>
<div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
{t("upload_target", { target })}
</h3>
</div>
</div>
<div className="mb-4">
<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-50 rounded-full max-h-20">
{!imageSrc && (
<p className="w-full text-sm text-center text-white sm:text-xs">
{t("no_target", { target })}
</p>
)}
{imageSrc && <img className="w-20 h-20 rounded-full" src={imageSrc} alt={target} />}
</div>
)}
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
<label className="px-3 py-1 mt-8 text-xs font-medium leading-4 text-gray-700 bg-white border border-gray-300 rounded-sm hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900 dark:bg-transparent dark:text-white dark:border-gray-800 dark:hover:bg-gray-900">
<input
onInput={onInputFile}
type="file"
name={id}
placeholder={t("upload_image")}
className="absolute mt-4 opacity-0 pointer-events-none"
accept="image/*"
/>
{t("choose_a_file")}
</label>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2">
<DialogClose asChild>
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</Button>
</DialogClose>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
}

72
components/List.tsx Normal file
View File

@@ -0,0 +1,72 @@
import Link from "next/link";
import { createElement } from "react";
import classNames from "@lib/classNames";
export function List(props: JSX.IntrinsicElements["ul"]) {
return (
<ul {...props} className={classNames("sm:overflow-hidden rounded-sm sm:mx-0 -mx-4", props.className)}>
{props.children}
</ul>
);
}
export type ListItemProps = { expanded?: boolean } & ({ href?: never } & JSX.IntrinsicElements["li"]);
export function ListItem(props: ListItemProps) {
const { href, expanded, ...passThroughProps } = props;
const elementType = href ? "a" : "li";
const element = createElement(
elementType,
{
...passThroughProps,
className: classNames(
"items-center bg-white min-w-0 flex-1 flex border-gray-200",
expanded ? "my-2 border" : "border -mb-px last:mb-0",
props.className,
(props.onClick || href) && "hover:bg-neutral-50"
),
},
props.children
);
return href ? (
<Link passHref href={href}>
{element}
</Link>
) : (
element
);
}
export function ListItemTitle<TComponent extends keyof JSX.IntrinsicElements = "span">(
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
) {
const { component = "span", ...passThroughProps } = props;
return createElement(
component,
{
...passThroughProps,
className: classNames("text-sm font-medium text-neutral-900 truncate", props.className),
},
props.children
);
}
export function ListItemText<TComponent extends keyof JSX.IntrinsicElements = "span">(
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
) {
const { component = "span", ...passThroughProps } = props;
return createElement(
component,
{
...passThroughProps,
className: classNames("text-sm text-gray-500 truncate", props.className),
},
props.children
);
}

View File

@@ -1,7 +1,7 @@
export default function Loader() {
return (
<div className="loader border-black dark:border-white">
<span className="loader-inner bg-black dark:bg-white"></span>
<div className="loader border-brand dark:border-white">
<span className="loader-inner bg-brand dark:bg-white"></span>
</div>
);
}

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,82 +0,0 @@
/* legacy and soon deprecated, please refactor to use <Dialog> only */
import { Fragment, ReactNode } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { CheckIcon, InformationCircleIcon } from "@heroicons/react/outline";
import classNames from "@lib/classNames";
export default function Modal(props: {
heading: ReactNode;
description: ReactNode;
handleClose: () => void;
open: boolean;
variant?: "success" | "warning";
}) {
const { variant = "success" } = props;
return (
<Transition.Root show={props.open} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-50 inset-0 overflow-y-auto"
open={props.open}
onClose={props.handleClose}>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<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-sm sm:w-full sm:p-6">
<div>
<div
className={classNames(
"mx-auto flex items-center justify-center h-12 w-12 rounded-full",
variant === "success" && "bg-green-100",
variant === "warning" && "bg-yellow-100"
)}>
{variant === "success" && (
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
)}
{variant === "warning" && (
<InformationCircleIcon className={"h-6 w-6 text-yellow-400"} aria-hidden="true" />
)}
</div>
<div className="mt-3 text-center sm:mt-5">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{props.heading}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">{props.description}</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<button type="button" className="btn-wide btn-primary" onClick={() => props.handleClose()}>
Dismiss
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
}

53
components/NavTabs.tsx Normal file
View File

@@ -0,0 +1,53 @@
import Link, { LinkProps } from "next/link";
import { useRouter } from "next/router";
import React, { ElementType, FC } from "react";
import classNames from "@lib/classNames";
interface Props {
tabs: {
name: string;
href: string;
icon?: ElementType;
}[];
linkProps?: Omit<LinkProps, "href">;
}
const NavTabs: FC<Props> = ({ tabs, linkProps }) => {
const router = useRouter();
return (
<>
<nav className="-mb-px flex space-x-2 sm:space-x-5" aria-label="Tabs">
{tabs.map((tab) => {
const isCurrent = router.asPath === tab.href;
return (
<Link {...linkProps} key={tab.name} href={tab.href}>
<a
className={classNames(
isCurrent
? "border-neutral-900 text-neutral-900"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm"
)}
aria-current={isCurrent ? "page" : undefined}>
{tab.icon && (
<tab.icon
className={classNames(
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
"-ml-0.5 mr-2 h-5 w-5 hidden sm:inline-block"
)}
aria-hidden="true"
/>
)}
<span>{tab.name}</span>
</a>
</Link>
);
})}
</nav>
<hr />
</>
);
};
export default NavTabs;

View File

@@ -1,68 +0,0 @@
import Link from "next/link";
import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import classNames from "@lib/classNames";
export default function SettingsShell(props) {
const router = useRouter();
const tabs = [
{
name: "Profile",
href: "/settings/profile",
icon: UserIcon,
current: router.pathname == "/settings/profile",
},
{
name: "Security",
href: "/settings/security",
icon: KeyIcon,
current: router.pathname == "/settings/security",
},
{ name: "Embed", href: "/settings/embed", icon: CodeIcon, current: router.pathname == "/settings/embed" },
{
name: "Teams",
href: "/settings/teams",
icon: UserGroupIcon,
current: router.pathname == "/settings/teams",
},
{
name: "Billing",
href: "/settings/billing",
icon: CreditCardIcon,
current: router.pathname == "/settings/billing",
},
];
return (
<div>
<div className="sm:mx-auto">
<nav className="-mb-px flex space-x-2 sm:space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<Link key={tab.name} href={tab.href}>
<a
className={classNames(
tab.current
? "border-neutral-900 text-neutral-900"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm"
)}
aria-current={tab.current ? "page" : undefined}>
<tab.icon
className={classNames(
tab.current ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
"-ml-0.5 mr-2 h-5 w-5 hidden sm:inline-block"
)}
aria-hidden="true"
/>
<span>{tab.name}</span>
</a>
</Link>
))}
</nav>
<hr />
</div>
<main className="max-w-4xl">{props.children}</main>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
import NavTabs from "./NavTabs";
export default function SettingsShell({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const tabs = [
{
name: t("profile"),
href: "/settings/profile",
icon: UserIcon,
},
{
name: t("security"),
href: "/settings/security",
icon: KeyIcon,
},
{
name: t("teams"),
href: "/settings/teams",
icon: UserGroupIcon,
},
{
name: t("billing"),
href: "/settings/billing",
icon: CreditCardIcon,
},
];
return (
<>
<div className="sm:mx-auto">
<NavTabs tabs={tabs} />
</div>
<main className="max-w-4xl">{children}</main>
</>
);
}

View File

@@ -1,84 +1,201 @@
import Link from "next/link";
import React, { Fragment, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { signOut, useSession } from "next-auth/client";
// TODO: replace headlessui with radix-ui
import { Menu, Transition } from "@headlessui/react";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { SelectorIcon } from "@heroicons/react/outline";
import {
CalendarIcon,
ChatAltIcon,
ArrowLeftIcon,
ClockIcon,
CogIcon,
ExternalLinkIcon,
LinkIcon,
LogoutIcon,
PuzzleIcon,
MoonIcon,
MapIcon,
} from "@heroicons/react/solid";
import Logo from "./Logo";
import classNames from "@lib/classNames";
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 Avatar from "@components/ui/Avatar";
import { User } from "@prisma/client";
import { HeadSeo } from "@components/seo/head-seo";
export default function Shell(props) {
import LicenseBanner from "@ee/components/LicenseBanner";
import TrialBanner from "@ee/components/TrialBanner";
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
import classNames from "@lib/classNames";
import { shouldShowOnboarding } from "@lib/getting-started";
import { useLocale } from "@lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { trpc } from "@lib/trpc";
import CustomBranding from "@components/CustomBranding";
import Loader from "@components/Loader";
import { HeadSeo } from "@components/seo/head-seo";
import Avatar from "@components/ui/Avatar";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/ui/Dropdown";
import { useViewerI18n } from "./I18nLanguageHandler";
import Logo from "./Logo";
import Button from "./ui/Button";
export function useMeQuery() {
const meQuery = trpc.useQuery(["viewer.me"], {
retry(failureCount) {
return failureCount > 3;
},
});
return meQuery;
}
function useRedirectToLoginIfUnauthenticated() {
const { data: session, status } = useSession();
const loading = status === "loading";
const router = useRouter();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
useEffect(() => {
if (!loading && !session) {
router.replace({
pathname: "/auth/login",
query: {
callbackUrl: `${location.pathname}${location.search}`,
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, session]);
return {
loading: loading && !session,
};
}
function useRedirectToOnboardingIfNeeded() {
const router = useRouter();
const query = useMeQuery();
const user = query.data;
const [isRedirectingToOnboarding, setRedirecting] = useState(false);
useEffect(() => {
if (user && shouldShowOnboarding(user)) {
setRedirecting(true);
}
}, [router, user]);
useEffect(() => {
if (isRedirectingToOnboarding) {
router.replace({
pathname: "/getting-started",
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRedirectingToOnboarding]);
return {
isRedirectingToOnboarding,
};
}
export function ShellSubHeading(props: {
title: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={classNames("block sm:flex justify-between mb-3", props.className)}>
<div>
<h2 className="flex items-center content-center space-x-2 text-base font-bold leading-6 text-gray-900">
{props.title}
</h2>
{props.subtitle && <p className="mr-4 text-sm text-neutral-500">{props.subtitle}</p>}
</div>
{props.actions && <div className="flex-shrink-0">{props.actions}</div>}
</div>
);
}
export default function Shell(props: {
centered?: boolean;
title?: string;
heading: ReactNode;
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();
const { loading } = useRedirectToLoginIfUnauthenticated();
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
const telemetry = useTelemetry();
const navigation = [
{
name: "Event Types",
name: t("event_types_page_title"),
href: "/event-types",
icon: LinkIcon,
current: router.pathname.startsWith("/event-types"),
current: router.asPath.startsWith("/event-types"),
},
{
name: "Bookings",
href: "/bookings",
icon: ClockIcon,
current: router.pathname.startsWith("/bookings"),
},
{
name: "Availability",
href: "/availability",
name: t("bookings"),
href: "/bookings/upcoming",
icon: CalendarIcon,
current: router.pathname.startsWith("/availability"),
current: router.asPath.startsWith("/bookings"),
},
{
name: "Integrations",
name: t("availability"),
href: "/availability",
icon: ClockIcon,
current: router.asPath.startsWith("/availability"),
},
{
name: t("integrations"),
href: "/integrations",
icon: PuzzleIcon,
current: router.pathname.startsWith("/integrations"),
current: router.asPath.startsWith("/integrations"),
},
{
name: "Settings",
name: t("settings"),
href: "/settings/profile",
icon: CogIcon,
current: router.pathname.startsWith("/settings"),
current: router.asPath.startsWith("/settings"),
},
];
useEffect(() => {
telemetry.withJitsu((jitsu) => {
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname));
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
});
}, [telemetry]);
if (!loading && !session) {
router.replace("/auth/login");
}
}, [telemetry, router.asPath]);
const pageTitle = typeof props.heading === "string" ? props.heading : props.title;
return session ? (
const query = useMeQuery();
const user = query.data;
const i18n = useViewerI18n();
if (i18n.status === "loading" || isRedirectingToOnboarding || loading) {
// show spinner whilst i18n is loading to avoid language flicker
return (
<div className="absolute z-50 flex items-center w-full h-screen bg-gray-50">
<Loader />
</div>
);
}
return (
<>
<CustomBranding val={user?.brandColor} />
<HeadSeo
title={pageTitle}
description={props.subtitle}
title={pageTitle ?? "Cal.com"}
description={props.subtitle ? props.subtitle?.toString() : ""}
nextSeoProps={{
nofollow: true,
noindex: true,
@@ -88,19 +205,23 @@ export default function Shell(props) {
<Toaster position="bottom-right" />
</div>
<div className="h-screen flex overflow-hidden bg-gray-100">
{/* Static sidebar for desktop */}
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-56">
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<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-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="mt-5 flex-1 px-2 bg-white space-y-1">
{/* 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
@@ -119,57 +240,88 @@ export default function Shell(props) {
)}
aria-hidden="true"
/>
{item.name}
<span className="hidden lg:inline">{item.name}</span>
</a>
</Link>
))}
</nav>
</div>
<div className="flex-shrink-0 flex p-4">
<UserDropdown />
<TrialBanner />
<div className="p-2 pt-2 pr-2 m-2 rounded-sm hover:bg-gray-100">
<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 w-0 flex-1 overflow-hidden">
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none">
<div className="flex flex-col flex-1 w-0 overflow-hidden">
<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="md:hidden bg-white shadow p-4 flex justify-between items-center">
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
<Link href="/event-types">
<a>
<Logo />
</a>
</Link>
<div className="flex gap-3 items-center self-center">
<button className="bg-white p-2 rounded-full text-gray-400 hover:text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span className="sr-only">View notifications</span>
<div className="flex items-center self-center gap-3">
<button className="p-2 text-gray-400 bg-white rounded-full hover:text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
<span className="sr-only">{t("view_notifications")}</span>
<Link href="/settings/profile">
<a>
<CogIcon className="h-6 w-6" aria-hidden="true" />
<CogIcon className="w-6 h-6" aria-hidden="true" />
</a>
</Link>
</button>
<div className="mt-1">
<UserDropdown small bottom session={session} />
</div>
<UserDropdown small />
</div>
</nav>
<div className="py-8">
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8">
<div className="mb-8">
<h1 className="text-xl font-bold text-gray-900">{props.heading}</h1>
<p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p>
<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="mb-4 flex-shrink-0">{props.CTA}</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}
</h1>
<p className="mr-4 text-sm text-neutral-500">{props.subtitle}</p>
</div>
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
</div>
<div
className={classNames(
"px-4 sm:px-6 md:px-8",
props.flexChildrenContainer && "flex flex-col flex-1"
)}>
{props.children}
</div>
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
{/* show bottom navigation for md and smaller (tablet and phones) */}
<nav className="bottom-nav md:hidden flex fixed bottom-0 bg-white w-full shadow">
<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 */}
{navigation.flatMap((item, itemIdx) =>
item.name === "Settings" ? (
item.href === "/settings/profile" ? (
[]
) : (
<Link key={item.name} href={item.href}>
@@ -188,172 +340,156 @@ export default function Shell(props) {
)}
aria-hidden="true"
/>
<span>{item.name}</span>
<span className="truncate">{item.name}</span>
</a>
</Link>
)
)}
</nav>
{/* add padding to content for mobile navigation*/}
<div className="block md:hidden pt-12" />
<div className="block pt-12 md:hidden" />
</div>
<LicenseBanner />
</main>
</div>
</div>
</>
) : null;
}
function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch("/api/me")
.then((res) => res.json())
.then((responseBody) => {
setUser(responseBody.user);
});
}, []);
return (
<Menu as="div" className="w-full relative inline-block text-left">
{({ open }) => (
<>
<div>
{user && (
<Menu.Button className="group w-full rounded-md text-sm text-left font-medium text-gray-700 focus:outline-none">
<span className="flex w-full justify-between items-center">
<span className="flex min-w-0 items-center justify-between space-x-3">
<Avatar
imageSrc={user?.avatar}
displayName={user?.name}
className={classNames(
small ? "w-8 h-8" : "w-10 h-10",
"bg-gray-300 rounded-full flex-shrink-0"
)}
/>
{!small && (
<span className="flex-1 flex flex-col min-w-0">
<span className="text-gray-900 text-sm font-medium truncate">{user?.name}</span>
<span className="text-neutral-500 font-normal text-sm truncate">
/{user?.username}
</span>
</span>
)}
</span>
{!small && (
<SelectorIcon
className="flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
)}
</span>
</Menu.Button>
)}
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className={classNames(
bottom ? "origin-top top-1 right-0" : "origin-bottom bottom-14 left-0",
"w-64 z-10 absolute mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none"
)}>
<div className="py-1">
<a href={"/" + user?.username} className="flex px-4 py-2 text-sm text-neutral-500">
View public page <ExternalLinkIcon className="ml-1 mt-1 w-3 h-3 text-neutral-400" />
</a>
</div>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href="https://cal.com/slack"
target="_blank"
rel="noreferrer"
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-neutral-700",
"flex px-4 py-2 text-sm font-medium"
)}>
<svg
viewBox="0 0 2447.6 2452.5"
className={classNames(
"text-neutral-400 group-hover:text-neutral-500",
"mt-0.5 mr-3 flex-shrink-0 h-4 w-4"
)}
xmlns="http://www.w3.org/2000/svg">
<g clipRule="evenodd" fillRule="evenodd">
<path
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
fill="#9BA6B6"></path>
<path
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
fill="#9BA6B6"></path>
<path
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
fill="#9BA6B6"></path>
<path
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
fill="#9BA6B6"></path>
</g>
</svg>
Join our Slack
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href="mailto:feedback@cal.com"
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-neutral-700",
"flex px-4 py-2 text-sm font-medium"
)}>
<ChatAltIcon
className={classNames(
"text-neutral-400 group-hover:text-neutral-500",
"mr-2 flex-shrink-0 h-5 w-5"
)}
aria-hidden="true"
/>
Feedback
</a>
)}
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
"flex px-4 py-2 text-sm font-medium"
)}>
<LogoutIcon
className={classNames(
"text-neutral-400 group-hover:text-neutral-500",
"mr-2 flex-shrink-0 h-5 w-5"
)}
aria-hidden="true"
/>
Sign out
</a>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
);
}
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 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 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>
<span className="block font-normal truncate text-neutral-500">
{user?.username ? `cal.com/${user.username}` : "No public page"}
</span>
</span>
<SelectorIcon
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
target="_blank"
rel="noopener noreferrer"
href={`${process.env.NEXT_PUBLIC_APP_URL}/${user.username}`}
className="flex items-center px-4 py-2 text-sm text-gray-700">
<ExternalLinkIcon className="w-5 h-5 mr-3 text-gray-500" /> {t("view_public_page")}
</a>
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem>
<a
href="https://cal.com/slack"
target="_blank"
rel="noreferrer"
className="flex px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">
<svg
viewBox="0 0 2447.6 2452.5"
className={classNames(
"text-gray-500 group-hover:text-gray-700",
"mt-0.5 mr-3 flex-shrink-0 h-4 w-4"
)}
xmlns="http://www.w3.org/2000/svg">
<g clipRule="evenodd" fillRule="evenodd">
<path
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
fill="#9BA6B6"></path>
<path
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
fill="#9BA6B6"></path>
<path
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
fill="#9BA6B6"></path>
<path
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
fill="#9BA6B6"></path>
</g>
</svg>
{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>
<a
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
className="flex px-4 py-2 text-sm cursor-pointer hover:bg-gray-100 hover:text-gray-900">
<LogoutIcon
className={classNames("text-gray-500 group-hover:text-gray-700", "mr-2 flex-shrink-0 h-5 w-5")}
aria-hidden="true"
/>
{t("sign_out")}
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
);
}

View File

@@ -1,15 +1,22 @@
import React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import React from "react";
const Slider = ({ value, min, max, step, label, changeHandler }) => (
const Slider = ({
value,
label,
changeHandler,
...props
}: Omit<SliderPrimitive.SliderProps, "value"> & {
value: number;
label: string;
changeHandler: (value: number) => void;
}) => (
<SliderPrimitive.Root
className="slider mt-2"
min={min}
step={step}
max={max}
className="mt-2 slider"
value={[value]}
aria-label={label}
onValueChange={changeHandler}>
onValueChange={(value: number[]) => changeHandler(value[0] ?? value)}
{...props}>
<SliderPrimitive.Track className="slider-track">
<SliderPrimitive.Range className="slider-range" />
</SliderPrimitive.Track>

View File

@@ -1,5 +1,5 @@
import React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import React from "react";
export function Tooltip({
children,
@@ -9,12 +9,11 @@ export function Tooltip({
onOpenChange,
...props
}: {
[x: string]: any;
children: React.ReactNode;
content: React.ReactNode;
open: boolean;
defaultOpen: boolean;
onOpenChange: (open: boolean) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
return (
<TooltipPrimitive.Root

View File

@@ -1,91 +1,131 @@
import { ExclamationIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import { Dayjs } from "dayjs";
import Link from "next/link";
import { useRouter } from "next/router";
import { useSlots } from "@lib/hooks/useSlots";
import { ExclamationIcon } from "@heroicons/react/solid";
import React from "react";
import Loader from "@components/Loader";
import { SchedulingType } from "@prisma/client";
import React, { FC, useEffect, useState } from "react";
const AvailableTimes = ({
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { useSlots } from "@lib/hooks/useSlots";
import Loader from "@components/Loader";
type AvailableTimesProps = {
timeFormat: string;
minimumBookingNotice: number;
eventTypeId: number;
eventLength: number;
slotInterval: number | null;
date: Dayjs;
users: {
username: string | null;
}[];
schedulingType: SchedulingType | null;
};
const AvailableTimes: FC<AvailableTimesProps> = ({
date,
eventLength,
eventTypeId,
slotInterval,
minimumBookingNotice,
workingHours,
timeFormat,
users,
schedulingType,
}) => {
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:max-h-97 overflow-y-auto">
<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>{date.format("dddd")}</strong>
<span className="text-gray-500">{date.format(", DD MMMM")}</span>
<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(", D ")}
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
</span>
</span>
</div>
{!loading &&
slots?.length > 0 &&
slots.map((slot) => {
const bookingUrl = {
pathname: "book",
query: {
...router.query,
date: slot.time.format(),
type: eventTypeId,
},
};
<div className="flex-grow md:h-[364px] overflow-y-auto">
{!loading &&
slots?.length > 0 &&
slots.map((slot) => {
type BookingURL = {
pathname: string;
query: Record<string, string | number | string[] | undefined>;
};
const bookingUrl: BookingURL = {
pathname: "book",
query: {
...router.query,
date: slot.time.format(),
type: eventTypeId,
},
};
if (rescheduleUid) {
bookingUrl.query.rescheduleUid = rescheduleUid;
}
if (rescheduleUid) {
bookingUrl.query.rescheduleUid = rescheduleUid as string;
}
if (schedulingType === SchedulingType.ROUND_ROBIN) {
bookingUrl.query.user = slot.users;
}
if (schedulingType === SchedulingType.ROUND_ROBIN) {
bookingUrl.query.user = slot.users;
}
return (
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<a className="block font-medium mb-4 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
{slot.time.format(timeFormat)}
</a>
</Link>
</div>
);
})}
{!loading && !error && !slots.length && (
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
<h1 className="text-xl text-black dark:text-white">All booked today.</h1>
</div>
)}
return (
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<a
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>
</Link>
</div>
);
})}
{!loading && !error && !slots.length && (
<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>
)}
{loading && <Loader />}
{loading && <Loader />}
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">Could not load the available time slots.</p>
{error && (
<div className="p-4 border-l-4 border-yellow-400 bg-yellow-50">
<div className="flex">
<div className="flex-shrink-0">
<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>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,142 @@
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import { useMutation } from "react-query";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
function BookingListItem(booking: BookingItem) {
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const mutation = useMutation(
async (confirm: boolean) => {
const res = await fetch("/api/book/confirm", {
method: "PATCH",
body: JSON.stringify({ id: booking.id, confirmed: confirm, language: i18n.language }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new HttpError({ statusCode: res.status });
}
},
{
async onSettled() {
await utils.invalidateQueries(["viewer.bookings"]);
},
}
);
const isUpcoming = new Date(booking.endTime) >= new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const pendingActions: ActionType[] = [
{
id: "reject",
label: t("reject"),
onClick: () => mutation.mutate(false),
icon: BanIcon,
disabled: mutation.isLoading,
},
{
id: "confirm",
label: t("confirm"),
onClick: () => mutation.mutate(true),
icon: CheckIcon,
disabled: mutation.isLoading,
color: "primary",
},
];
const bookedActions: ActionType[] = [
{
id: "cancel",
label: t("cancel"),
href: `/cancel/${booking.uid}`,
icon: XIcon,
},
{
id: "reschedule",
label: t("reschedule"),
href: `/reschedule/${booking.uid}`,
icon: ClockIcon,
},
];
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
return (
<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={"pl-4 py-4 flex-1" + (booking.rejected ? " line-through" : "")}>
<div className="sm:hidden">
{!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">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</small>
</div>
</div>
<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 && (
<Tag className="hidden ml-2 sm:inline-flex">{t("unconfirmed")}</Tag>
)}
</div>
{booking.description && (
<div className="text-sm text-gray-500 truncate max-w-52 md:max-w-96" title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<div className="text-sm text-gray-900 hover:text-blue-500">
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
</div>
)}
</td>
<td className="py-4 pr-4 text-sm font-medium text-right whitespace-nowrap">
{isUpcoming && !isCancelled ? (
<>
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">{t("rejected")}</div>
)}
</>
) : null}
</td>
</tr>
);
}
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,61 +1,115 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import { useEffect, useState } from "react";
import { EventType, PeriodType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import dayjsBusinessTime from "dayjs-business-time";
import timezone from "dayjs/plugin/timezone";
import getSlots from "@lib/slots";
import dayjsBusinessDays from "dayjs-business-days";
import classNames from "@lib/classNames";
import utc from "dayjs/plugin/utc";
import { useEffect, useMemo, useState } from "react";
dayjs.extend(dayjsBusinessDays);
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);
const DatePicker = ({
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,
}) => {
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 + 1);
};
const decrementMonth = () => {
setSelectedMonth(selectedMonth - 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;
@@ -64,65 +118,45 @@ const 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).businessDaysAdd(periodDays, "days").endOf("day");
return (
date.endOf("day").isBefore(dayjs().utcOffsett(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
@@ -132,33 +166,30 @@ const 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">{inviteeDate().format("MMMM")}</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={
"group mr-2 p-1" + (selectedMonth <= dayjs().month() && "text-gray-400 dark:text-gray-600")
}
disabled={selectedMonth <= dayjs().month()}>
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
className={classNames("group mr-2 p-1", isFirstMonth && "text-gray-400 dark:text-gray-600")}
disabled={isFirstMonth}
data-testid="decrementMonth">
<ChevronLeftIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
</button>
<button className="group p-1" onClick={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">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
.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">
{weekDay}
</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) => (
@@ -167,25 +198,25 @@ const 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-black 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-black text-white-important"
"hover:border hover:border-brand dark:hover:border-white",
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"
data-disabled={day.disabled}>
{day.date}
</button>
)}
@@ -194,6 +225,6 @@ const DatePicker = ({
</div>
</div>
);
};
}
export default DatePicker;

View File

@@ -1,96 +0,0 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
import getSlots from "../../lib/slots";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween);
dayjs.extend(utc);
type Props = {
eventLength: number;
minimumBookingNotice?: number;
date: Dayjs;
workingHours: [];
organizerTimeZone: string;
};
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerTimeZone }: Props) => {
minimumBookingNotice = minimumBookingNotice || 0;
const router = useRouter();
const { user } = router.query;
const [slots, setSlots] = useState([]);
const [isFullyBooked, setIsFullyBooked] = useState(false);
const [hasErrors, setHasErrors] = useState(false);
useEffect(() => {
setSlots([]);
setIsFullyBooked(false);
setHasErrors(false);
fetch(
`/api/availability/${user}?dateFrom=${date.startOf("day").format()}&dateTo=${date
.endOf("day")
.format()}`
)
.then((res) => res.json())
.then(handleAvailableSlots)
.catch((e) => {
console.error(e);
setHasErrors(true);
});
}, [date]);
const handleAvailableSlots = (busyTimes: []) => {
const times = getSlots({
frequency: eventLength,
inviteeDate: date,
workingHours,
minimumBookingNotice,
organizerTimeZone,
});
const timesLengthBeforeConflicts: number = times.length;
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
busyTimes.every((busyTime): boolean => {
const startTime = dayjs(busyTime.start).utc();
const endTime = dayjs(busyTime.end).utc();
// Check if start times are the same
if (times[i].utc().isSame(startTime)) {
times.splice(i, 1);
}
// Check if time is between start and end times
else if (times[i].utc().isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if slot end time is between start and end time
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if startTime is between slot
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
times.splice(i, 1);
} else {
return true;
}
return false;
});
}
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
setIsFullyBooked(true);
}
// Display available times
setSlots(times);
};
return {
slots,
isFullyBooked,
hasErrors,
};
};
export default Slots;

View File

@@ -1,13 +1,22 @@
// TODO: replace headlessui with radix-ui
import { Switch } from "@headlessui/react";
import TimezoneSelect from "react-timezone-select";
import { useEffect, useState } from "react";
import { is24h, timeZone } from "../../lib/clock";
import classNames from "@lib/classNames";
import { FC, useEffect, useState } from "react";
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
const TimeOptions = (props) => {
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { is24h, timeZone } from "../../lib/clock";
type Props = {
onSelectTimeZone: (selectedTimeZone: string) => void;
onToggle24hClock: (is24hClock: boolean) => void;
};
const TimeOptions: FC<Props> = (props) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [is24hClock, setIs24hClock] = useState(false);
const { t } = useLocale();
useEffect(() => {
setIs24hClock(is24h());
@@ -25,47 +34,45 @@ const TimeOptions = (props) => {
props.onToggle24hClock(is24h(is24hClock));
};
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="flex mb-4">
<div className="w-1/2 dark:text-white text-gray-600 font-medium">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">am/pm</span>
</Switch.Label>
<Switch
checked={is24hClock}
onChange={handle24hClockToggle}
return selectedTimeZone !== "" ? (
<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 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 text-gray-500 dark:text-white">{t("am_pm")}</span>
</Switch.Label>
<Switch
checked={is24hClock}
onChange={handle24hClockToggle}
className={classNames(
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>
<span
aria-hidden="true"
className={classNames(
is24hClock ? "bg-black" : "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">Use setting</span>
<span
aria-hidden="true"
className={classNames(
is24hClock ? "translate-x-3" : "translate-x-0",
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm dark:text-white text-gray-500">24h</span>
</Switch.Label>
</Switch.Group>
</div>
is24hClock ? "translate-x-3" : "translate-x-0",
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm text-gray-500 dark:text-white">{t("24_h")}</span>
</Switch.Label>
</Switch.Group>
</div>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(tz) => setSelectedTimeZone(tz.value)}
className="mb-2 shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
)
);
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
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;
};
export default TimeOptions;

View File

@@ -1,41 +1,41 @@
// Get router variables
import { useRouter } from "next/router";
import { useEffect, useState, useMemo } from "react";
import { EventType } from "@prisma/client";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon } from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import DatePicker from "@components/booking/DatePicker";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import PoweredByCalendso from "@components/ui/PoweredByCalendso";
import { timeZone } from "@lib/clock";
import AvailableTimes from "@components/booking/AvailableTimes";
import TimeOptions from "@components/booking/TimeOptions";
import * as Collapsible from "@radix-ui/react-collapsible";
import { HeadSeo } from "@components/seo/head-seo";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import CustomBranding from "@components/CustomBranding";
import AvailableTimes from "@components/booking/AvailableTimes";
import DatePicker from "@components/booking/DatePicker";
import TimeOptions from "@components/booking/TimeOptions";
import { HeadSeo } from "@components/seo/head-seo";
import AvatarGroup from "@components/ui/AvatarGroup";
import PoweredByCal from "@components/ui/PoweredByCal";
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
dayjs.extend(utc);
dayjs.extend(customParseFormat);
type AvailabilityPageProps = {
eventType: EventType;
profile: {
name: string;
image: string;
theme?: string;
};
workingHours: [];
};
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPageProps) => {
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const themeLoaded = useTheme(profile.theme);
const { isReady } = useTheme(profile.theme);
const { t } = useLocale();
const selectedDate = useMemo(() => {
const dateString = asStringOrNull(router.query.date);
@@ -61,7 +61,6 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
}, [telemetry]);
const changeDate = (newDate: Dayjs) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
router.replace(
{
query: {
@@ -89,33 +88,38 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
};
return (
themeLoaded && (
<>
<HeadSeo
title={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title}`}
name={profile.name}
avatar={profile.image}
/>
<div>
<main
className={
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
}>
<>
<HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
name={profile.name || undefined}
avatar={profile.image || undefined}
/>
<CustomBranding val={profile.brandColor} />
<div>
<main
className={
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
}>
{isReady && (
<div className="bg-white border-gray-200 rounded-sm sm:dark:border-gray-600 dark:bg-gray-900 md:border">
{/* mobile: details */}
<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}
/>
@@ -125,8 +129,20 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
{eventType.title}
<div>
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
{eventType.length} {t("minutes")}
</div>
{eventType.price > 0 && (
<div>
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</div>
)}
</div>
</div>
</div>
@@ -140,25 +156,41 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
(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}
/>
<h2 className="font-medium text-gray-500 dark:text-gray-300 mt-3">{profile.name}</h2>
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="mb-4 text-3xl font-semibold text-gray-800 font-cal dark:text-white">
{eventType.title}
</h1>
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</p>
)}
<TimezoneDropdown />
@@ -172,14 +204,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
periodDays={eventType?.periodDays}
periodCountCalendarDays={eventType?.periodCountCalendarDays}
onDatePicked={changeDate}
workingHours={[
{
days: [0, 1, 2, 3, 4, 5, 6],
endTime: 1440,
startTime: 0,
},
]}
weekStart="Sunday"
workingHours={workingHours}
weekStart={profile.weekStart || "Sunday"}
eventLength={eventType.length}
minimumBookingNotice={eventType.minimumBookingNotice}
/>
@@ -190,10 +216,10 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
{selectedDate && (
<AvailableTimes
workingHours={workingHours}
timeFormat={timeFormat}
minimumBookingNotice={eventType.minimumBookingNotice}
eventTypeId={eventType.id}
slotInterval={eventType.slotInterval}
eventLength={eventType.length}
date={selectedDate}
users={eventType.users}
@@ -202,11 +228,11 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
)}
</div>
</div>
{eventType.users.length && isBrandingHidden(eventType.users[0]) && <PoweredByCalendso />}
</main>
</div>
</>
)
)}
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && <PoweredByCal />}
</main>
</div>
</>
);
function TimezoneDropdown() {

View File

@@ -1,246 +1,364 @@
import {
CalendarIcon,
ClockIcon,
CreditCardIcon,
ExclamationIcon,
LocationMarkerIcon,
} from "@heroicons/react/solid";
import { EventTypeCustomInputType } from "@prisma/client";
import dayjs from "dayjs";
import Head from "next/head";
import { useRouter } from "next/router";
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
import { EventTypeCustomInputType } from "@prisma/client";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import "react-phone-number-input/style.css";
import PhoneInput from "react-phone-number-input";
import { LocationType } from "@lib/location";
import { Button } from "@components/ui/Button";
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 AvatarGroup from "@components/ui/AvatarGroup";
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";
const BookingPage = (props: any): JSX.Element => {
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";
import { BookPageProps } from "../../../pages/[user]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => {
const { t, i18n } = useLocale();
const router = useRouter();
const { rescheduleUid } = router.query;
const themeLoaded = useTheme(props.profile.theme);
/*
* 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);
// TODO: Move to translations
const locationLabels = {
[LocationType.InPerson]: "Link or In-person meeting",
[LocationType.Phone]: "Phone call",
[LocationType.InPerson]: t("in_person_meeting"),
[LocationType.Phone]: t("phone_call"),
[LocationType.GoogleMeet]: "Google Meet",
[LocationType.Zoom]: "Zoom Video",
[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 ? "Yes" : "No");
} else {
return input.label + "\n" + data.value;
}
}
})
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
type BookingFormValues = {
name: string;
email: string;
notes?: string;
locationType?: LocationType;
guests?: string[];
phone?: string;
customInputs?: {
[key: string]: string;
};
};
const payload = {
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,
rescheduleUid: rescheduleUid,
timeZone: timeZone(),
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),
};
};
if (router.query.user) {
payload.user = router.query.user;
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 (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;
}
if (locations.length === 1) {
return locations[0]?.type;
}
})(),
});
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
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],
}),
{}
);
/*const res = await */ fetch("/api/book/event", {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
// TODO When the endpoint is fixed, change this to await the result again
//if (res.ok) {
let successUrl = `/success?date=${encodeURIComponent(date)}&type=${props.eventType.id}&user=${
props.profile.slug
}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
if (payload["location"]) {
if (payload["location"].includes("integration")) {
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
} else {
successUrl += "&location=" + encodeURIComponent(payload["location"]);
}
}
await router.push(successUrl);
};
event.preventDefault();
book();
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 (
themeLoaded && (
<div>
<Head>
<title>
{rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with {props.profile.name}{" "}
| Cal.com
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-0 sm:my-24">
<div className="dark:bg-neutral-900 bg-white overflow-hidden border border-gray-200 dark:border-0 sm:rounded-sm">
<div className="sm:flex px-4 py-5 sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-black">
<div>
<Head>
<title>
{rescheduleUid
? t("booking_reschedule_confirmation", {
eventTypeTitle: props.eventType.title,
profileName: props.profile.name,
})
: t("booking_confirmation", {
eventTypeTitle: props.eventType.title,
profileName: props.profile.name,
})}{" "}
| Cal.com
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<CustomBranding val={props.profile.brandColor} />
<main className="max-w-3xl mx-auto my-0 rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
{isReady && (
<div className="overflow-hidden bg-white border border-gray-200 dark:bg-neutral-900 dark:border-0 sm:rounded-sm">
<div className="px-4 py-5 sm:flex sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
<AvatarGroup
size={16}
items={[{ image: props.profile.image, alt: props.profile.name }].concat(
size={14}
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 || "",
}))
)}
/>
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.profile.name}</h2>
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
<h2 className="mt-2 font-medium text-gray-500 font-cal dark:text-gray-300">
{props.profile.name}
</h2>
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
{props.eventType.title}
</h1>
<p className="text-gray-500 mb-2">
<p className="mb-2 text-gray-500">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
{props.eventType.length} {t("minutes")}
</p>
{selectedLocation === LocationType.InPerson && (
<p className="text-gray-500 mb-2">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{locationInfo(selectedLocation).address}
{props.eventType.price > 0 && (
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
<IntlProvider locale="en">
<FormattedNumber
value={props.eventType.price / 100.0}
style="currency"
currency={props.eventType.currency.toUpperCase()}
/>
</IntlProvider>
</p>
)}
<p className="text-green-500 mb-4">
{selectedLocation === LocationType.InPerson && (
<p className="mb-2 text-gray-500">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{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="dark:text-white text-gray-600 mb-8">{props.eventType.description}</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 dark:text-white text-gray-700">
Your name
<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="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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>
<div className="mb-4">
<label
htmlFor="email"
className="block text-sm font-medium dark:text-white text-gray-700">
Email address
className="block text-sm font-medium text-gray-700 dark:text-white">
{t("email_address")}
</label>
<div className="mt-1">
<input
type="email"
name="email"
id="email"
<EmailInput
{...bookingForm.register("email")}
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
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>
{locations.length > 1 && (
<div className="mb-4">
<span className="block text-sm font-medium dark:text-white text-gray-700">
Location
<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="location focus:ring-black h-4 w-4 text-black border-gray-300 mr-2"
name="location"
className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black"
{...bookingForm.register("locationType", { required: true })}
value={location.type}
checked={selectedLocation === location.type}
defaultChecked={selectedLocation === location.type}
/>
<span className="text-sm ml-2 dark:text-gray-500">
<span className="ml-2 text-sm dark:text-gray-500">
{locationLabels[location.type]}
</span>
</label>
@@ -251,153 +369,158 @@ const BookingPage = (props: any): JSX.Element => {
<div className="mb-4">
<label
htmlFor="phone"
className="block text-sm font-medium dark:text-white text-gray-700">
Phone Number
className="block text-sm font-medium text-gray-700 dark:text-white">
{t("phone_number")}
</label>
<div className="mt-1">
<PhoneInput
name="phone"
placeholder="Enter phone number"
id="phone"
required
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
}}
/>
<PhoneInput name="phone" placeholder={t("enter_phone_number")} id="phone" required />
</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 && (
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
{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="text"
name={"custom_" + input.id}
type="checkbox"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="focus:ring-black h-4 w-4 text-black border-gray-300 rounded mr-2"
placeholder=""
/>
<label
htmlFor={"custom_" + input.id}
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
{input.label}
</label>
</div>
)}
</div>
))}
<div className="mb-4">
{!guestToggle && (
<label
onClick={toggleGuestEmailInput}
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer">
+ Additional Guests
</label>
)}
{guestToggle && (
<div>
<label
htmlFor="guests"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Guests
</label>
<ReactMultiEmail
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>
);
}}
/>
<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>
))}
{!props.eventType.disableGuests && (
<div className="mb-4">
{!guestToggle && (
<label
onClick={() => setGuestToggle(!guestToggle)}
htmlFor="guests"
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>
)}
{guestToggle && (
<div>
<label
htmlFor="guests"
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{t("guests")}
</label>
<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>
)}
</div>
)}
<div className="mb-4">
<label
htmlFor="notes"
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
Additional notes
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{t("additional_notes")}
</label>
<textarea
name="notes"
{...bookingForm.register("notes")}
id="notes"
rows={3}
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="Please share anything that will help prepare for our meeting."
defaultValue={props.booking ? props.booking.description : ""}
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")}
/>
</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}>
{rescheduleUid ? "Reschedule" : "Confirm"}
<Button type="submit" loading={mutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
</Button>
<Button color="secondary" type="button" onClick={() => router.back()}>
Cancel
{t("cancel")}
</Button>
</div>
</form>
{error && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mt-2">
</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">
<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">
Could not {rescheduleUid ? "reschedule" : "book"} the meeting.
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
</p>
</div>
</div>
@@ -406,9 +529,9 @@ const BookingPage = (props: any): JSX.Element => {
</div>
</div>
</div>
</main>
</div>
)
)}
</main>
</div>
);
};

View File

@@ -1,19 +1,33 @@
import { DialogClose, DialogContent } from "@components/Dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ExclamationIcon } from "@heroicons/react/outline";
import React, { PropsWithChildren } from "react";
import { CheckIcon } from "@heroicons/react/solid";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { PropsWithChildren, ReactNode } from "react";
import { useLocale } from "@lib/hooks/useLocale";
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;
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
title: string;
variety?: "danger" /* no others yet */;
variety?: "danger" | "warning" | "success";
};
export default function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationDialogContentProps>) {
const { title, variety, confirmBtnText = "Confirm", cancelBtnText = "Cancel", onConfirm, children } = props;
const { t } = useLocale();
const {
title,
variety,
confirmBtn = null,
confirmBtnText = t("confirm"),
cancelBtnText = t("cancel"),
onConfirm,
children,
} = props;
return (
<DialogContent>
@@ -21,20 +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="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="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="text-xl font-bold text-gray-900">{title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-neutral-500">{children}</DialogPrimitive.Description>
<DialogPrimitive.Title className="text-xl font-bold text-gray-900 font-cal">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-neutral-500">
{children}
</DialogPrimitive.Description>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2">
<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

@@ -1,4 +1,5 @@
import React from "react";
import { HttpError } from "@lib/core/http/error";
type Props = {

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

@@ -1,7 +1,24 @@
import { EventType, SchedulingType } from "@prisma/client";
import { ClockIcon, InformationCircleIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
import { ClockIcon, CreditCardIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import { Prisma } from "@prisma/client";
import React from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: {
id: true,
length: true,
price: true,
currency: true,
schedulingType: true,
description: true,
},
});
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
export type EventTypeDescriptionProps = {
eventType: EventType;
@@ -9,34 +26,49 @@ export type EventTypeDescriptionProps = {
};
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
const { t } = useLocale();
return (
<ul className={classNames("mt-2 space-x-4 text-neutral-500 dark:text-white flex", className)}>
<li className="flex whitespace-nowrap">
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.length}m
</li>
{eventType.schedulingType ? (
<li className="flex whitespace-nowrap">
<UsersIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && "Round Robin"}
{eventType.schedulingType === SchedulingType.COLLECTIVE && "Collective"}
</li>
) : (
<li className="flex whitespace-nowrap">
<UserIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
1-on-1
</li>
)}
{eventType.description && (
<li className="flex">
<InformationCircleIcon
className="flex-none inline mr-1.5 mt-0.5 h-4 w-4 text-neutral-400"
aria-hidden="true"
/>
<span>{eventType.description.substring(0, 100)}</span>
</li>
)}
</ul>
<>
<div className={classNames("text-neutral-500 dark:text-white", className)}>
{eventType.description && (
<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 ">
<li className="flex whitespace-nowrap">
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.length}m
</li>
{eventType.schedulingType ? (
<li className="flex whitespace-nowrap">
<UsersIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
</li>
) : (
<li className="flex whitespace-nowrap">
<UserIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{t("1_on_1")}
</li>
)}
{eventType.price > 0 && (
<li className="flex whitespace-nowrap">
<CreditCardIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</li>
)}
</ul>
</div>
</>
);
};

221
components/form/fields.tsx Normal file
View File

@@ -0,0 +1,221 @@
import { useId } from "@radix-ui/react-id";
import { forwardRef, ReactElement, ReactNode, Ref } from "react";
import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form";
import classNames from "@lib/classNames";
import { getErrorFromUnknown } from "@lib/errors";
import { useLocale } from "@lib/hooks/useLocale";
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-1 focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm",
props.className
)}
/>
);
});
export function Label(props: JSX.IntrinsicElements["label"]) {
return (
<label {...props} className={classNames("block text-sm font-medium text-gray-700", props.className)}>
{props.children}
</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;
} & React.ComponentProps<typeof Input> & {
labelProps?: React.ComponentProps<typeof Label>;
};
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(props, ref) {
const id = useId();
const { t } = useLocale();
const methods = useFormContext();
const {
label = t(props.name),
labelProps,
placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
? t(props.name + "_placeholder")
: "",
className,
addOnLeading,
...passThrough
} = props;
return (
<div>
{!!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", props.addOnLeading && "rounded-l-none")}
{...passThrough}
ref={ref}
/>
</div>
) : (
<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} />
)}
</div>
);
});
export const TextField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(props, ref) {
return <InputField ref={ref} {...props} />;
});
export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(function PasswordField(
props,
ref
) {
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 <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<
JSX.IntrinsicElements["form"],
"onSubmit"
>;
const PlainForm = <T extends FieldValues>(props: FormProps<T>, ref: Ref<HTMLFormElement>) => {
const { form, handleSubmit, ...passThrough } = props;
return (
<FormProvider {...form}>
<form
ref={ref}
onSubmit={(event) => {
form
.handleSubmit(handleSubmit)(event)
.catch((err) => {
showToast(`${getErrorFromUnknown(err).message}`, "error");
});
}}
{...passThrough}>
{props.children}
</form>
</FormProvider>
);
};
export const Form = forwardRef(PlainForm) as <T extends FieldValues>(
p: FormProps<T> & { ref?: Ref<HTMLFormElement> }
) => ReactElement;
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
return (
<legend {...props} className={classNames("text-sm font-medium text-gray-700", props.className)}>
{props.children}
</legend>
);
}
export function InputGroupBox(props: JSX.IntrinsicElements["div"]) {
return (
<div
{...props}
className={classNames("p-2 bg-white border border-gray-300 rounded-sm space-y-2", props.className)}>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,304 @@
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";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import { List } from "@components/List";
import { ShellSubHeading } from "@components/Shell";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch";
import ConnectIntegration from "./ConnectIntegrations";
import DisconnectIntegration from "./DisconnectIntegration";
import IntegrationListItem from "./IntegrationListItem";
import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections";
type Props = {
onChanged: () => unknown | Promise<unknown>;
};
function CalendarSwitch(props: {
type: string;
externalId: string;
title: string;
defaultSelected: boolean;
}) {
const utils = trpc.useContext();
const mutation = useMutation<
unknown,
unknown,
{
isOn: boolean;
}
>(
async ({ isOn }) => {
const body = {
integration: props.type,
externalId: props.externalId,
};
if (isOn) {
const res = await fetch("/api/availability/calendar", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Something went wrong");
}
} else {
const res = await fetch("/api/availability/calendar", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Something went wrong");
}
}
},
{
async onSettled() {
await utils.invalidateQueries(["viewer.integrations"]);
},
onError() {
showToast(`Something went wrong when toggling "${props.title}""`, "error");
},
}
);
return (
<div className="py-1">
<Switch
key={props.externalId}
name="enabled"
label={props.title}
defaultChecked={props.defaultSelected}
onCheckedChange={(isOn: boolean) => {
mutation.mutate({ isOn });
}}
/>
</div>
);
}
function ConnectedCalendarsList(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
return (
<QueryCell
query={query}
empty={() => null}
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 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"]);
return (
<QueryCell
query={query}
success={({ data }) => (
<List>
{data.calendar.items.map((item) => (
<IntegrationListItem
key={item.title}
{...item}
actions={
<ConnectIntegration
type={item.type}
render={(btnProps) => (
<Button color="secondary" {...btnProps} data-testid="integration-connection-button">
{t("connect")}
</Button>
)}
onOpenChange={() => props.onChanged()}
/>
}
/>
))}
</List>
)}
/>
);
}
export function CalendarListContainer(props: { heading?: false }) {
const { t } = useLocale();
const { heading = true } = props;
const utils = trpc.useContext();
const onChanged = () =>
Promise.allSettled([
utils.invalidateQueries(["viewer.integrations"]),
utils.invalidateQueries(["viewer.connectedCalendars"]),
]);
const query = trpc.useQuery(["viewer.connectedCalendars"]);
return (
<>
{heading && (
<ShellSubHeading
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?.connectedCalendars.length && (
<ShellSubHeading
className="mt-6"
title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />}
/>
)}
<CalendarList onChanged={onChanged} />
</>
);
}

View File

@@ -0,0 +1,63 @@
import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types";
import { useState } from "react";
import { useMutation } from "react-query";
import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration";
import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration";
import { ButtonBaseProps } from "@components/ui/Button";
export default function ConnectIntegration(props: {
type: string;
render: (renderProps: ButtonBaseProps) => JSX.Element;
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
}) {
const { type } = props;
const [isLoading, setIsLoading] = useState(false);
const mutation = useMutation(async () => {
const state: IntegrationOAuthCallbackState = {
returnTo: location.pathname + location.search,
};
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}`;
const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add" + searchParams);
if (!res.ok) {
throw new Error("Something went wrong");
}
const json = await res.json();
window.location.href = json.url;
setIsLoading(true);
});
const [isModalOpen, _setIsModalOpen] = useState(false);
const setIsModalOpen = (v: boolean) => {
_setIsModalOpen(v);
props.onOpenChange(v);
};
return (
<>
{props.render({
onClick() {
if (["caldav_calendar", "apple_calendar"].includes(type)) {
// special handlers
setIsModalOpen(true);
return;
}
mutation.mutate();
},
loading: mutation.isLoading || isLoading,
disabled: isModalOpen,
})}
{type === "caldav_calendar" && (
<AddCalDavIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
)}
{type === "apple_calendar" && (
<AddAppleIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
)}
</>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { useMutation } from "react-query";
import { Dialog } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { ButtonBaseProps } from "@components/ui/Button";
export default function DisconnectIntegration(props: {
/** Integration credential id */
id: number;
render: (renderProps: ButtonBaseProps) => JSX.Element;
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
}) {
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation(
async () => {
const res = await fetch("/api/integrations", {
method: "DELETE",
body: JSON.stringify({ id: props.id }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Something went wrong");
}
},
{
async onSettled() {
await props.onOpenChange(modalOpen);
},
onSuccess() {
setModalOpen(false);
},
}
);
return (
<>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<ConfirmationDialogContent
variety="danger"
title="Disconnect Integration"
confirmBtnText="Yes, disconnect integration"
cancelBtnText="Cancel"
onConfirm={() => {
mutation.mutate();
}}>
Are you sure you want to disconnect this integration?
</ConfirmationDialogContent>
</Dialog>
{props.render({
onClick() {
setModalOpen(true);
},
disabled: modalOpen,
loading: mutation.isLoading,
})}
</>
);
}

View File

@@ -0,0 +1,30 @@
import Image from "next/image";
import { ReactNode } from "react";
import classNames from "@lib/classNames";
import { ListItem, ListItemText, ListItemTitle } from "@components/List";
function IntegrationListItem(props: {
imageSrc: string;
title: string;
description: string;
actions?: ReactNode;
children?: ReactNode;
}): JSX.Element {
return (
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
<div className={classNames("flex flex-1 space-x-2 w-full p-3 items-center")}>
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
<div className="flex-grow pl-2 truncate">
<ListItemTitle component="h3">{props.title}</ListItemTitle>
<ListItemText component="p">{props.description}</ListItemText>
</div>
<div>{props.actions}</div>
</div>
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
</ListItem>
);
}
export default IntegrationListItem;

View File

@@ -0,0 +1,29 @@
import { ReactNode } from "react";
import Badge from "@components/ui/Badge";
function pluralize(opts: { num: number; plural: string; singular: string }) {
if (opts.num === 0) {
return opts.singular;
}
return opts.singular;
}
export default function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
const num = props.numConnections;
return (
<>
<span>{props.title}</span>
{num ? (
<Badge variant="success">
{num}{" "}
{pluralize({
num,
singular: "connection",
plural: "connections",
})}
</Badge>
) : null}
</>
);
}

View File

@@ -0,0 +1,128 @@
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
import React, { FC } from "react";
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
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;
selectedCustomInput?: EventTypeCustomInput;
}
type IFormInput = EventTypeCustomInput;
const CustomInputTypeForm: FC<Props> = (props) => {
const { t } = useLocale();
const inputOptions: OptionTypeBase[] = [
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
];
const { selectedCustomInput } = props;
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
const { register, control, handleSubmit } = useForm<IFormInput>({
defaultValues,
});
const selectedInputType = useWatch({ name: "type", control });
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value)!;
const onCancel = () => {
props.onCancel();
};
return (
<form onSubmit={handleSubmit(props.onSubmit)}>
<div className="mb-2">
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
{t("input_type")}
</label>
<Controller
name="type"
control={control}
render={({ field }) => (
<Select
id="type"
defaultValue={selectedInputOption}
options={inputOptions}
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) => field.onChange(option.value)}
value={selectedInputOption}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</div>
<div className="mb-2">
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
{t("label")}
</label>
<div className="mt-1">
<input
type="text"
id="label"
required
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
defaultValue={selectedCustomInput?.label}
{...register("label", { required: true })}
/>
</div>
</div>
{(selectedInputType === EventTypeCustomInputType.TEXT ||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
<div className="mb-2">
<label htmlFor="placeholder" className="block text-sm font-medium text-gray-700">
{t("placeholder")}
</label>
<div className="mt-1">
<input
type="text"
id="placeholder"
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")}
/>
</div>
</div>
)}
<div className="flex items-center h-5">
<input
id="required"
type="checkbox"
className="w-4 h-4 mr-2 border-gray-300 rounded focus:ring-primary-500 text-primary-600"
defaultChecked={selectedCustomInput?.required ?? true}
{...register("required")}
/>
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
{t("is_required")}
</label>
</div>
<input
type="hidden"
id="eventTypeId"
value={selectedCustomInput?.eventTypeId || -1}
{...register("eventTypeId", { valueAsNumber: true })}
/>
<input
type="hidden"
id="id"
value={selectedCustomInput?.id || -1}
{...register("id", { valueAsNumber: true })}
/>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit">{t("save")}</Button>
<Button onClick={onCancel} type="button" color="secondary" className="mr-2">
{t("cancel")}
</Button>
</div>
</form>
);
};
export default CustomInputTypeForm;

View File

@@ -1,22 +1,21 @@
import React, { SyntheticEvent, useState } from "react";
import Modal from "@components/Modal";
import { ErrorCode } from "@lib/auth";
const errorMessages: { [key: string]: string } = {
[ErrorCode.IncorrectPassword]: "Current password is incorrect",
[ErrorCode.NewPasswordMatchesOld]:
"New password matches your old password. Please choose a different password.",
};
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("");
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { t } = useLocale();
const closeSuccessModal = () => {
setSuccessModalOpen(false);
const errorMessages: { [key: string]: string } = {
[ErrorCode.IncorrectPassword]: t("current_incorrect_password"),
[ErrorCode.NewPasswordMatchesOld]: t("new_password_matches_old_password"),
};
async function changePasswordHandler(e: SyntheticEvent) {
@@ -41,15 +40,15 @@ const ChangePasswordSection = () => {
if (response.status === 200) {
setOldPassword("");
setNewPassword("");
setSuccessModalOpen(true);
showToast(t("password_has_been_changed"), "success");
return;
}
const body = await response.json();
setErrorMessage(errorMessages[body.error] || "Something went wrong. Please try again");
setErrorMessage(errorMessages[body.error] || `${t("something_went_wrong")}${t("please_try_again")}`);
} catch (err) {
console.error("Error changing password", err);
setErrorMessage("Something went wrong. Please try again");
console.error(t("error_changing_password"), err);
setErrorMessage(`${t("something_went_wrong")}${t("please_try_again")}`);
} finally {
setIsSubmitting(false);
}
@@ -58,14 +57,14 @@ const ChangePasswordSection = () => {
return (
<>
<div className="mt-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Change Password</h2>
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">{t("change_password")}</h2>
</div>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
<div className="py-6 lg:pb-8">
<div className="flex">
<div className="w-1/2 mr-2">
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
Current Password
{t("current_password")}
</label>
<div className="mt-1">
<input
@@ -75,14 +74,14 @@ const ChangePasswordSection = () => {
name="current_password"
id="current_password"
required
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your old password"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder={t("your_old_password")}
/>
</div>
</div>
<div className="w-1/2 ml-2">
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
New Password
{t("new_password")}
</label>
<div className="mt-1">
<input
@@ -92,29 +91,19 @@ const ChangePasswordSection = () => {
value={newPassword}
required
onInput={(e) => setNewPassword(e.currentTarget.value)}
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your super secure new password"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder={t("super_secure_new_password")}
/>
</div>
</div>
</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">
Save
</button>
<Button type="submit">{t("save")}</Button>
</div>
<hr className="mt-4" />
</div>
</form>
<Modal
heading="Password updated successfully"
description="Your password has been successfully changed."
open={successModalOpen}
handleClose={closeSuccessModal}
/>
</>
);
};

View File

@@ -1,19 +1,18 @@
import { SyntheticEvent, useState } from "react";
import Button from "@components/ui/Button";
import { Dialog, DialogContent } from "@components/Dialog";
import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import { Dialog, DialogContent } from "@components/Dialog";
import Button from "@components/ui/Button";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
import TwoFactorModalHeader from "./TwoFactorModalHeader";
interface DisableTwoFactorAuthModalProps {
/**
* Called when the user closes the modal without disabling two-factor auth
*/
/** Called when the user closes the modal without disabling two-factor auth */
onCancel: () => void;
/**
* Called when the user disables two-factor auth
*/
/** Called when the user disables two-factor auth */
onDisable: () => void;
}
@@ -21,6 +20,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
const [password, setPassword] = useState("");
const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { t } = useLocale();
async function handleDisable(e: SyntheticEvent) {
e.preventDefault();
@@ -40,13 +40,13 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage("Password is incorrect.");
setErrorMessage(t("incorrect_password"));
} else {
setErrorMessage("Something went wrong.");
setErrorMessage(t("something_went_wrong"));
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error disabling two-factor authentication", e);
setErrorMessage(t("something_went_wrong"));
console.error(t("error_disabling_2fa"), e);
} finally {
setIsDisabling(false);
}
@@ -55,15 +55,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
return (
<Dialog open={true}>
<DialogContent>
<TwoFactorModalHeader
title="Disable two-factor authentication"
description="If you need to disable 2FA, we recommend re-enabling it as soon as possible."
/>
<TwoFactorModalHeader title={t("disable_2fa")} description={t("disable_2fa_recommendation")} />
<form onSubmit={handleDisable}>
<div className="mb-4">
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
Password
{t("password")}
</label>
<div className="mt-1">
<input
@@ -87,10 +84,10 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
className="ml-2"
onClick={handleDisable}
disabled={password.length === 0 || isDisabling}>
Disable
{t("disable")}
</Button>
<Button color="secondary" onClick={onCancel}>
Cancel
{t("cancel")}
</Button>
</div>
</DialogContent>

View File

@@ -1,7 +1,11 @@
import React, { SyntheticEvent, useState } from "react";
import Button from "@components/ui/Button";
import { Dialog, DialogContent } from "@components/Dialog";
import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import { Dialog, DialogContent } from "@components/Dialog";
import Button from "@components/ui/Button";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
import TwoFactorModalHeader from "./TwoFactorModalHeader";
@@ -23,12 +27,6 @@ enum SetupStep {
EnterTotpCode,
}
const setupDescriptions = {
[SetupStep.ConfirmPassword]: "Confirm your current password to get started.",
[SetupStep.DisplayQrCode]: "Scan the image below with the authenticator app on your phone.",
[SetupStep.EnterTotpCode]: "Enter the six-digit code from your authenticator app below.",
};
const WithStep = ({
step,
current,
@@ -42,10 +40,17 @@ const WithStep = ({
};
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
const { t } = useLocale();
const setupDescriptions = {
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
};
const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [dataUri, setDataUri] = useState("");
const [secret, setSecret] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -65,18 +70,19 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
if (response.status === 200) {
setDataUri(body.dataUri);
setSecret(body.secret);
setStep(SetupStep.DisplayQrCode);
return;
}
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage("Password is incorrect.");
setErrorMessage(t("incorrect_password"));
} else {
setErrorMessage("Something went wrong.");
setErrorMessage(t("something_went_wrong"));
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error setting up two-factor authentication", e);
setErrorMessage(t("something_went_wrong"));
console.error(t("error_enabling_2fa"), e);
} finally {
setIsSubmitting(false);
}
@@ -102,13 +108,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
}
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
setErrorMessage("Code is incorrect. Please try again.");
setErrorMessage(`${t("code_is_incorrect")} ${t("please_try_again")}`);
} else {
setErrorMessage("Something went wrong.");
setErrorMessage(t("something_went_wrong"));
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error enabling up two-factor authentication", e);
setErrorMessage(t("something_went_wrong"));
console.error(t("error_enabling_2fa"), e);
} finally {
setIsSubmitting(false);
}
@@ -117,16 +123,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
return (
<Dialog open={true}>
<DialogContent>
<TwoFactorModalHeader
title="Enable two-factor authentication"
description={setupDescriptions[step]}
/>
<TwoFactorModalHeader title={t("enable_2fa")} description={setupDescriptions[step]} />
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<form onSubmit={handleSetup}>
<div className="mb-4">
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
Password
{t("password")}
</label>
<div className="mt-1">
<input
@@ -145,15 +148,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
</form>
</WithStep>
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<div className="flex justify-center">
<img src={dataUri} />
</div>
<>
<div className="flex justify-center">
<img src={dataUri} />
</div>
<p className="text-center text-xs font-mono">{secret}</p>
</>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<form onSubmit={handleEnable}>
<div className="mb-4">
<label htmlFor="code" className="mt-4 block text-sm font-medium text-gray-700">
Code
{t("code")}
</label>
<div className="mt-1">
<input
@@ -182,12 +188,12 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
className="ml-2"
onClick={handleSetup}
disabled={password.length === 0 || isSubmitting}>
Continue
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<Button type="submit" className="ml-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
Continue
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
@@ -196,11 +202,11 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
className="ml-2"
onClick={handleEnable}
disabled={totpCode.length !== 6 || isSubmitting}>
Enable
{t("enable")}
</Button>
</WithStep>
<Button color="secondary" onClick={onCancel}>
Cancel
{t("cancel")}
</Button>
</div>
</DialogContent>

View File

@@ -1,31 +1,34 @@
import { useState } from "react";
import Button from "@components/ui/Button";
import { useLocale } from "@lib/hooks/useLocale";
import Badge from "@components/ui/Badge";
import EnableTwoFactorModal from "./EnableTwoFactorModal";
import Button from "@components/ui/Button";
import DisableTwoFactorModal from "./DisableTwoFactorModal";
import EnableTwoFactorModal from "./EnableTwoFactorModal";
const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean }) => {
const [enabled, setEnabled] = useState(twoFactorEnabled);
const [enableModalOpen, setEnableModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const { t } = useLocale();
return (
<>
<div className="flex flex-row items-center">
<h2 className="text-lg leading-6 font-medium text-gray-900">Two-Factor Authentication</h2>
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">{t("2fa")}</h2>
<Badge className="text-xs ml-2" variant={enabled ? "success" : "gray"}>
{enabled ? "Enabled" : "Disabled"}
{enabled ? t("enabled") : t("disabled")}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-500">
Add an extra layer of security to your account in case your password is stolen.
</p>
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
<Button
className="mt-6"
type="submit"
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
{enabled ? "Disable" : "Enable"} Two-Factor Authentication
{enabled ? t("disable") : t("enable")} {t("2fa")}
</Button>
{enableModalOpen && (

View File

@@ -1,14 +1,14 @@
import React from "react";
import { ShieldCheckIcon } from "@heroicons/react/solid";
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-black 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="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

@@ -1,16 +1,17 @@
import merge from "lodash/merge";
import { NextSeo, NextSeoProps } from "next-seo";
import React from "react";
import { getBrowserInfo } from "@lib/core/browser/browser.utils";
import { getSeoImage, seoConfig } from "@lib/config/next-seo.config";
import merge from "lodash.merge";
import { getBrowserInfo } from "@lib/core/browser/browser.utils";
export type HeadSeoProps = {
title: string;
description: string;
siteName?: string;
name?: string;
avatar?: string;
url?: string;
username?: string;
canonical?: string;
nextSeoProps?: NextSeoProps;
};
@@ -38,9 +39,6 @@ const buildSeoMeta = (pageProps: {
images: [
{
url: image,
//width: 1077,
//height: 565,
//alt: "Alt image"
},
],
},
@@ -65,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%2Fcalendso-logo-white.svg&images=" +
encodeURIComponent(avatar)
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
(process.env.NEXT_PUBLIC_APP_URL || process.env.BASE_URL) +
"/" +
username +
"/avatar.png"
);
};
@@ -81,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,298 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
import ErrorAlert from "@components/ui/alerts/Error";
import { UsernameInput } from "@components/ui/UsernameInput";
import MemberList from "./MemberList";
import Avatar from "@components/ui/Avatar";
import ImageUploader from "@components/ImageUploader";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Modal from "@components/Modal";
import MemberInvitationModal from "@components/team/MemberInvitationModal";
import Button from "@components/ui/Button";
import { Member } from "@lib/member";
import { Team } from "@lib/team";
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 [successModalOpen, setSuccessModalOpen] = useState(false);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
const [errorMessage, setErrorMessage] = useState("");
const [imageSrc, setImageSrc] = useState<string>("");
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(() => {
setSuccessModalOpen(true);
setHasErrors(false); // dismiss any open errors
})
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
}
const onMemberInvitationModalExit = () => {
loadMembers();
setShowMemberInvitationModal(false);
};
const closeSuccessModal = () => {
setSuccessModalOpen(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()}>
Back
</Button>
</div>
<div className="">
<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>Manage your team</p>
</div>
</div>
</div>
<hr className="mt-2" />
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">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={"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">
Team name
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="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">
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">
A few sentences about your team. This will appear on your team&apos;s URL page.
</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 ? imageSrc : props.team?.logo}
/>
<ImageUploader
target="logo"
id="logo-upload"
buttonMsg={imageSrc !== "" ? "Edit logo" : "Upload a logo"}
handleAvatarChange={handleLogoChange}
imageRef={imageSrc ? imageSrc : props.team?.logo}
/>
</div>
<hr className="mt-6" />
</div>
<div className="flex justify-between mt-7">
<h3 className="font-bold leading-6 text-gray-900 text-md">Members</h3>
<div className="relative flex items-center">
<Button
type="button"
color="secondary"
StartIcon={PlusIcon}
onClick={() => onInviteMember(props.team)}>
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">
Disable Cal.com branding
</label>
<p className="text-gray-500">Hide all Cal.com branding from your public pages.</p>
</div>
</div>
<hr className="mt-6" />
</div>
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">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" />
Disband Team
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title="Disband Team"
confirmBtnText="Yes, disband team"
cancelBtnText="Cancel"
onConfirm={() => deleteTeam()}>
Are you sure you want to disband this team? Anyone who you&apos;ve shared this team
link with will no longer be able to book using it.
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</div>
</div>
<hr className="mt-8" />
<div className="flex justify-end py-4">
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
</form>
<Modal
heading="Team updated successfully"
description="Your team has been updated successfully."
open={successModalOpen}
handleClose={closeSuccessModal}
/>
{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,44 +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 { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import { EmailInput } from "@components/form/fields";
import Button from "@components/ui/Button";
import { Team } from "@lib/team";
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) => {
function inviteMember(e: SyntheticEvent) {
e.preventDefault();
if (!props.team) return;
const payload = {
role: e.target.elements["role"].value,
usernameOrEmail: e.target.elements["inviteUser"].value,
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
const target = e.target as typeof e.target & {
elements: {
role: { value: MembershipRole };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
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.
});
};
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 (
<div
@@ -57,15 +63,15 @@ 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-black 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">
Invite a new member
{t("invite_new_member")}
</h3>
<div>
<p className="text-sm text-gray-400">Invite someone to your team.</p>
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
</div>
</div>
</div>
@@ -73,26 +79,26 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
<div>
<div className="mb-4">
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
Email or Username
{t("email_or_username")}
</label>
<input
<EmailInput
type="text"
name="inviteUser"
id="inviteUser"
placeholder="email@example.com"
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
<div className="mb-4">
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
Role
{t("role")}
</label>
<select
id="role"
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm">
<option value="MEMBER">Member</option>
<option value="OWNER">Owner</option>
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>
</select>
</div>
<div className="relative flex items-start">
@@ -102,12 +108,12 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm"
className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
<div className="ml-2 text-sm">
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
Send an invite email
{t("send_invite_email")}
</label>
</div>
</div>
@@ -120,10 +126,10 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
)}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ml-2">
Invite
{t("invite")}
</Button>
<Button type="button" color="secondary" onClick={props.onExit}>
Cancel
{t("cancel")}
</Button>
</div>
</form>

View File

@@ -1,29 +1,20 @@
import MemberListItem from "./MemberListItem";
import { Member } from "@lib/member";
import { inferQueryOutput } from "@lib/trpc";
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;
}
};
import MemberListItem from "./MemberListItem";
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-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{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,93 +1,177 @@
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
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 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 { Member } from "@lib/member";
import Button from "@components/ui/Button";
import ModalContainer from "@components/ui/ModalContainer";
export default function MemberListItem(props: {
member: Member;
onActionSelect: (text: string) => void;
onChange: (text: string) => void;
}) {
const [member] = useState(props.member);
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/Dropdown";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamRole from "./TeamRole";
import { MembershipRole } from ".prisma/client";
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">
<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 || "")
}
displayName={props.member.name || ""}
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">{props.member.name}</span>
<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">
{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">
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">
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">
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">
Owner
</span>
)}
<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">
Remove User
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title="Remove member"
confirmBtnText="Yes, remove member"
cancelBtnText="Cancel"
onConfirm={() => props.onActionSelect("remove")}>
Are you sure you want to remove this member from the team?
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
<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,37 +1,44 @@
import TeamListItem from "./TeamListItem";
import { Team } from "@lib/team";
import showToast from "@lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
export default function TeamList(props: {
teams: Team[];
onChange: () => void;
onEditTeam: (text: Team) => void;
}) {
const selectAction = (action: string, team: Team) => {
import TeamListItem from "./TeamListItem";
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 = (team: Team) => {
return fetch("/api/teams/" + team.id, {
method: "DELETE",
}).then(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,166 +1,212 @@
import {
DotsHorizontalIcon,
ExternalLinkIcon,
LinkIcon,
PencilAltIcon,
TrashIcon,
} from "@heroicons/react/outline";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
import { useState } from "react";
import { Tooltip } from "@components/Tooltip";
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
import Link from "next/link";
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 showToast from "@lib/notification";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@components/ui/Dropdown";
interface Team {
id: number;
name: string | null;
slug: string | null;
logo: string | null;
bio: string | null;
role: string | null;
hideBranding: boolean;
prevState: null;
import TeamRole from "./TeamRole";
import { MembershipRole } from ".prisma/client";
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
imageSrc={
props.team.logo
? props.team.logo
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
encodeURIComponent(props.team.name || "")
}
displayName="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">
{window.location.hostname}/{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}>
Reject
{t("reject")}
</Button>
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
Accept
<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}>
Leave
</Button>
</div>
)}
{props.team.role === "OWNER" && (
<div className="flex">
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
Owner
</span>
<Tooltip content="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(window.location.hostname + "/team/" + props.team.slug);
showToast("Link copied!", "success");
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"
className="w-full pl-5 ml-8"
StartIcon={LinkIcon}
type="button"></Button>
type="button">
<LinkIcon className="w-5 h-5 group-hover:text-gray-600" />
</Button>
</Tooltip>
<Dropdown>
<DropdownMenuTrigger>
<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}>
{" "}
Edit team
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="">
<Link href={`/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}>
{" "}
Preview team page
{t("preview_team")}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={TrashIcon}
className="w-full">
Disband Team
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title="Disband Team"
confirmBtnText="Yes, disband team"
cancelBtnText="Cancel"
onConfirm={() => props.onActionSelect("disband")}>
Are you sure you want to disband this team? Anyone who you&apos;ve shared this team
link with will no longer be able to book using it.
</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

@@ -1,30 +1,42 @@
import React from "react";
import Text from "@components/ui/Text";
import Link from "next/link";
import Avatar from "@components/ui/Avatar";
import { ArrowRightIcon } from "@heroicons/react/outline";
import classnames from "classnames";
import { ArrowLeftIcon } from "@heroicons/react/solid";
import Button from "@components/ui/Button";
import classnames from "classnames";
import Link from "next/link";
import { TeamPageProps } from "pages/team/[slug]";
import React from "react";
const Team = ({ team }) => {
const Member = ({ member }) => {
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";
type TeamType = TeamPageProps["team"];
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
const Team = ({ team }: TeamPageProps) => {
const { t } = useLocale();
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",
"hover:border-black dark:border-neutral-700 dark:hover:border-neutral-600",
"hover:border-brand dark:border-neutral-700 dark:hover:border-neutral-600",
"rounded-sm",
"hover:shadow-md"
);
return (
<Link key={member.id} href={`/${member.user.username}`}>
<Link key={member.id} href={`/${member.username}`}>
<div className={classes}>
<ArrowRightIcon
className={classnames(
@@ -37,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>
@@ -50,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 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-6">
<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 key={member.id} member={member} />;
return member.username !== null && <Member key={member.id} member={member} />;
})}
</section>
);
@@ -67,10 +83,10 @@ const Team = ({ team }) => {
return (
<div>
<Members members={team.members} />
{team.eventTypes.length && (
<aside className="text-center dark:text-white mt-8">
{team.eventTypes.length > 0 && (
<aside className="mt-8 text-center dark:text-white">
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
Go back
{t("go_back")}
</Button>
</aside>
)}

View File

@@ -3,8 +3,9 @@ import classNames from "classnames";
import { ReactNode } from "react";
export interface AlertProps {
title: ReactNode;
title?: ReactNode;
message?: ReactNode;
actions?: ReactNode;
className?: string;
severity: "success" | "warning" | "error";
}
@@ -14,10 +15,10 @@ export function Alert(props: AlertProps) {
return (
<div
className={classNames(
"rounded-md p-4",
"rounded-sm p-2",
props.className,
severity === "error" && "bg-red-50 text-red-800",
severity === "warning" && "bg-yellow-50 text-yellow-800",
severity === "warning" && "bg-yellow-50 text-yellow-700",
severity === "success" && "bg-gray-900 text-white"
)}>
<div className="flex">
@@ -32,10 +33,11 @@ export function Alert(props: AlertProps) {
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
)}
</div>
<div className="ml-3">
<div className="flex-grow ml-3">
<h3 className="text-sm font-medium">{props.title}</h3>
<div className="text-sm">{props.message}</div>
</div>
{props.actions && <div className="text-sm">{props.actions}</div>}
</div>
</div>
);

View File

@@ -1,23 +1,27 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as Tooltip from "@radix-ui/react-tooltip";
import { defaultAvatarSrc } from "@lib/profile";
import classNames from "@lib/classNames";
import { defaultAvatarSrc } from "@lib/profile";
import { Maybe } from "@trpc/server";
export type AvatarProps = {
className?: string;
size: number;
imageSrc?: string;
size?: number;
imageSrc?: Maybe<string>;
title?: string;
alt: string;
gravatarFallbackMd5?: string;
};
export default function Avatar({ imageSrc, gravatarFallbackMd5, size, alt, title, ...props }: AvatarProps) {
const className = classNames("rounded-full", props.className, `h-${size} w-${size}`);
export default function Avatar(props: AvatarProps) {
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
const className = classNames("rounded-full", props.className, size && `h-${size} w-${size}`);
const avatar = (
<AvatarPrimitive.Root>
<AvatarPrimitive.Image
src={imageSrc}
src={imageSrc ?? undefined}
alt={alt}
className={classNames("rounded-full", `h-auto w-${size}`, props.className)}
/>
@@ -32,7 +36,7 @@ export default function Avatar({ imageSrc, gravatarFallbackMd5, size, alt, title
return title ? (
<Tooltip.Tooltip delayDuration={300}>
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
<Tooltip.Content className="p-2 rounded-sm text-sm bg-black 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

@@ -1,6 +1,9 @@
import React from "react";
import Avatar from "@components/ui/Avatar";
import classNames from "@lib/classNames";
import Avatar from "@components/ui/Avatar";
// import * as Tooltip from "@radix-ui/react-tooltip";
export type AvatarGroupProps = {
@@ -9,7 +12,7 @@ export type AvatarGroupProps = {
items: {
image: string;
title?: string;
alt: string;
alt?: string;
}[];
className?: string;
};
@@ -25,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-black 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

@@ -1,6 +1,7 @@
import classNames from "@lib/classNames";
import React from "react";
import classNames from "@lib/classNames";
export type BadgeProps = {
variant: "default" | "success" | "gray";
} & JSX.IntrinsicElements["span"];
@@ -12,7 +13,7 @@ export const Badge = function Badge(props: BadgeProps) {
<span
{...passThroughProps}
className={classNames(
"font-bold px-2 py-0.5 inline-block rounded-sm",
"font-bold px-2 py-0.5 inline-block rounded-sm text-xs",
variant === "default" && "bg-yellow-100 text-yellow-800",
variant === "success" && "bg-green-100 text-green-800",
variant === "gray" && "bg-gray-200 text-gray-800",

View File

@@ -1,22 +1,24 @@
import classNames from "@lib/classNames";
import Link, { LinkProps } from "next/link";
import React, { forwardRef } from "react";
type SVGComponent = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
import classNames from "@lib/classNames";
import { SVGComponent } from "@lib/types/SVGComponent";
export type ButtonProps = {
export type ButtonBaseProps = {
color?: "primary" | "secondary" | "minimal" | "warn";
size?: "base" | "sm" | "lg" | "fab";
size?: "base" | "sm" | "lg" | "fab" | "icon";
loading?: boolean;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
StartIcon?: SVGComponent;
EndIcon?: SVGComponent;
shallow?: boolean;
} & (
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
| (JSX.IntrinsicElements["button"] & { href?: never })
);
};
export type ButtonProps = ButtonBaseProps &
(
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
| (JSX.IntrinsicElements["button"] & { href?: never })
);
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
props: ButtonProps,
@@ -52,6 +54,8 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-sm",
size === "base" && "px-3 py-2 text-sm font-medium rounded-sm",
size === "lg" && "px-4 py-2 text-base font-medium rounded-sm",
size === "icon" &&
"group p-2 border rounded-sm border-transparent text-neutral-400 hover:border-gray-200 transition",
// turn button into a floating action button (fab)
size === "fab" ? "fixed" : "relative",
size === "fab" && "justify-center bottom-20 right-8 rounded-full p-4 w-14 h-14",
@@ -60,7 +64,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
color === "primary" &&
(disabled
? "border border-transparent bg-gray-400 text-white"
: "border border-transparent text-white bg-neutral-900 hover:bg-neutral-800 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"
@@ -72,7 +76,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
color === "warn" &&
(disabled
? "text-gray-400 bg-transparent"
: "text-red-700 bg-transparent hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
: "text-gray-700 bg-transparent hover:text-red-700 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
// set not-allowed cursor if disabled
loading ? "cursor-wait" : disabled ? "cursor-not-allowed" : "",
props.className
@@ -85,14 +89,16 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
: props.onClick,
},
<>
{StartIcon && <StartIcon className="inline w-5 h-5 mr-2 -ml-1" />}
{StartIcon && (
<StartIcon className={classNames("inline", size === "icon" ? "w-5 h-5 " : "w-5 h-5 mr-2 -ml-1")} />
)}
{props.children}
{loading && (
<div className="absolute transform -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2">
<svg
className={classNames(
"w-5 h-5 mx-4 animate-spin",
color === "primary" ? "text-white" : "text-black"
color === "primary" ? "dark:text-black text-white" : "text-black"
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"

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

@@ -0,0 +1,28 @@
import Link from "next/link";
import { useLocale } from "@lib/hooks/useLocale";
const PoweredByCal = () => {
const { t } = useLocale();
return (
<div className="text-xs text-center sm:text-right p-1">
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
<a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
{t("powered_by")}{" "}
<img
className="dark:hidden w-auto inline h-[10px] relative -mt-px"
src="https://cal.com/logo.svg"
alt="Cal.com Logo"
/>
<img
className="hidden dark:inline w-auto h-[10px] relativ -mt-px"
src="https://cal.com/logo-white.svg"
alt="Cal.com Logo"
/>
</a>
</Link>
</div>
);
};
export default PoweredByCal;

View File

@@ -1,25 +0,0 @@
import Link from "next/link";
const PoweredByCalendso = () => (
<div className="text-xs text-center sm:text-right p-1">
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
<a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
powered by{" "}
<img
style={{ top: -2 }}
className="dark:hidden w-auto inline h-3 relative"
src="/calendso-logo-word.svg"
alt="Cal.com Logo"
/>
<img
style={{ top: -2 }}
className="hidden dark:inline w-auto h-3 relative"
src="/calendso-logo-word-dark.svg"
alt="Cal.com Logo"
/>
</a>
</Link>
</div>
);
export default PoweredByCalendso;

View File

@@ -1,333 +0,0 @@
import React from "react";
import Text from "@components/ui/Text";
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
import dayjs, { Dayjs } from "dayjs";
import classnames from "classnames";
export const SCHEDULE_FORM_ID = "SCHEDULE_FORM_ID";
export const toCalendsoAvailabilityFormat = (schedule: Schedule) => {
return schedule;
};
export const _24_HOUR_TIME_FORMAT = `HH:mm:ss`;
const DEFAULT_START_TIME = "09:00:00";
const DEFAULT_END_TIME = "17:00:00";
/** Begin Time Increments For Select */
const increment = 15;
/**
* Creates an array of times on a 15 minute interval from
* 00:00:00 (Start of day) to
* 23:45:00 (End of day with enough time for 15 min booking)
*/
const TIMES = (() => {
const starting_time = dayjs().startOf("day");
const ending_time = dayjs().endOf("day");
const times = [];
let t: Dayjs = starting_time;
while (t.isBefore(ending_time)) {
times.push(t);
t = t.add(increment, "minutes");
}
return times;
})();
/** End Time Increments For Select */
const DEFAULT_SCHEDULE: Schedule = {
monday: [{ start: "09:00:00", end: "17:00:00" }],
tuesday: [{ start: "09:00:00", end: "17:00:00" }],
wednesday: [{ start: "09:00:00", end: "17:00:00" }],
thursday: [{ start: "09:00:00", end: "17:00:00" }],
friday: [{ start: "09:00:00", end: "17:00:00" }],
saturday: null,
sunday: null,
};
type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
export type TimeRange = {
start: string;
end: string;
};
export type FreeBusyTime = TimeRange[];
export type Schedule = {
monday?: FreeBusyTime | null;
tuesday?: FreeBusyTime | null;
wednesday?: FreeBusyTime | null;
thursday?: FreeBusyTime | null;
friday?: FreeBusyTime | null;
saturday?: FreeBusyTime | null;
sunday?: FreeBusyTime | null;
};
type ScheduleBlockProps = {
day: DayOfWeek;
ranges?: FreeBusyTime | null;
selected?: boolean;
};
type Props = {
schedule?: Schedule;
onChange?: (data: Schedule) => void;
onSubmit: (data: Schedule) => void;
};
const SchedulerForm = ({ schedule = DEFAULT_SCHEDULE, onSubmit }: Props) => {
const ref = React.useRef<HTMLFormElement>(null);
const transformElementsToSchedule = (elements: HTMLFormControlsCollection): Schedule => {
const schedule: Schedule = {};
const formElements = Array.from(elements)
.map((element) => {
return element.id;
})
.filter((value) => value);
/**
* elementId either {day} or {day.N.start} or {day.N.end}
* If elementId in DAYS_ARRAY add elementId to scheduleObj
* then element is the checkbox and can be ignored
*
* If elementId starts with a day in DAYS_ARRAY
* the elementId should be split by "." resulting in array length 3
* [day, rangeIndex, "start" | "end"]
*/
formElements.forEach((elementId) => {
const [day, rangeIndex, rangeId] = elementId.split(".");
if (rangeIndex && rangeId) {
if (!schedule[day]) {
schedule[day] = [];
}
if (!schedule[day][parseInt(rangeIndex)]) {
schedule[day][parseInt(rangeIndex)] = {};
}
schedule[day][parseInt(rangeIndex)][rangeId] = elements[elementId].value;
}
});
return schedule;
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const elements = ref.current?.elements;
if (elements) {
const schedule = transformElementsToSchedule(elements);
onSubmit && typeof onSubmit === "function" && onSubmit(schedule);
}
};
const ScheduleBlock = ({ day, ranges: defaultRanges, selected: defaultSelected }: ScheduleBlockProps) => {
const [ranges, setRanges] = React.useState(defaultRanges);
const [selected, setSelected] = React.useState(defaultSelected);
React.useEffect(() => {
if (!ranges || ranges.length === 0) {
setSelected(false);
} else {
setSelected(true);
}
}, [ranges]);
const handleSelectedChange = () => {
if (!selected && (!ranges || ranges.length === 0)) {
setRanges([
{
start: "09:00:00",
end: "17:00:00",
},
]);
}
setSelected(!selected);
};
const handleAddRange = () => {
let rangeToAdd;
if (!ranges || ranges?.length === 0) {
rangeToAdd = {
start: DEFAULT_START_TIME,
end: DEFAULT_END_TIME,
};
setRanges([rangeToAdd]);
} else {
const lastRange = ranges[ranges.length - 1];
const [hour, minute, second] = lastRange.end.split(":");
const date = dayjs()
.set("hour", parseInt(hour))
.set("minute", parseInt(minute))
.set("second", parseInt(second));
const nextStartTime = date.add(1, "hour");
const nextEndTime = date.add(2, "hour");
/**
* If next range goes over into "tomorrow"
* i.e. time greater that last value in Times
* return
*/
if (nextStartTime.isAfter(date.endOf("day"))) {
return;
}
rangeToAdd = {
start: nextStartTime.format(_24_HOUR_TIME_FORMAT),
end: nextEndTime.format(_24_HOUR_TIME_FORMAT),
};
setRanges([...ranges, rangeToAdd]);
}
};
const handleDeleteRange = (range: TimeRange) => {
if (ranges && ranges.length > 0) {
setRanges(
ranges.filter((r: TimeRange) => {
return r.start != range.start;
})
);
}
};
/**
* Should update ranges values
*/
const handleSelectRangeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const [day, rangeIndex, rangeId] = event.currentTarget.name.split(".");
if (day && ranges) {
const newRanges = ranges.map((range, index) => {
const newRange = {
...range,
[rangeId]: event.currentTarget.value,
};
return index === parseInt(rangeIndex) ? newRange : range;
});
setRanges(newRanges);
}
};
const TimeRangeField = ({ range, day, index }: { range: TimeRange; day: DayOfWeek; index: number }) => {
const timeOptions = (type: "start" | "end") =>
TIMES.map((time) => (
<option
key={`${day}.${index}.${type}.${time.format(_24_HOUR_TIME_FORMAT)}`}
value={time.format(_24_HOUR_TIME_FORMAT)}>
{time.toDate().toLocaleTimeString(undefined, { minute: "numeric", hour: "numeric" })}
</option>
));
return (
<div key={`${day}-range-${index}`} className="flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<select
id={`${day}.${index}.start`}
name={`${day}.${index}.start`}
defaultValue={range?.start || DEFAULT_START_TIME}
onChange={handleSelectRangeChange}
className="block px-4 pr-8 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-sm">
{timeOptions("start")}
</select>
<Text>-</Text>
<select
id={`${day}.${index}.end`}
name={`${day}.${index}.end`}
defaultValue={range?.end || DEFAULT_END_TIME}
onChange={handleSelectRangeChange}
className=" block px-4 pr-8 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-sm">
{timeOptions("end")}
</select>
</div>
<div className="">
<DeleteAction range={range} />
</div>
</div>
);
};
const Actions = () => {
return (
<div className="flex items-center space-x-2">
<button type="button" onClick={() => handleAddRange()}>
<PlusIcon className="h-5 w-5 text-neutral-400 hover:text-neutral-500" />
</button>
</div>
);
};
const DeleteAction = ({ range }: { range: TimeRange }) => {
return (
<button type="button" onClick={() => handleDeleteRange(range)}>
<TrashIcon className="h-5 w-5 text-neutral-400 hover:text-neutral-500" />
</button>
);
};
return (
<fieldset className=" py-6">
<section
className={classnames(
"flex flex-col space-y-6 sm:space-y-0 sm:flex-row sm:justify-between",
ranges && ranges?.length > 1 ? "sm:items-start" : "sm:items-center"
)}>
<div style={{ minWidth: "33%" }} className="flex items-center justify-between">
<div className="flex items-center space-x-2 ">
<input
id={day}
name={day}
checked={selected}
onChange={handleSelectedChange}
type="checkbox"
className="focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm"
/>
<Text variant="overline">{day}</Text>
</div>
<div className="sm:hidden justify-self-end self-end">
<Actions />
</div>
</div>
<div className="space-y-2 w-full">
{selected && ranges && ranges.length != 0 ? (
ranges.map((range, index) => (
<TimeRangeField key={`${day}-range-${index}`} range={range} index={index} day={day} />
))
) : (
<Text key={`${day}`} variant="caption">
Unavailable
</Text>
)}
</div>
<div className="hidden sm:block px-2">
<Actions />
</div>
</section>
</fieldset>
);
};
return (
<>
<form id={SCHEDULE_FORM_ID} onSubmit={handleSubmit} ref={ref} className="divide-y divide-gray-200">
{Object.keys(schedule).map((day) => {
const selected = schedule[day as DayOfWeek] != null;
return (
<ScheduleBlock
key={`${day}`}
day={day as DayOfWeek}
ranges={schedule[day as DayOfWeek]}
selected={selected}
/>
);
})}
</form>
</>
);
};
export default SchedulerForm;

View File

@@ -1,12 +1,18 @@
import React, { useEffect, useState } from "react";
import TimezoneSelect from "react-timezone-select";
import { TrashIcon } from "@heroicons/react/outline";
import { Availability } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import React, { useEffect, useState } from "react";
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import { useLocale } from "@lib/hooks/useLocale";
import { WorkingHours } from "@lib/types/schedule";
import Button from "@components/ui/Button";
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { Availability } from "@prisma/client";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -14,43 +20,30 @@ dayjs.extend(timezone);
type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: unknown;
setTimeZone: (timeZone: string) => void;
setAvailability: (schedule: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] }) => void;
};
export const Scheduler = ({
availability,
setAvailability,
timeZone: selectedTimeZone,
setTimeZone,
}: Props) => {
/**
* @deprecated
*/
export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone }: Props) => {
const { t, i18n } = useLocale();
const [editSchedule, setEditSchedule] = useState(-1);
const [dateOverrides, setDateOverrides] = useState([]);
const [openingHours, setOpeningHours] = useState([]);
const [openingHours, setOpeningHours] = useState<Availability[]>([]);
useEffect(() => {
setOpeningHours(
availability
.filter((item: Availability) => item.days.length !== 0)
.map((item) => {
item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes");
item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes");
return item;
})
);
setDateOverrides(availability.filter((item: Availability) => item.date));
setOpeningHours(availability.filter((item: Availability) => item.days.length !== 0));
}, []);
// updates availability to how it should be formatted outside this component.
useEffect(() => {
setAvailability({
dateOverrides: dateOverrides,
openingHours: openingHours,
});
}, [dateOverrides, openingHours]);
setAvailability({ openingHours, dateOverrides: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openingHours]);
const addNewSchedule = () => setEditSchedule(openingHours.length);
const applyEditSchedule = (changed) => {
const applyEditSchedule = (changed: Availability) => {
// new entry
if (!changed.days) {
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
@@ -59,39 +52,33 @@ export const Scheduler = ({
// update
const replaceWith = { ...openingHours[editSchedule], ...changed };
openingHours.splice(editSchedule, 1, replaceWith);
setOpeningHours([].concat(openingHours));
setOpeningHours([...openingHours]);
}
};
const removeScheduleAt = (toRemove: number) => {
openingHours.splice(toRemove, 1);
setOpeningHours([].concat(openingHours));
setOpeningHours([...openingHours]);
};
const OpeningHours = ({ idx, item }) => (
<li className="py-2 flex justify-between border-b">
const OpeningHours = ({ idx, item }: { idx: number; item: Availability }) => (
<li className="flex justify-between py-2 border-b">
<div className="flex flex-col space-y-4 lg:inline-flex">
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
<button
className="text-sm bg-neutral-100 rounded-sm py-2 px-3"
className="px-3 py-2 text-sm rounded-sm bg-neutral-100"
type="button"
onClick={() => setEditSchedule(idx)}>
{dayjs()
.startOf("day")
.add(item.startTime, "minutes")
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
&nbsp;until&nbsp;
{dayjs()
.startOf("day")
.add(item.endTime, "minutes")
.format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
{item.startTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
&nbsp;{t("until")}&nbsp;
{item.endTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
</button>
</div>
<button
type="button"
onClick={() => removeScheduleAt(idx)}
className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-5 w-5 inline text-gray-400 -mt-1" />
className="px-2 py-1 ml-1 bg-transparent btn-sm">
<TrashIcon className="inline w-5 h-5 -mt-1 text-gray-400" />
</button>
</li>
);
@@ -100,16 +87,16 @@ export const Scheduler = ({
<div>
<div className="flex">
<div className="w-full">
<div className="">
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
Timezone
{t("timezone")}
</label>
<div className="mt-1">
<TimezoneSelect
id="timeZone"
value={{ value: selectedTimeZone }}
onChange={(tz) => setTimeZone(tz.value)}
className="shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
value={timeZone}
onChange={(tz: ITimezoneOption) => setTimeZone(tz.value)}
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
</div>
@@ -118,16 +105,36 @@ export const Scheduler = ({
<OpeningHours key={idx} idx={idx} item={item} />
))}
</ul>
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm mt-2">
Add another
</button>
<Button type="button" onClick={addNewSchedule} className="mt-2" color="secondary" size="sm">
{t("add_another")}
</Button>
</div>
</div>
{editSchedule >= 0 && (
<SetTimesModal
startTime={openingHours[editSchedule] ? openingHours[editSchedule].startTime : 540}
endTime={openingHours[editSchedule] ? openingHours[editSchedule].endTime : 1020}
onChange={(times) => applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
startTime={
openingHours[editSchedule]
? new Date(openingHours[editSchedule].startTime).getHours() * 60 +
new Date(openingHours[editSchedule].startTime).getMinutes()
: 540
}
endTime={
openingHours[editSchedule]
? new Date(openingHours[editSchedule].endTime).getHours() * 60 +
new Date(openingHours[editSchedule].endTime).getMinutes()
: 1020
}
onChange={(times: { startTime: number; endTime: number }) =>
applyEditSchedule({
...(openingHours[editSchedule] || {}),
startTime: new Date(
new Date().setHours(Math.floor(times.startTime / 60), times.startTime % 60, 0, 0)
),
endTime: new Date(
new Date().setHours(Math.floor(times.endTime / 60), times.endTime % 60, 0, 0)
),
})
}
onExit={() => setEditSchedule(-1)}
/>
)}

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,12 +1,14 @@
import { useState } from "react";
import * as PrimitiveSwitch from "@radix-ui/react-switch";
import { useId } from "@radix-ui/react-id";
import * as Label from "@radix-ui/react-label";
import * as PrimitiveSwitch from "@radix-ui/react-switch";
import React, { useState } from "react";
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
import classNames from "@lib/classNames";
export default function Switch(props) {
type SwitchProps = React.ComponentProps<typeof PrimitiveSwitch.Root> & {
label: string;
};
export default function Switch(props: SwitchProps) {
const { label, onCheckedChange, ...primitiveProps } = props;
const [checked, setChecked] = useState(props.defaultChecked || false);
@@ -16,7 +18,7 @@ export default function Switch(props) {
}
setChecked(change);
};
const id = useId();
return (
<div className="flex items-center h-[20px]">
<PrimitiveSwitch.Root
@@ -25,6 +27,7 @@ export default function Switch(props) {
onCheckedChange={onPrimitiveCheckedChange}
{...primitiveProps}>
<PrimitiveSwitch.Thumb
id={id}
className={classNames(
"bg-white w-[16px] h-[16px] block transition-transform",
checked ? "translate-x-[16px]" : "translate-x-0"
@@ -32,7 +35,9 @@ export default function Switch(props) {
/>
</PrimitiveSwitch.Root>
{label && (
<Label.Root className="text-neutral-700 align-text-top ml-3 font-medium cursor-pointer">
<Label.Root
htmlFor={id}
className="text-neutral-700 text-sm align-text-top ml-3 font-medium cursor-pointer">
{label}
</Label.Root>
)}

View File

@@ -0,0 +1,96 @@
import { Menu, Transition } from "@headlessui/react";
import { DotsHorizontalIcon } from "@heroicons/react/solid";
import React, { FC, Fragment } from "react";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { SVGComponent } from "@lib/types/SVGComponent";
import Button from "./Button";
export type ActionType = {
id: string;
icon: SVGComponent;
label: string;
disabled?: boolean;
color?: "primary" | "secondary";
} & ({ href?: never; onClick: () => any } | { href: string; onClick?: never });
interface Props {
actions: ActionType[];
}
const TableActions: FC<Props> = ({ actions }) => {
const { t } = useLocale();
return (
<>
<div className="space-x-2 hidden lg:block">
{actions.map((action) => (
<Button
key={action.id}
data-testid={action.id}
href={action.href}
onClick={action.onClick}
StartIcon={action.icon}
disabled={action.disabled}
color={action.color || "secondary"}>
{action.label}
</Button>
))}
</div>
<Menu as="div" className="inline-block lg:hidden text-left ">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<span className="sr-only">{t("open_options")}</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
<div className="py-1">
{actions.map((action) => {
const Element = typeof action.onClick === "function" ? "span" : "a";
return (
<Menu.Item key={action.id} disabled={action.disabled}>
{({ active }) => (
<Element
href={action.href}
onClick={action.onClick}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<action.icon
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
{action.label}
</Element>
)}
</Menu.Item>
);
})}
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</>
);
};
export default TableActions;

View File

@@ -1,9 +1,10 @@
import React from "react";
import classnames from "classnames";
import React from "react";
import { TextProps } from "../Text";
const Body: React.FunctionComponent<TextProps> = (props: TextProps) => {
const classes = classnames("text-lg leading-relaxed text-gray-900 dark:text-white", props?.className);
const classes = classnames("text-gray-900 dark:text-white", props?.className);
return <p className={classes}>{props?.text || props.children}</p>;
};

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