Compare commits
493 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33694196e1 | ||
|
|
b5569c6b1c | ||
|
|
4e74c0e27f | ||
|
|
5e8a80001d | ||
|
|
9921b76da0 | ||
|
|
73f607f27a | ||
|
|
fac4de1144 | ||
|
|
e5f8437282 | ||
|
|
1a20b0a0c6 | ||
|
|
ffc0f460a0 | ||
|
|
c9c21e6a67 | ||
|
|
9c94aadbf7 | ||
|
|
f8c036164c | ||
|
|
67bcbfd75a | ||
|
|
54be2a2ec1 | ||
|
|
4608b9d56d | ||
|
|
8d9c69916b | ||
|
|
643e64a0e4 | ||
|
|
20404611b0 | ||
|
|
f7fda47534 | ||
|
|
c48d0d6c34 | ||
|
|
861cfdfed0 | ||
|
|
fbc1df9a30 | ||
|
|
ac6275b906 | ||
|
|
d2b9e67424 | ||
|
|
70683a89b9 | ||
|
|
59d4d92b52 | ||
|
|
8b68263800 | ||
|
|
7739994f4e | ||
|
|
a61cb690af | ||
|
|
e24d8889fc | ||
|
|
f0abf47ecc | ||
|
|
1c0c3c7690 | ||
|
|
09c4040ce5 | ||
|
|
57eeb48a8e | ||
|
|
84d75cf693 | ||
|
|
bf46038474 | ||
|
|
e93b7d942a | ||
|
|
6e7359ae96 | ||
|
|
bd2a795d7a | ||
|
|
8a70ea66e9 | ||
|
|
46df4c048e | ||
|
|
592dcd36b3 | ||
|
|
3bb76a3a62 | ||
|
|
4537117624 | ||
|
|
9b5da1bca3 | ||
|
|
80bd7fd89b | ||
|
|
a66610d9c2 | ||
|
|
4cd7a4ce5b | ||
|
|
baa7e868bd | ||
|
|
445faa406a | ||
|
|
4be4a01968 | ||
|
|
bc46f4fbc4 | ||
|
|
9b583694a3 | ||
|
|
81e2ae1352 | ||
|
|
3e1fe30186 | ||
|
|
f91ed7837c | ||
|
|
1567feb75e | ||
|
|
43a721dce6 | ||
|
|
0f82427b1e | ||
|
|
3e5987abec | ||
|
|
3761a75b28 | ||
|
|
9d7dc09974 | ||
|
|
bab72f1514 | ||
|
|
fbbd7ea45a | ||
|
|
7bc7b241ac | ||
|
|
0dd72888a9 | ||
|
|
a6382cf07f | ||
|
|
39761c520e | ||
|
|
c9a8bd369c | ||
|
|
d95e26d55c | ||
|
|
3bc659af44 | ||
|
|
cbf528c33e | ||
|
|
38f762f7b2 | ||
|
|
94a10992d2 | ||
|
|
26e1194ef3 | ||
|
|
21103580f7 | ||
|
|
ca405743fb | ||
|
|
6b426b5386 | ||
|
|
c0c4cb53db | ||
|
|
c21f0c2d49 | ||
|
|
4ce879e5dc | ||
|
|
25372b3c9e | ||
|
|
a3bd226347 | ||
|
|
e6f71c81bb | ||
|
|
972402be2c | ||
|
|
5c5d9d3406 | ||
|
|
c2a60657d4 | ||
|
|
d2965627d0 | ||
|
|
5deea2c5f6 | ||
|
|
7e6d56ca1f | ||
|
|
725a7ec0f4 | ||
|
|
ad8ffd3de4 | ||
|
|
8e447ea4b5 | ||
|
|
8bbfc0c7f0 | ||
|
|
2abd7779ac | ||
|
|
b6518b9ce1 | ||
|
|
43c939e342 | ||
|
|
357e279dd8 | ||
|
|
8afcba23c8 | ||
|
|
c359ebe85c | ||
|
|
3587e1ac9c | ||
|
|
3ff99f7877 | ||
|
|
c1d90eb438 | ||
|
|
5902f78fb2 | ||
|
|
8617b2db65 | ||
|
|
850497ea80 | ||
|
|
1890d5daf7 | ||
|
|
8d4b3c1c2c | ||
|
|
22a6d6ee3b | ||
|
|
05fa1feab0 | ||
|
|
bbf96a2e1d | ||
|
|
3b00bc7508 | ||
|
|
878c8b8248 | ||
|
|
23127318dc | ||
|
|
db7711869f | ||
|
|
ec2acedf34 | ||
|
|
d76ef4a007 | ||
|
|
c43e6783a7 | ||
|
|
91f2c380c5 | ||
|
|
b11d81fdd2 | ||
|
|
6792e17c80 | ||
|
|
dd446abeec | ||
|
|
c109ab1e30 | ||
|
|
dc13c95644 | ||
|
|
c85f0650fe | ||
|
|
7e6628e3ac | ||
|
|
e8af9110a7 | ||
|
|
ec9c8bb35d | ||
|
|
22aa083883 | ||
|
|
8c1b69cc0f | ||
|
|
8d1d3fcc7a | ||
|
|
dd48749f42 | ||
|
|
bd51316c68 | ||
|
|
2430784142 | ||
|
|
2b51cd9c8d | ||
|
|
de3c4aa75a | ||
|
|
813eaa83b7 | ||
|
|
ec2d0a89ba | ||
|
|
80a2b6c068 | ||
|
|
7faa9508c4 | ||
|
|
5773d064c2 | ||
|
|
0a7233d452 | ||
|
|
529f3027cd | ||
|
|
20cbab1c15 | ||
|
|
98d3cb1915 | ||
|
|
8322e5c8d1 | ||
|
|
36767afbf5 | ||
|
|
396355e350 | ||
|
|
da2c825dad | ||
|
|
dab146a313 | ||
|
|
712266664e | ||
|
|
f8781e4d5f | ||
|
|
d8b3c42c28 | ||
|
|
24e36ad46a | ||
|
|
644b0f1b0e | ||
|
|
deb97fdab0 | ||
|
|
58f55f84e2 | ||
|
|
5b3dd02747 | ||
|
|
ecc960f0a3 | ||
|
|
dfb1b5602d | ||
|
|
d6dd13a9d8 | ||
|
|
e0d1b6b5ea | ||
|
|
ffdf0b9217 | ||
|
|
f3c95fa3de | ||
|
|
669b7798db | ||
|
|
4e01b13133 | ||
|
|
11e7779a58 | ||
|
|
7de35dc4e5 | ||
|
|
0a34512e3b | ||
|
|
9fd078c59d | ||
|
|
11269229ae | ||
|
|
6b171a6f87 | ||
|
|
6fa980f801 | ||
|
|
fd2adae10f | ||
|
|
f0ff048f4d | ||
|
|
7998b6248d | ||
|
|
6e100a6559 | ||
|
|
d4f8030b6b | ||
|
|
bf659c0b16 | ||
|
|
16fba702fb | ||
|
|
87b2ecec26 | ||
|
|
990b106c8b | ||
|
|
aaa48372b1 | ||
|
|
7c6dd7a73b | ||
|
|
8664d217c9 | ||
|
|
559ccb8ca7 | ||
|
|
43fa4f6497 | ||
|
|
55a7cc928f | ||
|
|
4ce9f9110b | ||
|
|
528c620aa7 | ||
|
|
df687009bd | ||
|
|
9befd4abb9 | ||
|
|
a404ca847c | ||
|
|
0ef6d8b452 | ||
|
|
b49553c960 | ||
|
|
86cb961fa8 | ||
|
|
0e5a7cdd55 | ||
|
|
823abfb3da | ||
|
|
debef8119e | ||
|
|
773e9ac57e | ||
|
|
0e9c50a58d | ||
|
|
401016772e | ||
|
|
3c5a84b7b4 | ||
|
|
ac3569b78e | ||
|
|
0ee523643d | ||
|
|
b82f2be391 | ||
|
|
ae09e220d0 | ||
|
|
aafbe626fc | ||
|
|
a002b194da | ||
|
|
d147772d91 | ||
|
|
8e669cf21e | ||
|
|
1a361283ed | ||
|
|
d4b70162e8 | ||
|
|
6318972aa1 | ||
|
|
256305eb6b | ||
|
|
265c634db9 | ||
|
|
5a25e6daee | ||
|
|
68e062c100 | ||
|
|
15e3b28a23 | ||
|
|
d7be47ed10 | ||
|
|
c1fb56b4b7 | ||
|
|
8a87cb4f3b | ||
|
|
6464cee5a1 | ||
|
|
8cb42c44b7 | ||
|
|
4f75b94d88 | ||
|
|
ad46fc121d | ||
|
|
863fc2e5cc | ||
|
|
b7435b5b93 | ||
|
|
307856f8e6 | ||
|
|
6635363521 | ||
|
|
88f10e0586 | ||
|
|
9475c5836d | ||
|
|
a83717363f | ||
|
|
317e3d2ade | ||
|
|
a0c2e57891 | ||
|
|
1790aeb577 | ||
|
|
78523f7a57 | ||
|
|
cc25a772a1 | ||
|
|
5291dade42 | ||
|
|
ed4587b3af | ||
|
|
eefb829f75 | ||
|
|
2feed85a1a | ||
|
|
a5eb3fce28 | ||
|
|
91fca7477d | ||
|
|
98829d23d3 | ||
|
|
dddb494071 | ||
|
|
41382caa6c | ||
|
|
265b76083a | ||
|
|
94f3ae1c64 | ||
|
|
5af159cf4e | ||
|
|
f91de82daf | ||
|
|
e38086b8fe | ||
|
|
eabb096e14 | ||
|
|
605586ea1b | ||
|
|
b6b307605b | ||
|
|
9879f9910a | ||
|
|
22682aa54d | ||
|
|
baba307a9f | ||
|
|
9842aaaf6a | ||
|
|
9efa429294 | ||
|
|
a9df3b9ad0 | ||
|
|
8d6fec79d3 | ||
|
|
356d470e16 | ||
|
|
1043b31cc7 | ||
|
|
69a54d10df | ||
|
|
1649d41dd5 | ||
|
|
d780e39241 | ||
|
|
3eaf48fac2 | ||
|
|
3f1066e1c3 | ||
|
|
c42dce2fdb | ||
|
|
d6a5d1f3da | ||
|
|
a6eed6ffcc | ||
|
|
c28d800aa9 | ||
|
|
b8e8319b23 | ||
|
|
d63a180bb2 | ||
|
|
85d7122e43 | ||
|
|
bd99a06765 | ||
|
|
9e16007c05 | ||
|
|
02c62c18ef | ||
|
|
40377215f6 | ||
|
|
45ecf0c49c | ||
|
|
ade88700fc | ||
|
|
362e8114a0 | ||
|
|
a9b6b5f066 | ||
|
|
06822a2c57 | ||
|
|
1447251c83 | ||
|
|
687af03cc3 | ||
|
|
9e69029943 | ||
|
|
86d292838c | ||
|
|
b753d9e5e3 | ||
|
|
d8dac426eb | ||
|
|
12f6065d84 | ||
|
|
415d5fe8bd | ||
|
|
4d5b5663c0 | ||
|
|
656d58b94f | ||
|
|
c146231b31 | ||
|
|
f08a2271fe | ||
|
|
22c4d29db5 | ||
|
|
a67813ee77 | ||
|
|
a32e002fd7 | ||
|
|
3641d5e46e | ||
|
|
c01004b470 | ||
|
|
78182db99c | ||
|
|
5ffee8646e | ||
|
|
ce8e9c126b | ||
|
|
b5e176a87e | ||
|
|
c4e2b6b458 | ||
|
|
e1f4ba06d8 | ||
|
|
12f72e0283 | ||
|
|
59e25ad04e | ||
|
|
c2c37b701e | ||
|
|
2ce2bb1ca8 | ||
|
|
b74dbdca9f | ||
|
|
60db5823c7 | ||
|
|
f955ccdef9 | ||
|
|
55d77993af | ||
|
|
f1eae5fe77 | ||
|
|
a711670a70 | ||
|
|
f4d2f3b3d1 | ||
|
|
109cc51e7a | ||
|
|
1783dae121 | ||
|
|
3b844583c9 | ||
|
|
a71d97a4ad | ||
|
|
0861d7cc61 | ||
|
|
26f20e2397 | ||
|
|
e615347790 | ||
|
|
cf92deb145 | ||
|
|
dc4331ed17 | ||
|
|
5c4f4bfd90 | ||
|
|
ec6b897191 | ||
|
|
9e2f8de313 | ||
|
|
bee41b242b | ||
|
|
4ce4b141b4 | ||
|
|
f9e315d10a | ||
|
|
0871f5edf8 | ||
|
|
4b05c56a0d | ||
|
|
64a01d33ba | ||
|
|
9539d26ac7 | ||
|
|
c94231f777 | ||
|
|
cfd70172f0 | ||
|
|
7dd6fdde7a | ||
|
|
6f204ca521 | ||
|
|
392c8e8da4 | ||
|
|
2fd25acc3c | ||
|
|
a73187d46b | ||
|
|
c3dc18643e | ||
|
|
7dc4a55319 | ||
|
|
f27b0b3cad | ||
|
|
683713e73f | ||
|
|
69c808333e | ||
|
|
ff5b2b1668 | ||
|
|
6f219fc98c | ||
|
|
82f11b5121 | ||
|
|
8cbf880af6 | ||
|
|
7488f29dc9 | ||
|
|
0927b86831 | ||
|
|
9ef4815ffb | ||
|
|
bcf20914d3 | ||
|
|
fdd4bd2e14 | ||
|
|
e83980a999 | ||
|
|
b794469c05 | ||
|
|
014b74be8c | ||
|
|
f03a2c2a1a | ||
|
|
35dd3f088c | ||
|
|
015b7c18af | ||
|
|
2c9b301b77 | ||
|
|
33a683d4b0 | ||
|
|
32567c8e80 | ||
|
|
adee3fd211 | ||
|
|
58de920951 | ||
|
|
30f97117e8 | ||
|
|
a9ee2ef9ae | ||
|
|
c62e1a0eeb | ||
|
|
effbd44f48 | ||
|
|
521be467a4 | ||
|
|
95b49a5995 | ||
|
|
4474e9dd74 | ||
|
|
d272f32ee3 | ||
|
|
5254297944 | ||
|
|
d97da42950 | ||
|
|
6868474c92 | ||
|
|
4c07faefe7 | ||
|
|
785058558c | ||
|
|
abe4f38a5e | ||
|
|
f70d92df7e | ||
|
|
89e5da15df | ||
|
|
1662c9cf91 | ||
|
|
33273b18d3 | ||
|
|
eb93e778bd | ||
|
|
342ea3e5d2 | ||
|
|
e12c879242 | ||
|
|
6547ef1e86 | ||
|
|
4879479981 | ||
|
|
5bed09218a | ||
|
|
08f83dd85c | ||
|
|
3d3e99272a | ||
|
|
860db6c959 | ||
|
|
30163f0a78 | ||
|
|
60298f6eeb | ||
|
|
e33962686e | ||
|
|
c80992aa1c | ||
|
|
8ac56fbf4a | ||
|
|
378cf25521 | ||
|
|
dc6841e761 | ||
|
|
033c4835f7 | ||
|
|
a04336ba06 | ||
|
|
5318047794 | ||
|
|
079a920c2c | ||
|
|
a30381f229 | ||
|
|
e7314257c3 | ||
|
|
4a07c27da5 | ||
|
|
d7df292296 | ||
|
|
3014df61cc | ||
|
|
8f6689cfc1 | ||
|
|
7a8ad8381e | ||
|
|
f8a4f81991 | ||
|
|
e684824c79 | ||
|
|
3b71c86b1e | ||
|
|
230c82e316 | ||
|
|
b8c4dfb9e1 | ||
|
|
a4fbe7b2b4 | ||
|
|
0372289fe6 | ||
|
|
5478c135d1 | ||
|
|
a3abdac33b | ||
|
|
7779c098dc | ||
|
|
dd9f801872 | ||
|
|
f23e4f2b9d | ||
|
|
dcea723ea4 | ||
|
|
58dde562a3 | ||
|
|
9f2cfffce4 | ||
|
|
dc7b084bdf | ||
|
|
78c78a6981 | ||
|
|
35c450d5ef | ||
|
|
649e79bdc7 | ||
|
|
34300650e4 | ||
|
|
0938f6f4b2 | ||
|
|
c22beb698c | ||
|
|
7ab49acebe | ||
|
|
b23c032a4c | ||
|
|
22b050b9e7 | ||
|
|
6d2f89fc32 | ||
|
|
ee5cc32936 | ||
|
|
b125ece57b | ||
|
|
97cb7bdc83 | ||
|
|
515c548acd | ||
|
|
727793af02 | ||
|
|
9e7cb2c0b8 | ||
|
|
889b3f36ae | ||
|
|
50367c236a | ||
|
|
63930c1817 | ||
|
|
6a4d6c7eba | ||
|
|
a8ec6e7060 | ||
|
|
f709972f86 | ||
|
|
bb1da8150f | ||
|
|
c152e43b82 | ||
|
|
521bb4069a | ||
|
|
2b2fde179a | ||
|
|
420daec147 | ||
|
|
f5a7ed2e36 | ||
|
|
d3d6778c60 | ||
|
|
8ad685653c | ||
|
|
9e785c01fd | ||
|
|
ad3a06384f | ||
|
|
b741559dbc | ||
|
|
235e74440c | ||
|
|
6dfd3f4aba | ||
|
|
2c50781084 | ||
|
|
bcacc1d166 | ||
|
|
790ed3e6b1 | ||
|
|
7574c322c4 | ||
|
|
168db02e1f | ||
|
|
cb4a1e031e | ||
|
|
4d2e556d7d | ||
|
|
2bc4678ef0 | ||
|
|
bb3362f2ef | ||
|
|
82e7e51fca | ||
|
|
3764a9d462 | ||
|
|
4f964533cf | ||
|
|
51752bd2bd | ||
|
|
d194878bb2 | ||
|
|
3add84a279 | ||
|
|
43563bc8d5 | ||
|
|
81a3d82ce7 | ||
|
|
0b74ef35d2 | ||
|
|
a280739f01 | ||
|
|
e1f1386332 | ||
|
|
1c2998fc13 | ||
|
|
8eb3a31af4 | ||
|
|
a047177e72 | ||
|
|
d4f29464f2 | ||
|
|
48bc4c64f4 | ||
|
|
3c089af58a |
48
.env.example
48
.env.example
@@ -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
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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.
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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
31
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
|
||||
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@@ -1,9 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
commit-message:
|
||||
prefix: "⬆️"
|
||||
open-pull-requests-limit: 1
|
||||
@@ -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
|
||||
23
.github/workflows/cron-bookingReminder.yml
vendored
Normal file
23
.github/workflows/cron-bookingReminder.yml
vendored
Normal 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
|
||||
23
.github/workflows/cron-downgradeUsers.yml
vendored
Normal file
23
.github/workflows/cron-downgradeUsers.yml
vendored
Normal 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
25
.github/workflows/crowdin.yml
vendored
Normal 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 }}
|
||||
57
.github/workflows/e2e.yml
vendored
57
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/lint.yml
vendored
5
.github/workflows/lint.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Lint
|
||||
on: [push]
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -4,4 +4,5 @@ version = 1
|
||||
autoupdate_label = "♻️ autoupdate"
|
||||
|
||||
[approve]
|
||||
auto_approve_usernames = ["dependabot"]
|
||||
auto_approve_usernames = ["dependabot", "PeerRich", "baileypumfleet"]
|
||||
|
||||
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
# used in tandem with package.json engine to only enable yarn
|
||||
engine-strict=true
|
||||
@@ -3,4 +3,13 @@ node_modules
|
||||
public
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
**/**/public
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
.gitignore
|
||||
.npmignore
|
||||
.prettierignore
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
|
||||
@@ -7,4 +7,6 @@ module.exports = {
|
||||
semi: true,
|
||||
printWidth: 110,
|
||||
arrowParens: "always",
|
||||
importOrder: ["^@ee/(.*)$", "^@lib/(.*)$", "^@components/(.*)$", "^@(server|trpc)/(.*)$", "^[./]"],
|
||||
importOrderSeparation: true,
|
||||
};
|
||||
|
||||
1
.vercelignore
Normal file
1
.vercelignore
Normal file
@@ -0,0 +1 @@
|
||||
.github
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -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
15
.vscode/launch.json
vendored
Normal 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -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
257
@types/ical.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
48
README.md
48
README.md
@@ -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 -->
|
||||
|
||||
|
||||
165
calendso.yaml
165
calendso.yaml
@@ -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: {}
|
||||
|
||||
@@ -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;
|
||||
51
components/AddToHomescreen.tsx
Normal file
51
components/AddToHomescreen.tsx
Normal 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;
|
||||
}
|
||||
30
components/BookingsShell.tsx
Normal file
30
components/BookingsShell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
components/ClientSuspense.tsx
Normal file
9
components/ClientSuspense.tsx
Normal 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}</>;
|
||||
};
|
||||
40
components/CustomBranding.tsx
Normal file
40
components/CustomBranding.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
components/EmptyScreen.tsx
Normal file
27
components/EmptyScreen.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
components/I18nLanguageHandler.tsx
Normal file
28
components/I18nLanguageHandler.tsx
Normal 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;
|
||||
@@ -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">
|
||||
​
|
||||
</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
72
components/List.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
​
|
||||
</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
53
components/NavTabs.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
42
components/SettingsShell.tsx
Normal file
42
components/SettingsShell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
142
components/booking/BookingListItem.tsx
Normal file
142
components/booking/BookingListItem.tsx
Normal 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}>
|
||||
"{booking.description}"
|
||||
</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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
type Props = {
|
||||
|
||||
244
components/eventtype/CreateEventType.tsx
Normal file
244
components/eventtype/CreateEventType.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
221
components/form/fields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
304
components/integrations/CalendarListContainer.tsx
Normal file
304
components/integrations/CalendarListContainer.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
components/integrations/ConnectIntegrations.tsx
Normal file
63
components/integrations/ConnectIntegrations.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
components/integrations/DisconnectIntegration.tsx
Normal file
60
components/integrations/DisconnectIntegration.tsx
Normal 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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
0
components/integrations/IntegrationList.tsx
Normal file
0
components/integrations/IntegrationList.tsx
Normal file
30
components/integrations/IntegrationListItem.tsx
Normal file
30
components/integrations/IntegrationListItem.tsx
Normal 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;
|
||||
29
components/integrations/SubHeadingTitleWithConnections.tsx
Normal file
29
components/integrations/SubHeadingTitleWithConnections.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
components/pages/eventtypes/CustomInputTypeForm.tsx
Normal file
128
components/pages/eventtypes/CustomInputTypeForm.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
86
components/team/MemberChangeRoleModal.tsx
Normal file
86
components/team/MemberChangeRoleModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
86
components/team/TeamCreateModal.tsx
Normal file
86
components/team/TeamCreateModal.tsx
Normal 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">
|
||||
​
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
37
components/team/TeamRole.tsx
Normal file
37
components/team/TeamRole.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
components/team/TeamSettings.tsx
Normal file
210
components/team/TeamSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
components/team/TeamSettingsRightSidebar.tsx
Normal file
130
components/team/TeamSettingsRightSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
components/ui/LinkIconButton.tsx
Normal file
21
components/ui/LinkIconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
components/ui/ModalContainer.tsx
Normal file
39
components/ui/ModalContainer.tsx
Normal 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">
|
||||
​
|
||||
</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>
|
||||
);
|
||||
}
|
||||
28
components/ui/PoweredByCal.tsx
Normal file
28
components/ui/PoweredByCal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")}
|
||||
until
|
||||
{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" })}
|
||||
{t("until")}
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
25
components/ui/SettingInputContainer.tsx
Normal file
25
components/ui/SettingInputContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
96
components/ui/TableActions.tsx
Normal file
96
components/ui/TableActions.tsx
Normal 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;
|
||||
@@ -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
Reference in New Issue
Block a user