Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6758194b | ||
|
|
5528d60897 | ||
|
|
bb1c55e07f | ||
|
|
c899921591 | ||
|
|
ea34545d72 | ||
|
|
9d3540511b | ||
|
|
e8cf92361a | ||
|
|
e8525b963b | ||
|
|
24dabd0ddc | ||
|
|
5203168b2e | ||
|
|
c3e40f84a9 | ||
|
|
8e2ece7526 | ||
|
|
b6eca6e083 | ||
|
|
4cb264f1f7 | ||
|
|
e248fda36b | ||
|
|
1c97e549eb | ||
|
|
77600f8168 | ||
|
|
6cad606ba1 | ||
|
|
7cae7a7bd4 | ||
|
|
93f65807e7 | ||
|
|
0a63820699 | ||
|
|
05ad437b27 | ||
|
|
26a57a71ae | ||
|
|
34a10205a3 | ||
|
|
fbf8ff6db7 | ||
|
|
bef6d18fdf | ||
|
|
2e96e2ef3f | ||
|
|
2ca6769a70 | ||
|
|
1eb514dfcf | ||
|
|
d6c004ba22 | ||
|
+17 |
afb9605f0a | ||
|
|
a2549bd7b6 | ||
|
|
c2a8f799ff | ||
|
|
c68ff54e77 | ||
|
|
74fe20802d | ||
|
|
d59cda3286 | ||
|
|
1cae2b6f3d | ||
|
|
9d9fa8e575 | ||
|
|
f04c634c48 | ||
|
|
c3e7981917 | ||
|
|
d6594dab0f | ||
|
|
5fcfbb2d90 | ||
|
|
8a99c3d2a5 | ||
|
|
336c2d3643 | ||
|
|
cfe3ef4ab0 | ||
|
|
4ec8241a86 | ||
|
|
e6313aafac | ||
|
|
f87f030bb5 | ||
|
|
f87254cdc3 | ||
|
|
1f74837189 | ||
|
|
c7064906c9 | ||
|
|
86c94a3cd5 | ||
|
|
2056ebe186 | ||
|
|
53424ca9ae | ||
|
|
8197ba18d4 | ||
|
|
d341ea5111 | ||
|
|
42dcd4a2a9 | ||
|
|
c63225fdbf | ||
|
|
41774e4932 | ||
|
|
af7849f951 | ||
|
|
5353610d9a | ||
|
+14 |
156f3e5de2 | ||
|
|
05f51459a6 | ||
|
|
f0db97bbb1 | ||
|
|
05c33bd94b | ||
|
|
7d4cc59cd2 | ||
|
|
a94465ff04 | ||
|
|
4d54ac3ca2 | ||
|
|
15da690270 | ||
|
|
2462e8e597 | ||
|
|
2f628a17df | ||
|
|
1ee3783db3 | ||
|
|
b3d8d7e944 | ||
|
|
a4b7f1af65 | ||
|
|
f7445bf8a1 | ||
|
|
9cad7086d7 | ||
|
|
3345ec61ee | ||
|
|
7797421ce9 | ||
|
|
075a274207 | ||
|
|
a46a31040a | ||
|
|
7a28e1d6f4 | ||
|
|
51fd7fdb3e | ||
|
|
d01963303a | ||
|
|
6877d0f693 | ||
|
|
39040fca9a | ||
|
|
6fa4befc9b | ||
|
|
8c9871c6ce | ||
|
+8 |
a9af2fb255 | ||
|
|
8c150264f4 | ||
|
|
cfc3644d51 | ||
|
|
bdb4a7353b | ||
|
|
30c0e6d1d7 | ||
|
|
390967990a | ||
|
|
cc56602474 | ||
|
|
43a044d59e | ||
|
|
5b8ed9c3ef | ||
|
|
c3d6df2dcf | ||
|
|
9d02cb493c | ||
|
|
26ed478e50 | ||
|
|
09e338970d | ||
|
|
f60399ca8b | ||
|
|
5cf7e32852 | ||
|
|
24be8a63f2 | ||
|
|
75509c97fd | ||
|
|
9b26c7aee6 | ||
|
|
f18e80cfcf | ||
|
|
0a4b1ca014 | ||
|
|
9fa6aced33 | ||
|
|
9093909075 | ||
|
|
832fce91e0 | ||
|
|
6da41fce63 | ||
|
|
3b51cc9aef | ||
|
|
50c8959cfa | ||
|
|
c96d258f5e | ||
|
|
6e45b1c383 | ||
|
|
551ebe863b | ||
|
|
fa7b94bfe9 | ||
|
|
56c5d39cd3 | ||
|
|
7574fb6f44 | ||
|
|
901d1f0e01 | ||
|
|
828fcbe1d9 | ||
|
|
34db46869e | ||
|
|
1f1cab2be1 | ||
|
|
6cf53feaf8 | ||
|
|
376b693d91 | ||
|
|
831aa72d23 | ||
|
|
ef78db7856 | ||
|
|
7ee5967075 | ||
|
|
5b165e6ebf | ||
|
|
624f52c305 | ||
|
|
df25ae6a3d | ||
|
|
7f65d7f3c3 | ||
|
|
c42674595c | ||
|
|
17eb45b0b6 | ||
|
|
48d0edeaf4 | ||
|
|
ed4b31767f | ||
|
|
cebb10bfe4 | ||
|
|
7a6e7c2f1d | ||
|
|
0ffed24359 | ||
|
|
e121615d36 | ||
|
|
4f9aa8bd96 | ||
|
|
64ede37ad6 | ||
|
|
36b6c351ec | ||
|
|
9ab5a3149c | ||
|
|
3b8f436bdf | ||
|
|
77d45a6ec2 | ||
|
|
50c74cf286 | ||
|
|
dcbafced8b | ||
|
|
4679c80ea0 | ||
|
|
291316ee01 | ||
|
|
208fc2f0e4 | ||
|
|
99e71054ff | ||
|
|
9d118d4e34 | ||
|
+6 |
d1a877e70d | ||
|
|
238b509be6 | ||
|
|
c7471b404c | ||
|
|
4db4df8ace | ||
|
|
a15b8fb92e | ||
|
|
a4bed48eaf | ||
|
|
1be6f192d4 | ||
|
|
f1c5838aef | ||
|
|
8e994dcc33 | ||
|
|
506bbe3d31 | ||
|
|
419a84c5a2 | ||
|
|
8cf0050d1f | ||
|
|
6c23813192 | ||
|
|
875f922597 | ||
|
|
c8e3f6494a | ||
|
|
333801d2ae | ||
|
|
60f7528cbc | ||
|
|
0faf294367 | ||
|
|
87c178e53b | ||
|
|
7710b1b718 | ||
|
|
d71c6ccb31 | ||
|
|
33a4117fb5 | ||
|
|
a90419c40b | ||
|
|
16adf86c8c | ||
|
|
3b838c475a | ||
|
|
cf33106d8b | ||
|
|
a63f9ddf61 | ||
|
|
1cf83068b2 | ||
|
|
23de92f818 | ||
|
|
c6fc1f756b | ||
|
|
88b7c506f1 | ||
|
|
755aae969c | ||
|
|
2d50d09ce6 | ||
|
|
a22397dbb4 | ||
|
|
d1076982a7 | ||
|
|
d77e4f3c4a | ||
|
|
b1c0ea4c38 | ||
|
|
425e18f53c | ||
|
|
3611807838 | ||
|
|
5b1cd9e5ce | ||
|
|
00bff598ef | ||
|
|
3a8aa26c78 | ||
|
|
3c761f4451 | ||
|
|
8640eb9f52 | ||
|
|
abb5fd36f3 | ||
|
|
865663646a | ||
|
|
b7c92063c2 | ||
|
|
4f293edb6b | ||
|
|
0c88cf75e0 | ||
|
|
0b93647209 | ||
|
|
b931b00714 | ||
|
|
796a5f8d51 | ||
|
|
905a110377 | ||
|
|
6ccc19985f | ||
|
|
8f71474303 | ||
|
|
5f351230ff | ||
|
|
3e005919c1 | ||
|
|
cfa8fd8b67 | ||
|
|
a888be6e04 | ||
|
|
6aa2cba3ce | ||
|
|
12ab615684 | ||
|
|
1d792a2c6c | ||
|
|
9602ac29fb | ||
|
|
5714471b9a | ||
|
|
a99e93a4bd | ||
|
|
7f044ff92f | ||
|
|
17a0a61313 | ||
|
|
12b7739c9a | ||
|
|
ff283a25d2 | ||
|
|
4b343f20a4 | ||
|
|
5bb6904ffd | ||
|
|
0e04f5d338 | ||
|
|
da0ea5d9d0 | ||
|
|
1a19261b8f | ||
|
|
55a6c65db6 | ||
|
|
c9ed3388ae | ||
|
|
2588b7ef84 | ||
|
|
7ce83542e2 | ||
|
|
0bb0883e64 | ||
|
|
d3be62fbdd | ||
|
|
736308e43a | ||
|
|
791927e45e | ||
|
|
fa984e1568 | ||
|
|
9b197da5be | ||
|
|
b7c316b01b | ||
|
|
5ab56310bf | ||
|
|
52e318ca18 | ||
|
|
8d078564dd | ||
|
|
7b5482e83d | ||
|
|
23086476b3 | ||
|
|
00cab5c41f | ||
|
|
fbc27e1d52 | ||
|
|
e1c9a8cbfc | ||
|
|
8ee109e975 | ||
|
|
cfaecc3e02 | ||
|
|
18246e0dc1 | ||
|
|
6dcb16724c | ||
|
|
d4c5a906b5 | ||
|
|
27e8d73b82 | ||
|
|
25eae4a546 | ||
|
|
f663774fee | ||
|
|
e2258fba27 | ||
|
|
d0d8878f34 | ||
|
|
59ff6c35ee | ||
|
|
3c07e305ce | ||
|
|
b5f274d663 | ||
|
|
6c3ae984fc | ||
|
|
a2e9bf0504 | ||
|
|
8aa089efe6 | ||
|
|
de64d3d9d4 | ||
|
|
71833c0737 | ||
|
|
c78f4deaf7 | ||
|
|
24fc7d611f | ||
|
|
8afbc3b457 | ||
|
|
da18aae21c | ||
|
|
87a0d533ef | ||
|
|
7cdd453953 | ||
|
|
8143816c18 | ||
|
|
8243ac3c00 | ||
|
|
207e120d66 | ||
|
|
b25d04da1c | ||
|
|
7064acf756 | ||
|
|
716407ad90 | ||
|
|
af0b08bb2b | ||
|
|
3e27735ccb | ||
|
|
76c087f29c | ||
|
|
e05bcf98c2 | ||
|
|
636e88d467 | ||
|
|
34a61214d3 | ||
|
|
b1af30e026 | ||
|
|
870b59d617 | ||
|
|
e25094ccab | ||
|
|
1376f0991f | ||
|
|
443329b99f | ||
|
|
a1fc4ec26d | ||
|
|
d462298e79 | ||
|
|
965cff38c2 | ||
|
|
f96e909dce | ||
|
|
a4d6f7da62 | ||
|
|
ce9413e0dd | ||
|
|
8fc0dfb763 | ||
|
|
690edcf0a7 | ||
|
|
b7dbf10068 | ||
|
|
83b4ba94fc | ||
|
|
62a09e7098 | ||
|
|
d2c4e2894d | ||
|
|
070d314897 | ||
|
|
0a76368e98 | ||
|
|
99287d9eb4 | ||
|
|
100035cb89 | ||
|
|
1750dbead6 | ||
|
|
4c8ed3d11f | ||
|
|
f21c4eb11e | ||
|
|
3f71a41d7e | ||
|
|
1ba191c4ed | ||
|
|
6414903567 | ||
|
|
e62d8b4596 | ||
|
|
e27b1b1bc2 | ||
|
|
9507138f29 | ||
|
|
e5fa202a09 | ||
|
|
447098bc97 | ||
|
|
dc5e042cdd | ||
|
|
92fb365b54 | ||
|
|
161f5c7000 | ||
|
|
c8744768c4 | ||
|
|
b34a568a93 | ||
|
|
a669403604 | ||
|
|
7b2bfc2a64 | ||
|
|
7655c0c867 | ||
|
|
6fbabb4b26 | ||
|
|
edfc9ff082 | ||
|
|
59a9a77e3f | ||
|
|
8e756d7648 | ||
|
|
5763c1c2f1 | ||
|
|
6c5ae0b4d9 | ||
|
|
9cb9ce2e42 | ||
|
|
018be43017 | ||
|
|
4f720c8c9b | ||
|
|
06e1e113f0 | ||
|
|
3fc6f38da9 | ||
|
|
8e8c96c267 | ||
|
|
fab765c5ee | ||
|
|
89ed07a097 | ||
|
|
d7d73d2caf | ||
|
|
aa92730e28 | ||
|
|
430430e0cd | ||
|
|
62713940b0 | ||
|
|
75aef09338 | ||
|
|
4e65c30e18 | ||
|
|
8b873e2828 | ||
|
|
e70d179628 | ||
|
|
ca9a836300 | ||
|
|
abdf72dc0f | ||
|
|
227cd165f6 | ||
|
|
a0135cdc59 | ||
|
|
112d504148 | ||
|
|
d275930732 | ||
|
|
97e33bdd7e | ||
|
|
3f13791faf | ||
|
|
359cfc4be0 | ||
|
|
1ad6bba0ab | ||
|
|
6dc4211d81 | ||
|
|
e5f0452728 | ||
|
|
d95bba63f4 | ||
|
|
6f48869021 | ||
|
|
845bfae887 | ||
|
|
3fe02fba26 | ||
|
|
4ca5ca9fe2 | ||
|
|
364e187080 | ||
|
|
b1193eef1e | ||
|
|
e110f12cf1 | ||
|
|
022b513935 | ||
|
|
010f39a7a4 | ||
|
|
6b6eed66eb | ||
|
|
c214e01f03 | ||
|
|
a28ac3634d | ||
|
|
a0709fac88 | ||
|
|
e5b6a3332e | ||
|
|
979686920a | ||
|
|
793850f3aa | ||
|
|
fa9d72fc0a | ||
|
|
0d485ffed6 | ||
|
|
9a2e55d528 | ||
|
|
81ecbb86a5 | ||
|
|
2cd816932b | ||
|
|
d83411162d | ||
|
|
c9c54db3a1 | ||
|
|
d100600789 | ||
|
|
6f9011a167 | ||
|
|
ae004c8a6c | ||
|
|
ccbb16fe76 | ||
|
|
b77ce4a010 | ||
|
|
f72f709e34 | ||
|
|
37ceb25f8c | ||
|
|
0141d8f954 | ||
|
|
47fe791006 | ||
|
|
0cbaba73e4 | ||
|
|
aaac491dc7 | ||
|
|
fc4203e963 | ||
|
|
8430e9612e | ||
|
|
de81934619 | ||
|
|
5aedcbee55 | ||
|
|
7bc8d7e991 | ||
|
|
df183f5882 | ||
|
|
27d9fad292 | ||
|
|
bed798d440 | ||
|
|
e5a8a4e34d | ||
|
|
de446db434 | ||
|
|
81546412bf | ||
|
|
459530fba3 | ||
|
|
42766b42c0 | ||
|
|
38b3563350 | ||
|
|
37159914cf | ||
|
|
268bf68532 | ||
|
|
b02c7e01d5 | ||
|
|
45d10a3bc4 | ||
|
|
9250b20c1d | ||
|
|
d9da311141 | ||
|
|
89a52dad21 | ||
|
|
2a1acc1294 | ||
|
|
8302990f84 | ||
|
|
5a03b61d63 | ||
|
|
6b6f239936 | ||
|
|
9c2748e8e9 | ||
|
|
bb5cc33737 | ||
|
|
5616a64827 | ||
|
|
33530ad8c8 | ||
|
|
fe14839f1b | ||
|
|
15123c1aa5 | ||
|
|
71ee9c72fe | ||
|
|
5503539856 | ||
|
|
6548f01efa | ||
|
|
518a4484d6 | ||
|
|
3535a25a59 | ||
|
|
06cc6741e6 | ||
|
|
ed1478e1ba | ||
|
|
854c05d673 | ||
|
|
bcf5fb18c5 | ||
|
|
3f822e814b | ||
|
|
4fd25c7215 | ||
|
|
f42bed2a55 | ||
|
|
469c205033 | ||
|
|
31551f3b9e | ||
|
|
69925af17c | ||
|
|
2502868ebc | ||
|
|
38d242add9 | ||
|
|
c272e281cf | ||
|
|
d4e19b0fa4 | ||
|
|
c12877a58f | ||
|
|
af83350d04 | ||
|
|
9d0cf6be0b | ||
|
|
2fe9c782d4 | ||
|
|
d0a3d8d144 | ||
|
|
cefcb1eaf8 | ||
|
|
83b60de640 | ||
|
|
a076418989 | ||
|
|
64ecce1ccb | ||
|
|
cd56a5d4f0 | ||
|
|
d5ca121ac6 | ||
|
|
1e20e80b65 | ||
|
|
75ac3fd40c | ||
|
|
3b91cc7f02 | ||
|
|
23450b61e0 | ||
|
|
dcc226976c | ||
|
|
196fde72f6 | ||
|
|
1e913cd406 | ||
|
|
02425702d1 | ||
|
|
2494655369 | ||
|
|
0e0a78c47e | ||
|
|
ed02c10f54 | ||
|
|
9c40f76184 | ||
|
|
8eda30d88c | ||
|
|
9de19d35a8 | ||
|
|
0770403ca5 | ||
|
|
1a45cb35c1 | ||
|
|
15cdff76d9 | ||
|
|
8c4eb4b5fe | ||
|
|
ae30aafafe | ||
|
|
3b74717591 | ||
|
|
7a6349b4c0 | ||
|
|
3b51fff88d | ||
|
+5 |
2a0b0be9d0 | ||
|
|
f373c9d818 | ||
|
|
540bf3a1cb | ||
|
|
2d9064e92f | ||
|
|
9487819ec1 | ||
|
|
49bf14b196 | ||
|
|
344f16d6cc | ||
|
|
ccbf3b1e54 | ||
|
|
dcf0fad35d | ||
|
|
a836800c69 | ||
|
|
b8c6ac43f7 | ||
|
|
f5e0c6b384 | ||
|
|
2a5dd9035f | ||
|
|
2cddad4336 | ||
|
|
4bcbff51b1 | ||
|
|
ae541e4c37 | ||
|
|
f254711d85 | ||
|
|
533e208643 | ||
|
|
795574ef88 | ||
|
|
48611d5c99 | ||
|
|
11a07961d0 | ||
|
|
6be0fd52e7 | ||
|
|
78da7ba338 | ||
|
|
9a72c648c5 | ||
|
|
3e42da7fc7 | ||
|
|
1d986cad04 | ||
|
|
f603e4eff2 | ||
|
|
1f4e6c1987 | ||
|
|
e57f734e79 | ||
|
|
7fad76ae23 | ||
|
|
7659a016b4 | ||
|
|
34fe1c4a95 | ||
|
|
205f73c4bc | ||
|
|
c4bd2167e1 | ||
|
|
49a7111b66 | ||
|
|
ca045a040b | ||
|
|
a8b4198bf7 | ||
|
|
2ab24e530b | ||
|
|
734b06dfdf | ||
|
|
467084993b | ||
|
|
1e2c8516d2 | ||
|
|
d57738f325 | ||
|
|
c39a7dba85 | ||
|
|
fd37397e5c | ||
|
|
df0a522656 | ||
|
|
916468b8cc | ||
|
|
b789e58624 | ||
|
|
4368536141 | ||
|
|
9eef9766ce | ||
|
|
68dbfbd7d0 | ||
|
|
692a84a86e | ||
|
|
1671095c94 | ||
|
|
3b896bee5c | ||
|
|
cc1985442e | ||
|
|
630f438b32 | ||
|
|
6fae01d45a | ||
|
|
7009373b11 | ||
|
|
c2f150dbab | ||
|
|
688541923b | ||
|
|
20402ac26c | ||
|
|
a54952b41a | ||
|
|
987756c4d4 | ||
|
|
3ee294ffc5 | ||
|
|
a638708871 | ||
|
|
3456cc5521 | ||
|
|
546f0c3881 | ||
|
|
7b18272d60 | ||
|
|
cf6efabdbb | ||
|
|
92d15a00f5 | ||
|
|
2f2b72dd54 | ||
|
|
8fd5d6b5b5 | ||
|
|
2078f12a85 | ||
|
|
77b8eb8a7f | ||
|
|
463787b607 | ||
|
|
1f4e7ca695 | ||
|
|
9eb52bbfb2 | ||
|
|
61e6f76bc6 | ||
|
|
2e6c3a6068 | ||
|
|
8f48ecafec | ||
|
|
8849d1148c | ||
|
|
98a4684a2b | ||
|
|
da0bdfacae | ||
|
|
68c7425922 | ||
|
|
c3418cbe1f | ||
|
|
3a641e0bc1 | ||
|
|
b7fcf6ade1 | ||
|
|
53d160e495 | ||
|
|
df4df6846b | ||
|
|
c1bbb9b139 | ||
|
|
56c07ec351 | ||
|
|
3080b47aec | ||
|
|
c1e23bfdc6 | ||
|
|
e7ff5508d7 | ||
|
|
5850d227ee | ||
|
|
ede987ba19 | ||
|
|
bc0d83164c | ||
|
|
30f0b080c5 | ||
|
|
ee39ea316f | ||
|
|
fbc2708259 | ||
|
|
9bb83aba7e | ||
|
|
3a67e5617e | ||
|
|
f401aa7669 | ||
|
|
a6e0678e59 | ||
|
|
e832015f26 | ||
|
|
5e86881e15 | ||
|
|
3b71f98460 | ||
|
|
144d57adb2 | ||
|
|
e1f1c15318 | ||
|
|
d88b1b2f8f | ||
|
|
2e61c15f2c | ||
|
|
df73473c7f | ||
|
|
7167973afd | ||
|
|
3c17bc2d1a | ||
|
|
a9a295dc54 | ||
|
+2 |
21dd1f4e95 | ||
|
|
5d775bd4a3 | ||
|
|
4e02b22472 | ||
|
|
4686d3ff48 | ||
|
|
02e9d8d45f | ||
|
|
7ce5096f13 | ||
|
|
83e51e9b9d | ||
|
|
dca3278c40 | ||
|
|
7501635fbf | ||
|
|
80c96ea5ac | ||
|
|
8c001e0d34 | ||
|
|
bf275497a2 | ||
|
|
6e351a4b20 | ||
|
|
5360cea3bf | ||
|
+2 |
2f36985462 | ||
|
|
6b26e9a7de | ||
|
|
d7655f0521 | ||
|
|
f4ed345a87 | ||
|
|
c3122c82e4 | ||
|
|
cc9b49131e | ||
|
|
190db1a0f6 | ||
|
|
bd48a72e99 | ||
|
|
a6836a1488 | ||
|
|
2b67ea9635 | ||
|
|
a9117c2d36 | ||
|
+1 |
10d3b5f1b9 | ||
|
|
b757cb6ba1 | ||
|
|
5968e0aa19 | ||
|
|
a06fffc4ec | ||
|
|
3312c84e7c | ||
|
|
d86710d5e9 | ||
|
|
2d8e65c160 | ||
|
|
f74eaf7cb3 | ||
|
|
bcfd57c953 | ||
|
|
c38588b93d | ||
|
|
32ed43574d | ||
|
|
38a5a22b9e | ||
|
|
2120e301c3 | ||
|
|
dd0aec4e97 | ||
|
|
3815b86258 | ||
|
|
7a6545bd03 | ||
|
|
11f467af5d | ||
|
|
0930cf50b6 | ||
|
|
c37cddd0b7 | ||
|
|
09253f83bf | ||
|
|
1cdcebac01 | ||
|
|
58270fa653 | ||
|
|
1a26a254ab | ||
|
|
a36d15894d | ||
|
|
cf077fd33c | ||
|
|
0c979d3604 | ||
|
|
33bc786bb3 | ||
|
|
a7f16462db | ||
|
|
2c7fcbcf51 | ||
|
|
d3e4d26fa5 | ||
|
|
3be6c05722 | ||
|
|
e293e590bd | ||
|
+1 |
d8c1c103a1 | ||
|
|
19d00e691c | ||
|
|
78aa4aecef | ||
|
|
3ab002e547 | ||
|
|
7f461bc275 | ||
|
|
571176102e | ||
|
|
8f1d3fbc50 | ||
|
|
5e88ad50ea | ||
|
|
056406bdd9 | ||
|
|
58af5a18a4 | ||
|
|
90b6581d76 | ||
|
|
b33dcc8841 | ||
|
|
d951a5b872 | ||
|
|
7122a9cfbf | ||
|
|
f1b92c49fc | ||
|
|
ef3ad36c2c | ||
|
|
072ce452b7 | ||
|
|
0e0940f5f0 | ||
|
|
310936125d | ||
|
|
a9df3309b9 | ||
|
|
148d658f14 | ||
|
|
242d571d7d | ||
|
|
dff49ec28a | ||
|
|
0c746ec10f | ||
|
|
901f54ca3a | ||
|
|
16fda5f599 | ||
|
|
d3e9adf382 | ||
|
|
cf559e0726 | ||
|
|
8447b1bbd5 | ||
|
|
0d03006653 | ||
|
|
d44c5fd8b0 | ||
|
|
74aa366f1e | ||
|
|
7ebea57602 | ||
|
|
26d675e162 | ||
|
|
e16d29e916 | ||
|
|
0e89fb9f6a |
@@ -0,0 +1,42 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-outside-of-docker
|
||||||
|
{
|
||||||
|
"name": "Docker outside of Docker",
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-from-docker:1": {
|
||||||
|
"version": "latest",
|
||||||
|
"enableNonRootDocker": "true",
|
||||||
|
"moby": "true"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {},
|
||||||
|
"ghcr.io/devcontainers-contrib/features/npm-package:1": {},
|
||||||
|
"ghcr.io/devcontainers-contrib/features/jest:2": {},
|
||||||
|
"ghcr.io/devcontainers-contrib/features/prisma:2": {},
|
||||||
|
"ghcr.io/guiyomh/features/vim:0": {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use this environment variable if you need to bind mount your local source code into a new container.
|
||||||
|
"remoteEnv": {
|
||||||
|
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
|
||||||
|
},
|
||||||
|
|
||||||
|
"hostRequirements": {
|
||||||
|
"cpus": 4,
|
||||||
|
"memory": "8gb"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "./deploy/install.sh"
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
// "customizations": {},
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
+31
-7
@@ -1,21 +1,21 @@
|
|||||||
# ********** INDEX **********
|
# ********** INDEX **********
|
||||||
#
|
#
|
||||||
# - LICENSE
|
# - LICENSE (DEPRECATED)
|
||||||
# - DATABASE
|
# - DATABASE
|
||||||
# - SHARED
|
# - SHARED
|
||||||
# - NEXTAUTH
|
# - NEXTAUTH
|
||||||
# - E-MAIL SETTINGS
|
# - E-MAIL SETTINGS
|
||||||
|
|
||||||
# - LICENSE *************************************************************************************************
|
# - LICENSE (DEPRECATED) ************************************************************************************
|
||||||
# Set this value to 'agree' to accept our license:
|
# https://github.com/calcom/cal.com/blob/main/LICENSE
|
||||||
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
|
||||||
#
|
#
|
||||||
# Summary of terms:
|
# Summary of terms:
|
||||||
# - The codebase has to stay open source, whether it was modified or not
|
# - The codebase has to stay open source, whether it was modified or not
|
||||||
# - You can not repackage or sell the codebase
|
# - You can not repackage or sell the codebase
|
||||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
#
|
||||||
# To enable enterprise-only features, fill your license key in here.
|
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
|
||||||
|
# instructions. This environment variable is deprecated although still supported for backward compatibility.
|
||||||
# @see https://console.cal.com
|
# @see https://console.cal.com
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
@@ -32,11 +32,14 @@ NEXT_PUBLIC_CONSOLE_URL='http://localhost:3004'
|
|||||||
NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js'
|
NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js'
|
||||||
|
|
||||||
# To enable SAML login, set both these variables
|
# To enable SAML login, set both these variables
|
||||||
# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login
|
# @see https://github.com/calcom/cal.com/tree/main/packages/features/ee#setting-up-saml-login
|
||||||
# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml"
|
# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml"
|
||||||
SAML_DATABASE_URL=
|
SAML_DATABASE_URL=
|
||||||
# SAML_ADMINS='pro@example.com'
|
# SAML_ADMINS='pro@example.com'
|
||||||
SAML_ADMINS=
|
SAML_ADMINS=
|
||||||
|
# NEXT_PUBLIC_HOSTED_CAL_FEATURES=1
|
||||||
|
NEXT_PUBLIC_HOSTED_CAL_FEATURES=
|
||||||
|
|
||||||
# If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line.
|
# If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line.
|
||||||
# @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js
|
# @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js
|
||||||
# PGSSLMODE='no-verify'
|
# PGSSLMODE='no-verify'
|
||||||
@@ -80,12 +83,17 @@ SEND_FEEDBACK_EMAIL=
|
|||||||
# Used for email reminders in workflows and internal sync services
|
# Used for email reminders in workflows and internal sync services
|
||||||
SENDGRID_API_KEY=
|
SENDGRID_API_KEY=
|
||||||
SENDGRID_EMAIL=
|
SENDGRID_EMAIL=
|
||||||
|
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||||
|
|
||||||
# Twilio
|
# Twilio
|
||||||
# Used to send SMS reminders in workflows
|
# Used to send SMS reminders in workflows
|
||||||
TWILIO_SID=
|
TWILIO_SID=
|
||||||
TWILIO_TOKEN=
|
TWILIO_TOKEN=
|
||||||
TWILIO_MESSAGING_SID=
|
TWILIO_MESSAGING_SID=
|
||||||
|
TWILIO_PHONE_NUMBER=
|
||||||
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
|
TWILIO_VERIFY_SID=
|
||||||
|
|
||||||
# This is used so we can bypass emails in auth flows for E2E testing
|
# This is used so we can bypass emails in auth flows for E2E testing
|
||||||
# Set it to "1" if you need to run E2E tests locally
|
# Set it to "1" if you need to run E2E tests locally
|
||||||
@@ -100,6 +108,8 @@ STRIPE_TEAM_MONTHLY_PRICE_ID=
|
|||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_PRIVATE_KEY=
|
STRIPE_PRIVATE_KEY=
|
||||||
STRIPE_CLIENT_ID=
|
STRIPE_CLIENT_ID=
|
||||||
|
PAYMENT_FEE_FIXED=
|
||||||
|
PAYMENT_FEE_PERCENTAGE=
|
||||||
|
|
||||||
# Use for internal Public API Keys and optional
|
# Use for internal Public API Keys and optional
|
||||||
API_KEY_PREFIX=cal_
|
API_KEY_PREFIX=cal_
|
||||||
@@ -140,3 +150,17 @@ SENDGRID_SYNC_API_KEY=
|
|||||||
# Sentry
|
# Sentry
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
SENTRY_IGNORE_API_RESOLUTION_ERROR=
|
SENTRY_IGNORE_API_RESOLUTION_ERROR=
|
||||||
|
|
||||||
|
# Change your Brand
|
||||||
|
NEXT_PUBLIC_APP_NAME="Cal.com"
|
||||||
|
NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS="help@cal.com"
|
||||||
|
NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc."
|
||||||
|
# Set this to true in to disable new signups
|
||||||
|
# NEXT_PUBLIC_DISABLE_SIGNUP=true
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
CSP_POLICY=
|
||||||
|
|
||||||
|
# Vercel Edge Config
|
||||||
|
EDGE_CONFIG=
|
||||||
|
|||||||
+5
-4
@@ -1,5 +1,6 @@
|
|||||||
"♻️ autoupdate":
|
"❗️ migrations":
|
||||||
- apps/web/public/static/locales/**/*.json
|
- packages/prisma/migrations/**/migration.sql
|
||||||
|
|
||||||
automerge:
|
"❗️ .env changes":
|
||||||
- apps/web/public/static/locales/**/*.json
|
- .env.example
|
||||||
|
- .env.appStore.example
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Add bugs to Bug Hunting
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- labeled
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- labeled
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-to-project:
|
|
||||||
name: Add issue to project
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/add-to-project@v0.1.0
|
|
||||||
with:
|
|
||||||
project-url: https://github.com/orgs/calcom/projects/9
|
|
||||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
labeled: 🙋🏻♂️help wanted
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Cron - mark stale for inactive issues
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
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 every day (see https://crontab.guru)
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v7
|
||||||
|
with:
|
||||||
|
days-before-stale: 60
|
||||||
|
include-only-assigned: true
|
||||||
|
days-before-close: -1
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
required-checks:
|
||||||
|
uses: calcom/cal.com/.github/workflows/required-checks.yml@main
|
||||||
synchronize-with-crowdin:
|
synchronize-with-crowdin:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
pull_request_labels: "♻️ autoupdate, automerge"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- apps/web/**
|
- apps/web/**
|
||||||
- packages/embeds/**
|
- packages/embeds/**
|
||||||
|
workflow_call:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
name: E2E test
|
name: E2E test
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
|
||||||
pull_request_target: # So we can test on forks
|
pull_request_target: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- apps/web/**
|
- apps/web/**
|
||||||
- packages/**
|
- packages/**
|
||||||
|
workflow_call:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v4
|
- uses: actions/labeler@v4
|
||||||
# Checking the actor will prevent your Action run failing on non-Dependabot
|
|
||||||
# PRs but also ensures that it only does work for Dependabot PRs.
|
|
||||||
if: ${{ github.actor == 'github-actions[bot]' }}
|
|
||||||
with:
|
with:
|
||||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
|
||||||
|
sync-labels: ""
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
name: "Next.js Bundle Analysis"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
# change this if your nextjs app does not live at the root of the repo
|
||||||
|
working-directory: ./apps/web/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||||
|
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET: secret
|
||||||
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
|
GOOGLE_LOGIN_ENABLED: true
|
||||||
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
|
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
|
||||||
|
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||||
|
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||||
|
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||||
|
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||||
|
PAYMENT_FEE_FIXED: 10
|
||||||
|
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
SAML_ADMINS: pro@example.com
|
||||||
|
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||||
|
NEXT_PUBLIC_IS_E2E: 1
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:12.1
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: calendso
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16.x
|
||||||
|
cache: "yarn"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
uses: bahmutov/npm-install@v1
|
||||||
|
|
||||||
|
- name: Restore next build
|
||||||
|
uses: actions/cache@v2
|
||||||
|
id: restore-build-cache
|
||||||
|
env:
|
||||||
|
cache-name: cache-next-build
|
||||||
|
with:
|
||||||
|
path: apps/web/.next/cache
|
||||||
|
# change this if you prefer a more strict cache
|
||||||
|
key: ${{ runner.os }}-build-${{ env.cache-name }}
|
||||||
|
|
||||||
|
- name: Build next.js app
|
||||||
|
# change this if your site requires a custom build command
|
||||||
|
run: cd ../.. && yarn db-seed && yarn build
|
||||||
|
|
||||||
|
# Here's the first place where next-bundle-analysis' own script is used
|
||||||
|
# This step pulls the raw bundle stats for the current bundle
|
||||||
|
- name: Analyze bundle
|
||||||
|
run: npx -p nextjs-bundle-analysis report
|
||||||
|
|
||||||
|
- name: Upload bundle
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: bundle
|
||||||
|
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||||
|
|
||||||
|
- name: Download base branch bundle stats
|
||||||
|
uses: dawidd6/action-download-artifact@v2
|
||||||
|
if: success() && github.event.number
|
||||||
|
with:
|
||||||
|
workflow: nextjs-bundle-analysis.yml
|
||||||
|
branch: ${{ github.event.pull_request.base.ref }}
|
||||||
|
path: apps/web/.next/analyze/base
|
||||||
|
|
||||||
|
# And here's the second place - this runs after we have both the current and
|
||||||
|
# base branch bundle stats, and will compare them to determine what changed.
|
||||||
|
# There are two configurable arguments that come from package.json:
|
||||||
|
#
|
||||||
|
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||||
|
# it's set to 350kb here by default, as informed by the following piece:
|
||||||
|
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||||
|
#
|
||||||
|
# - red-status-percentage: sets the percent size increase where you get a red
|
||||||
|
# status indicator, defaults to 20%
|
||||||
|
#
|
||||||
|
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||||
|
# entry in your package.json file.
|
||||||
|
- name: Compare with base branch bundle
|
||||||
|
if: success() && github.event.number
|
||||||
|
run: ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||||
|
|
||||||
|
- name: Get comment body
|
||||||
|
id: get-comment-body
|
||||||
|
if: success() && github.event.number
|
||||||
|
run: |
|
||||||
|
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Find Comment
|
||||||
|
uses: peter-evans/find-comment@v1
|
||||||
|
if: success() && github.event.number
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body-includes: "<!-- __NEXTJS_BUNDLE -->"
|
||||||
|
|
||||||
|
- name: Create Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v1.4.4
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
|
||||||
|
- name: Update Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v1.4.4
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
edit-mode: replace
|
||||||
@@ -11,6 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
|
ref: main # Always checkout main even for tagged releases
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
- run: git push origin +main:production
|
- run: git push origin +main:production
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
name: "Meta Workflow: Require Conditional Status Checks"
|
name: "Meta Workflow: Require Conditional Status Checks"
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_call:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
name: Unit tests
|
name: Unit tests
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ feat/routing-forms/reporting ] # TODO: Remove this after merged in main
|
|
||||||
pull_request_target: # So we can test on forks
|
pull_request_target: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- apps/web/**
|
- apps/web/**
|
||||||
- packages/**
|
- packages/**
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Crowdin Action]
|
||||||
|
types: [completed]
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: TODO to Issue
|
||||||
|
on:
|
||||||
|
pull_request_target: # So we can test on forks
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
MANUAL_COMMIT_REF:
|
||||||
|
description: "The SHA of the commit to get the diff for"
|
||||||
|
required: true
|
||||||
|
MANUAL_BASE_REF:
|
||||||
|
description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here"
|
||||||
|
required: false
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
todos:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: "Add TODOs to issues"
|
||||||
|
uses: "alstr/todo-to-issue-action@v4"
|
||||||
|
env:
|
||||||
|
MANUAL_COMMIT_REF: ${{ inputs.MANUAL_COMMIT_REF }}
|
||||||
|
MANUAL_BASE_REF: ${{ inputs.MANUAL_BASE_REF }}
|
||||||
|
with:
|
||||||
|
IDENTIFIERS: '[{"name": "TODO", "labels": ["🙋🏻♂️help wanted"]}, {"name": "FIXME", "labels": ["🐛 bug"]}]'
|
||||||
+10
@@ -77,6 +77,16 @@ lint-results
|
|||||||
#Storybook
|
#Storybook
|
||||||
apps/storybook/build-storybook.log
|
apps/storybook/build-storybook.log
|
||||||
|
|
||||||
|
# Infisical
|
||||||
|
.infisical.json
|
||||||
|
|
||||||
# Snaplet
|
# Snaplet
|
||||||
.snaplet/snapshots
|
.snaplet/snapshots
|
||||||
.snaplet/structure.d.ts
|
.snaplet/structure.d.ts
|
||||||
|
|
||||||
|
# Submodules
|
||||||
|
.gitmodules
|
||||||
|
apps/api
|
||||||
|
apps/website
|
||||||
|
apps/console
|
||||||
|
apps/auth
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
[submodule "apps/console"]
|
|
||||||
path = apps/console
|
|
||||||
url = https://github.com/calcom/console.git
|
|
||||||
branch = main
|
|
||||||
[submodule "apps/api"]
|
|
||||||
path = apps/api
|
|
||||||
url = https://github.com/calcom/api.git
|
|
||||||
branch = main
|
|
||||||
[submodule "apps/website"]
|
|
||||||
path = apps/website
|
|
||||||
url = https://github.com/calcom/website.git
|
|
||||||
branch = main
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* This is a Playwright script Checkly generated for you based on your Vercel project.
|
||||||
|
* To learn more about Browser checks and Playwright visit: https://www.checklyhq.com/docs/browser-checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Create a Chromium browser
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
// Checkly supports top level await, but we wrap your code in an async function so you can run it locally too.
|
||||||
|
async function run() {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const targetUrl = process.env.ENVIRONMENT_URL || "https://cal.com";
|
||||||
|
await page.goto(`${targetUrl}/rick/test-location-link`);
|
||||||
|
|
||||||
|
await page.waitForSelector('[data-testid="day"][data-disabled="false"]');
|
||||||
|
await page.click('[data-testid="day"][data-disabled="false"]');
|
||||||
|
|
||||||
|
await page.waitForSelector('[data-testid="time"]');
|
||||||
|
await page.click('[data-testid="time"]');
|
||||||
|
|
||||||
|
await page.waitForSelector("#name");
|
||||||
|
await page.click("#name");
|
||||||
|
|
||||||
|
await page.type("#name", "Calcom");
|
||||||
|
|
||||||
|
await page.type('[name="email"]', "cal@cal.com");
|
||||||
|
|
||||||
|
await page.waitForSelector('[data-testid="confirm-book-button"]');
|
||||||
|
await page.click('[data-testid="confirm-book-button"]');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"],
|
"keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"],
|
||||||
"addons": [
|
"addons": [
|
||||||
{
|
{
|
||||||
"plan": "heroku-postgresql:hobby-dev"
|
"plan": "heroku-postgresql:mini"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
@@ -33,11 +33,6 @@
|
|||||||
"generator": "secret",
|
"generator": "secret",
|
||||||
"required": "true"
|
"required": "true"
|
||||||
},
|
},
|
||||||
"NEXT_PUBLIC_LICENSE_CONSENT": {
|
|
||||||
"description": "License consent",
|
|
||||||
"value": "true",
|
|
||||||
"required": "true"
|
|
||||||
},
|
|
||||||
"CRON_API_KEY": {
|
"CRON_API_KEY": {
|
||||||
"description": "ApiKey for cronjobs",
|
"description": "ApiKey for cronjobs",
|
||||||
"value": ""
|
"value": ""
|
||||||
@@ -48,15 +43,18 @@
|
|||||||
},
|
},
|
||||||
"SENDGRID_API_KEY": {
|
"SENDGRID_API_KEY": {
|
||||||
"description": "Sendgrid api key. Used for email reminders in workflows",
|
"description": "Sendgrid api key. Used for email reminders in workflows",
|
||||||
"value": ""
|
"value": "",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"SENDGRID_SYNC_API_KEY": {
|
"SENDGRID_SYNC_API_KEY": {
|
||||||
"description": "Sendgrid internal sync service",
|
"description": "Sendgrid internal sync service",
|
||||||
"value": ""
|
"value": "",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"SENDGRID_EMAIL": {
|
"SENDGRID_EMAIL": {
|
||||||
"description": "Sendgrid email. Used for email reminders in workflows",
|
"description": "Sendgrid email. Used for email reminders in workflows",
|
||||||
"value": ""
|
"value": "",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"EMAIL_FROM": {
|
"EMAIL_FROM": {
|
||||||
"description": "Configures the global From: header whilst sending emails",
|
"description": "Configures the global From: header whilst sending emails",
|
||||||
|
|||||||
-1
Submodule apps/api deleted from 055699f612
-1
Submodule apps/console deleted from 9174a0c261
@@ -1,81 +1 @@
|
|||||||
<!-- PROJECT LOGO -->
|
|
||||||
<div align="right">
|
|
||||||
<a href="https://github.com/calcom/cal.com">
|
|
||||||
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
|
|
||||||
</a>
|
|
||||||
<a href="https://cal.com">Website</a>
|
|
||||||
·
|
|
||||||
<a href="https://github.com/calcom/cal.com/issues">Community Support</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Cal.com Documentation
|
|
||||||
|
|
||||||
The official product, support and developer documentation, containing information and guides about using the product as well as support for self-hosted installations. This documentation site runs on [Nextra](https://nextra.vercel.app), so you may refer to their documentation should you need information on anything that isn't covered here.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Git
|
|
||||||
- Node.js & npm
|
|
||||||
- Yarn
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Firstly, clone the repository using Git:
|
|
||||||
|
|
||||||
```console
|
|
||||||
git clone https://github.com/calcom/docs.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, you can install the dependencies with yarn:
|
|
||||||
|
|
||||||
```console
|
|
||||||
yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Editing
|
|
||||||
|
|
||||||
To create, edit and delete documentation pages, you can simply create markdown (.mdx) files in the `pages/` folder. You can edit Markdown with any text editor, but VS Code and WebStorm have side-by-side previews so you can see your formatted content whilst writing markdown.
|
|
||||||
|
|
||||||
You will also need to add it as an entry to the `meta.json` file found in whichever directory that the .mdx file is in.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
```console
|
|
||||||
yarn dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```console
|
|
||||||
yarn build
|
|
||||||
```
|
|
||||||
|
|
||||||
This command generates static content into the `build` directory and can be served using any static content hosting service.
|
|
||||||
|
|
||||||
## How to easily contribute
|
|
||||||
|
|
||||||
## Existing Page
|
|
||||||
|
|
||||||
1. From the documentation's GitHub repository, head to the folder called 'pages' and open it.
|
|
||||||
2. From here you can view all current pages on the documentation site. Select the page you would like to contribute to.
|
|
||||||
3. You should now be able to view the page you have selected. Located at the top right of the page will be a pencil icon. Pressing this will bring you up an editor to edit and make changes. You can add formatting using the buttons at the top, which will automatically insert the relevant markdown content needed to style the text.
|
|
||||||
4. From here make the changes you wish to make.
|
|
||||||
5. At the bottom of the screen will be a 'Propose Changes' box, fill in all the relevant details such as title and description then press the green 'Propose Changes' button.
|
|
||||||
6. Your changes have been saved, to submit them for review, located on your screen, press the green 'Create Pull Request' button.
|
|
||||||
7. Fill in all the relevant details such as title and description and after finalize the submission.
|
|
||||||
|
|
||||||
You have now successfully edited and submitted changes to our documentation site.
|
|
||||||
|
|
||||||
## Creating a New Page
|
|
||||||
|
|
||||||
1. From the documentation's GitHub repository, head to the folder called 'pages' and open it.
|
|
||||||
2. From here you can view all current pages on the documentation site. At the top of your screen press the 'New file' button.
|
|
||||||
3. You should now be able to view the page you have created. Remember when renaming the document to put .mdx at the end of the file name.
|
|
||||||
4. From here make the changes you wish to make. Such as creating a title, sub-title and body text.
|
|
||||||
5. At the bottom of the screen will be a 'Propose Changes' box, fill in all the relevant details such as title and description then press the green 'Propose Changes' button.
|
|
||||||
6. Your changes have been saved, to submit them for review, located on your screen, press the greem 'Create Pull Request' button.
|
|
||||||
7. Fill in all the relevant details such as title and description and after finalize the submission.
|
|
||||||
|
|
||||||
You have now successfully created and submitted changes to our documentation site.
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iframe-resizer-react": "^1.1.0",
|
"iframe-resizer-react": "^1.1.0",
|
||||||
"next": "^12.3.1",
|
"next": "^13.1.1",
|
||||||
"nextra": "^1.1.0",
|
"nextra": "^1.1.0",
|
||||||
"nextra-theme-docs": "^1.2.2",
|
"nextra-theme-docs": "^1.2.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ Cal.com uses [TailwindCSS](https://tailwindcss.com) as a replacement for traditi
|
|||||||
|
|
||||||
CSS files should be stored in the `styles` directory within the codebase.
|
CSS files should be stored in the `styles` directory within the codebase.
|
||||||
|
|
||||||
Within the `styles` directory, you will find two CSS files, `fonts.css` and `global.css`. We suggest not to add to these files, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
|
Within the `styles` directory, you will find a single CSS file, `global.css`. We suggest not to add to this file, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
|
||||||
|
|
||||||
## Adding new stylesheets
|
## Adding new stylesheets
|
||||||
Firstly, create the CSS file inside the `styles` directory.
|
Firstly, create the CSS file inside the `styles` directory.
|
||||||
|
|
||||||
Then, open the `pages/_app.tsx` file, and you will see the following two lines:
|
Then, open the `pages/_app.tsx` file, and you will see the following two lines:
|
||||||
```javascript
|
```javascript
|
||||||
import "../styles/fonts.css";
|
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ All apps can be found under `packages/app-store`. In this folder is `_example` w
|
|||||||
| ├──index.ts
|
| ├──index.ts
|
||||||
| ├──package.json
|
| ├──package.json
|
||||||
| ├──.env.example
|
| ├──.env.example
|
||||||
| ├──README.mdx
|
| ├──DESCRIPTION.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -48,9 +48,9 @@ Under the `/components` folder, this is where the install button for your app sh
|
|||||||
|
|
||||||
The `/lib` folder is where the functions of your app live. For example, when creating a booking with a MS Teams link the function to make the call to grab the link lives in the `/lib` folder. Export your endpoints in an `index.ts` file under `/lib` folder and import them in your main `index.ts` file.
|
The `/lib` folder is where the functions of your app live. For example, when creating a booking with a MS Teams link the function to make the call to grab the link lives in the `/lib` folder. Export your endpoints in an `index.ts` file under `/lib` folder and import them in your main `index.ts` file.
|
||||||
|
|
||||||
On the app store page you can customize your apps description by adding a markdown file called `README.mdx`. If you do not add one then the description from you `package.json` will be used instead.
|
On the app store page you can customize your apps description by adding a markdown file called `DESCRIPTION.md`. If you do not add one then the description from you `package.json` will be used instead.
|
||||||
|
|
||||||
The `/static` folder is where you can store your app icon and any images that your `README.mdx` may use.
|
The `/static` folder is where you can store your app icon and any images that your `DESCRIPTION.md` may use.
|
||||||
|
|
||||||
## Adding Your App to the App Store
|
## Adding Your App to the App Store
|
||||||
To render your app on the app store page, go to `packages/app-store/index.ts`. Import your app into the file and add it to the `appStore` object.
|
To render your app on the app store page, go to `packages/app-store/index.ts`. Import your app into the file and add it to the `appStore` object.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
storybook-static
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
@@ -21,4 +22,4 @@ dist-ssr
|
|||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { create } from "@storybook/theming";
|
||||||
|
|
||||||
|
export default create({
|
||||||
|
base: "light",
|
||||||
|
colorPrimary: "#111827",
|
||||||
|
colorSecondary: "#111827",
|
||||||
|
appContentBg: "#F9FAFC",
|
||||||
|
brandTitle: "Cal.com UI",
|
||||||
|
brandUrl: "https://cal.com",
|
||||||
|
brandImage: "https://cal.com/logo.svg",
|
||||||
|
brandTarget: "_self",
|
||||||
|
});
|
||||||
@@ -4,19 +4,22 @@ module.exports = {
|
|||||||
stories: [
|
stories: [
|
||||||
"../intro.stories.mdx",
|
"../intro.stories.mdx",
|
||||||
"../../../packages/ui/components/**/*.stories.mdx",
|
"../../../packages/ui/components/**/*.stories.mdx",
|
||||||
|
"../../../packages/features/**/*.stories.mdx",
|
||||||
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
||||||
],
|
],
|
||||||
addons: [
|
addons: [
|
||||||
"@storybook/addon-links",
|
"@storybook/addon-links",
|
||||||
"@storybook/addon-essentials",
|
"@storybook/addon-essentials",
|
||||||
"@storybook/addon-interactions",
|
"@storybook/addon-interactions",
|
||||||
|
"storybook-addon-rtl-direction",
|
||||||
"storybook-react-i18next",
|
"storybook-react-i18next",
|
||||||
{
|
"storybook-addon-next",
|
||||||
|
/*{
|
||||||
name: "storybook-addon-next",
|
name: "storybook-addon-next",
|
||||||
options: {
|
options: {
|
||||||
nextConfigPath: path.resolve(__dirname, "../../web/next.config.js"),
|
nextConfigPath: path.resolve(__dirname, "../../web/next.config.js"),
|
||||||
},
|
},
|
||||||
},
|
},*/
|
||||||
],
|
],
|
||||||
framework: "@storybook/react",
|
framework: "@storybook/react",
|
||||||
core: {
|
core: {
|
||||||
@@ -50,6 +53,21 @@ module.exports = {
|
|||||||
vm: false,
|
vm: false,
|
||||||
zlib: false,
|
zlib: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
"style-loader",
|
||||||
|
{
|
||||||
|
loader: "css-loader",
|
||||||
|
options: {
|
||||||
|
modules: true, // Enable modules to help you using className
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
include: path.resolve(__dirname, "../src"),
|
||||||
|
});
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { addons } from "@storybook/addons";
|
||||||
|
|
||||||
|
import yourTheme from "./YourTheme";
|
||||||
|
|
||||||
|
addons.setConfig({
|
||||||
|
theme: yourTheme,
|
||||||
|
});
|
||||||
@@ -15,7 +15,11 @@ export const parameters = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
addDecorator((storyFn) => <I18nextProvider i18n={i18n}>{storyFn()}</I18nextProvider>);
|
addDecorator((storyFn) => (
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<div style={{ margin: "2rem" }}>{storyFn()}</div>
|
||||||
|
</I18nextProvider>
|
||||||
|
));
|
||||||
|
|
||||||
window.getEmbedNamespace = () => {
|
window.getEmbedNamespace = () => {
|
||||||
const url = new URL(document.URL);
|
const url = new URL(document.URL);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||||
|
|
||||||
|
const withTM = require("next-transpile-modules")([
|
||||||
|
"@calcom/app-store",
|
||||||
|
"@calcom/dayjs",
|
||||||
|
"@calcom/emails",
|
||||||
|
"@calcom/trpc",
|
||||||
|
"@calcom/embed-core",
|
||||||
|
"@calcom/embed-react",
|
||||||
|
"@calcom/features",
|
||||||
|
"@calcom/lib",
|
||||||
|
"@calcom/prisma",
|
||||||
|
"@calcom/ui",
|
||||||
|
]);
|
||||||
|
const glob = require("glob");
|
||||||
|
|
||||||
|
const plugins = [];
|
||||||
|
plugins.push(withTM, withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||||
|
|
||||||
|
/** @type {import("next").NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
images: {
|
||||||
|
domains: ["www.datocms-assets.com"],
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
experimental: { images: { allowFutureImage: true } },
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
if (!isServer) {
|
||||||
|
// don't resolve 'fs' module on the client to prevent this error on build --> Error: Can't resolve 'fs'
|
||||||
|
config.resolve.fallback = {
|
||||||
|
fs: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||||
@@ -20,9 +20,10 @@
|
|||||||
"@radix-ui/react-slider": "^1.0.0",
|
"@radix-ui/react-slider": "^1.0.0",
|
||||||
"@radix-ui/react-switch": "^1.0.0",
|
"@radix-ui/react-switch": "^1.0.0",
|
||||||
"@radix-ui/react-tooltip": "^1.0.0",
|
"@radix-ui/react-tooltip": "^1.0.0",
|
||||||
"next": "^12.3.1",
|
"next": "^13.1.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"storybook-addon-rtl-direction": "^0.0.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.19.6",
|
"@babel/core": "^7.19.6",
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
"storybook-addon-next": "^1.6.9",
|
"storybook-addon-next": "^1.6.9",
|
||||||
"storybook-react-i18next": "^1.1.2",
|
"storybook-react-i18next": "^1.1.2",
|
||||||
"tailwindcss": "^3.2.1",
|
"tailwindcss": "^3.2.1",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.9.4",
|
||||||
"vite": "^2.9.15"
|
"vite": "^2.9.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@import "../../../packages/ui/styles/shared-globals.css"
|
@import "../../../packages/ui/styles/shared-globals.css"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import url("../../../packages/features/calendars/weeklyview/styles/styles.css");
|
||||||
|
|
||||||
.sbdocs {
|
.sbdocs {
|
||||||
font-family: 'Inter var' !important;
|
font-family: 'Inter var' !important;
|
||||||
padding: 0!important;
|
padding: 0!important;
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
|
|
||||||
/** Docs table **/
|
/** Docs table **/
|
||||||
.custom-args-wrapper{
|
.custom-args-wrapper{
|
||||||
max-height: 600px;
|
max-height: 400px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -187,3 +189,7 @@
|
|||||||
padding: 24px !important;
|
padding: 24px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-story {
|
||||||
|
padding: 24px !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"highlight.js": "^11.6.0",
|
"highlight.js": "^11.6.0",
|
||||||
"isarray": "2.0.5",
|
"isarray": "2.0.5",
|
||||||
"next": "^12.3.1",
|
"next": "^13.1.1",
|
||||||
"openapi-snippet": "^0.13.0",
|
"openapi-snippet": "^0.13.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -24,6 +24,6 @@
|
|||||||
"@types/node": "16.9.1",
|
"@types/node": "16.9.1",
|
||||||
"@types/react": "^18.0.17",
|
"@types/react": "^18.0.17",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
14.17
|
v16
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Icon } from "@calcom/ui";
|
import { FiX } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
export default function AddToHomescreen() {
|
export default function AddToHomescreen() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -40,7 +40,7 @@ export default function AddToHomescreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||||
<span className="sr-only">{t("dismiss")}</span>
|
<span className="sr-only">{t("dismiss")}</span>
|
||||||
<Icon.FiX className="h-6 w-6 text-white" aria-hidden="true" />
|
<FiX className="h-6 w-6 text-white" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
|
import { Badge, ListItemText } from "@calcom/ui";
|
||||||
|
import { FiAlertCircle } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
||||||
|
|
||||||
|
type AppListCardProps = {
|
||||||
|
logo?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
isDefault?: boolean;
|
||||||
|
isTemplate?: boolean;
|
||||||
|
invalidCredential?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
} & ShouldHighlight;
|
||||||
|
|
||||||
|
const schema = z.object({ hl: z.string().optional() });
|
||||||
|
|
||||||
|
export default function AppListCard(props: AppListCardProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const {
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
isDefault,
|
||||||
|
slug,
|
||||||
|
shouldHighlight,
|
||||||
|
isTemplate,
|
||||||
|
invalidCredential,
|
||||||
|
children,
|
||||||
|
} = props;
|
||||||
|
const {
|
||||||
|
data: { hl },
|
||||||
|
} = useTypedQuery(schema);
|
||||||
|
const router = useRouter();
|
||||||
|
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldHighlight && highlight) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setHighlight(false);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("hl");
|
||||||
|
router.replace(url.pathname, undefined, { shallow: true });
|
||||||
|
}, 3000);
|
||||||
|
timeoutRef.current = timer;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-4 ${highlight ? "bg-yellow-100" : ""}`}>
|
||||||
|
<div className="flex gap-x-3">
|
||||||
|
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||||
|
<div className="flex grow flex-col gap-y-1 truncate">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-gray-900">{title}</h3>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
{isDefault && <Badge variant="green">{t("default")}</Badge>}
|
||||||
|
{isTemplate && <Badge variant="red">Template</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListItemText component="p">{description}</ListItemText>
|
||||||
|
{invalidCredential && (
|
||||||
|
<div className="flex gap-x-2 pt-2">
|
||||||
|
<FiAlertCircle className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
|
||||||
|
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||||
|
{t("invalid_credential")}
|
||||||
|
</ListItemText>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
{children && <div className="w-full">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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}</>;
|
|
||||||
};
|
|
||||||
@@ -4,6 +4,7 @@ import { NextRouter, useRouter } from "next/router";
|
|||||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||||
import { components, ControlProps } from "react-select";
|
import { components, ControlProps } from "react-select";
|
||||||
|
|
||||||
|
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -11,16 +12,13 @@ import {
|
|||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
HorizontalTabs,
|
HorizontalTabs,
|
||||||
Icon,
|
|
||||||
InputLeading,
|
|
||||||
Label,
|
Label,
|
||||||
showToast,
|
showToast,
|
||||||
Switch,
|
Switch,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextField,
|
TextField,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
import { FiCode, FiTrello, FiSun, FiArrowLeft, FiChevronRight } from "@calcom/ui/components/icon";
|
||||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
|
||||||
|
|
||||||
import ColorPicker from "@components/ui/colorpicker";
|
import ColorPicker from "@components/ui/colorpicker";
|
||||||
import Select from "@components/ui/form/Select";
|
import Select from "@components/ui/form/Select";
|
||||||
@@ -45,6 +43,7 @@ type PreviewState = {
|
|||||||
palette: {
|
palette: {
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
};
|
};
|
||||||
|
hideEventTypeDetails: boolean;
|
||||||
};
|
};
|
||||||
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
||||||
|
|
||||||
@@ -126,10 +125,12 @@ const getEmbedUIInstructionString = ({
|
|||||||
apiName,
|
apiName,
|
||||||
theme,
|
theme,
|
||||||
brandColor,
|
brandColor,
|
||||||
|
hideEventTypeDetails,
|
||||||
}: {
|
}: {
|
||||||
apiName: string;
|
apiName: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
|
hideEventTypeDetails: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
theme = theme !== "auto" ? theme : undefined;
|
theme = theme !== "auto" ? theme : undefined;
|
||||||
return getInstructionString({
|
return getInstructionString({
|
||||||
@@ -142,6 +143,7 @@ const getEmbedUIInstructionString = ({
|
|||||||
brandColor,
|
brandColor,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hideEventTypeDetails: hideEventTypeDetails,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -254,18 +256,21 @@ const getEmbedTypeSpecificString = ({
|
|||||||
apiName: string;
|
apiName: string;
|
||||||
theme: PreviewState["theme"];
|
theme: PreviewState["theme"];
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
|
hideEventTypeDetails: boolean;
|
||||||
};
|
};
|
||||||
if (embedFramework === "react") {
|
if (embedFramework === "react") {
|
||||||
uiInstructionStringArg = {
|
uiInstructionStringArg = {
|
||||||
apiName: "cal",
|
apiName: "cal",
|
||||||
theme: previewState.theme,
|
theme: previewState.theme,
|
||||||
brandColor: previewState.palette.brandColor,
|
brandColor: previewState.palette.brandColor,
|
||||||
|
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
uiInstructionStringArg = {
|
uiInstructionStringArg = {
|
||||||
apiName: "Cal",
|
apiName: "Cal",
|
||||||
theme: previewState.theme,
|
theme: previewState.theme,
|
||||||
brandColor: previewState.palette.brandColor,
|
brandColor: previewState.palette.brandColor,
|
||||||
|
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!frameworkCodes[embedType]) {
|
if (!frameworkCodes[embedType]) {
|
||||||
@@ -482,7 +487,7 @@ const tabs = [
|
|||||||
{
|
{
|
||||||
name: "HTML",
|
name: "HTML",
|
||||||
href: "embedTabName=embed-code",
|
href: "embedTabName=embed-code",
|
||||||
icon: Icon.FiCode,
|
icon: FiCode,
|
||||||
type: "code",
|
type: "code",
|
||||||
Component: forwardRef<
|
Component: forwardRef<
|
||||||
HTMLTextAreaElement | HTMLIFrameElement | null,
|
HTMLTextAreaElement | HTMLIFrameElement | null,
|
||||||
@@ -498,7 +503,9 @@ const tabs = [
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
<small className="flex py-4 text-gray-500">
|
||||||
|
{t("place_where_cal_widget_appear", { appName: APP_NAME })}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<TextArea
|
<TextArea
|
||||||
data-testid="embed-code"
|
data-testid="embed-code"
|
||||||
@@ -533,7 +540,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
|||||||
{
|
{
|
||||||
name: "React",
|
name: "React",
|
||||||
href: "embedTabName=embed-react",
|
href: "embedTabName=embed-react",
|
||||||
icon: Icon.FiCode,
|
icon: FiCode,
|
||||||
type: "code",
|
type: "code",
|
||||||
Component: forwardRef<
|
Component: forwardRef<
|
||||||
HTMLTextAreaElement | HTMLIFrameElement | null,
|
HTMLTextAreaElement | HTMLIFrameElement | null,
|
||||||
@@ -548,7 +555,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className="flex py-4 text-neutral-500">{t("create_update_react_component")}</small>
|
<small className="flex py-4 text-gray-500">{t("create_update_react_component")}</small>
|
||||||
<TextArea
|
<TextArea
|
||||||
data-testid="embed-react"
|
data-testid="embed-react"
|
||||||
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
||||||
@@ -573,7 +580,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, prev
|
|||||||
{
|
{
|
||||||
name: "Preview",
|
name: "Preview",
|
||||||
href: "embedTabName=embed-preview",
|
href: "embedTabName=embed-preview",
|
||||||
icon: Icon.FiTrello,
|
icon: FiTrello,
|
||||||
type: "iframe",
|
type: "iframe",
|
||||||
Component: forwardRef<
|
Component: forwardRef<
|
||||||
HTMLIFrameElement | HTMLTextAreaElement | null,
|
HTMLIFrameElement | HTMLTextAreaElement | null,
|
||||||
@@ -589,7 +596,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, prev
|
|||||||
<iframe
|
<iframe
|
||||||
ref={ref as typeof ref & MutableRefObject<HTMLIFrameElement>}
|
ref={ref as typeof ref & MutableRefObject<HTMLIFrameElement>}
|
||||||
data-testid="embed-preview"
|
data-testid="embed-preview"
|
||||||
className="border-1 h-[100vh] border"
|
className="h-[100vh] border"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
src={`${WEBAPP_URL}/embed/preview.html?embedType=${embedType}&calLink=${calLink}`}
|
src={`${WEBAPP_URL}/embed/preview.html?embedType=${embedType}&calLink=${calLink}`}
|
||||||
@@ -609,7 +616,7 @@ Cal("init", {origin:"${WEBAPP_URL}"});
|
|||||||
const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme; label: string }, false>) => {
|
const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme; label: string }, false>) => {
|
||||||
return (
|
return (
|
||||||
<components.Control {...props}>
|
<components.Control {...props}>
|
||||||
<Icon.FiSun className="ml-2 h-4 w-4 text-gray-500" />
|
<FiSun className="ml-2 h-4 w-4 text-gray-500" />
|
||||||
{children}
|
{children}
|
||||||
</components.Control>
|
</components.Control>
|
||||||
);
|
);
|
||||||
@@ -619,19 +626,19 @@ const ChooseEmbedTypesDialogContent = () => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<DialogContent type="creation" useOwnActionButtons size="lg">
|
<DialogContent type="creation" size="lg">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||||
{t("how_you_want_add_cal_site")}
|
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site")}</p>
|
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site", { appName: APP_NAME })}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
{embeds.map((embed, index) => (
|
{embeds.map((embed, index) => (
|
||||||
<button
|
<button
|
||||||
className="mr-2 w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100"
|
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-gray-100 ltr:mr-2 rtl:ml-2"
|
||||||
key={index}
|
key={index}
|
||||||
data-testid={embed.type}
|
data-testid={embed.type}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -642,7 +649,7 @@ const ChooseEmbedTypesDialogContent = () => {
|
|||||||
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
||||||
{embed.illustration}
|
{embed.illustration}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 font-medium text-neutral-900">{embed.title}</div>
|
<div className="mt-2 font-medium text-gray-900">{embed.title}</div>
|
||||||
<p className="text-sm text-gray-500">{embed.subtitle}</p>
|
<p className="text-sm text-gray-500">{embed.subtitle}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -688,6 +695,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
theme: Theme.auto,
|
theme: Theme.auto,
|
||||||
floatingPopup: {},
|
floatingPopup: {},
|
||||||
elementClick: {},
|
elementClick: {},
|
||||||
|
hideEventTypeDetails: false,
|
||||||
palette: {
|
palette: {
|
||||||
brandColor: "#000000",
|
brandColor: "#000000",
|
||||||
},
|
},
|
||||||
@@ -752,6 +760,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
name: "ui",
|
name: "ui",
|
||||||
arg: {
|
arg: {
|
||||||
theme: previewState.theme,
|
theme: previewState.theme,
|
||||||
|
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||||
styles: {
|
styles: {
|
||||||
branding: {
|
branding: {
|
||||||
...previewState.palette,
|
...previewState.palette,
|
||||||
@@ -797,7 +806,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent size="xl" className="p-0.5" type="creation" useOwnActionButtons>
|
<DialogContent size="xl" className="p-0.5" type="creation">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
||||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||||
@@ -805,19 +814,19 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
removeQueryParams(router, ["embedType", "embedTabName"]);
|
removeQueryParams(router, ["embedType", "embedTabName"]);
|
||||||
}}>
|
}}>
|
||||||
<Icon.FiArrowLeft className="mr-4 w-4" />
|
<FiArrowLeft className="mr-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{embed.title}
|
{embed.title}
|
||||||
</h3>
|
</h3>
|
||||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
||||||
<div className="max-h-97 flex flex-col overflow-y-auto">
|
<div className="flex flex-col overflow-y-auto">
|
||||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={isEmbedCustomizationOpen}
|
open={isEmbedCustomizationOpen}
|
||||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
className="flex w-full items-center text-base font-medium text-gray-900">
|
||||||
<div>
|
<div>
|
||||||
{embedType === "inline"
|
{embedType === "inline"
|
||||||
? "Inline Embed Customization"
|
? "Inline Embed Customization"
|
||||||
@@ -825,10 +834,10 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
? "Floating Popup Customization"
|
? "Floating Popup Customization"
|
||||||
: "Element Click Customization"}
|
: "Element Click Customization"}
|
||||||
</div>
|
</div>
|
||||||
<Icon.FiChevronRight
|
<FiChevronRight
|
||||||
className={`${
|
className={`${
|
||||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||||
} ml-auto h-5 w-5 text-neutral-500`}
|
} ml-auto h-5 w-5 text-gray-500`}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="text-sm">
|
<CollapsibleContent className="text-sm">
|
||||||
@@ -837,7 +846,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
<div className="text-sm">Embed Window Sizing</div>
|
<div className="text-sm">Embed Window Sizing</div>
|
||||||
<div className="justify-left flex items-center">
|
<div className="justify-left flex items-center">
|
||||||
<TextField
|
<TextField
|
||||||
name="width"
|
|
||||||
labelProps={{ className: "hidden" }}
|
labelProps={{ className: "hidden" }}
|
||||||
required
|
required
|
||||||
value={previewState.inline.width}
|
value={previewState.inline.width}
|
||||||
@@ -854,12 +862,11 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
addOnLeading={<InputLeading>W</InputLeading>}
|
addOnLeading={<>W</>}
|
||||||
/>
|
/>
|
||||||
<span className="p-2">×</span>
|
<span className="p-2">×</span>
|
||||||
<TextField
|
<TextField
|
||||||
labelProps={{ className: "hidden" }}
|
labelProps={{ className: "hidden" }}
|
||||||
name="height"
|
|
||||||
value={previewState.inline.height}
|
value={previewState.inline.height}
|
||||||
required
|
required
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -875,7 +882,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
addOnLeading={<InputLeading>H</InputLeading>}
|
addOnLeading={<>H</>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -887,7 +894,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
<div className="mb-2 text-sm">Button Text</div>
|
<div className="mb-2 text-sm">Button Text</div>
|
||||||
{/* Default Values should come from preview iframe */}
|
{/* Default Values should come from preview iframe */}
|
||||||
<TextField
|
<TextField
|
||||||
name="buttonText"
|
|
||||||
labelProps={{ className: "hidden" }}
|
labelProps={{ className: "hidden" }}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPreviewState((previewState) => {
|
setPreviewState((previewState) => {
|
||||||
@@ -907,7 +913,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mt-4 flex items-center justify-start",
|
"mt-4 flex items-center justify-start",
|
||||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
embedType === "floating-popup" ? "space-x-2 rtl:space-x-reverse" : "hidden"
|
||||||
)}>
|
)}>
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={true}
|
defaultChecked={true}
|
||||||
@@ -994,15 +1000,29 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
open={isBookingCustomizationOpen}
|
open={isBookingCustomizationOpen}
|
||||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||||
<CollapsibleTrigger className="flex w-full" type="button">
|
<CollapsibleTrigger className="flex w-full" type="button">
|
||||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
<div className="text-base font-medium text-gray-900">Cal Booking Customization</div>
|
||||||
<Icon.FiChevronRight
|
<FiChevronRight
|
||||||
className={`${
|
className={`${
|
||||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||||
} ml-auto h-5 w-5 text-neutral-500`}
|
} ml-auto h-5 w-5 text-gray-500`}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="mt-6 text-sm">
|
<div className="mt-6 text-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-start space-x-2 rtl:space-x-reverse">
|
||||||
|
<Switch
|
||||||
|
checked={previewState.hideEventTypeDetails}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setPreviewState((previewState) => {
|
||||||
|
return {
|
||||||
|
...previewState,
|
||||||
|
hideEventTypeDetails: checked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="text-sm">{t("hide_eventtype_details")}</div>
|
||||||
|
</div>
|
||||||
<Label className="">
|
<Label className="">
|
||||||
<div className="mb-2">Theme</div>
|
<div className="mb-2">Theme</div>
|
||||||
<Select
|
<Select
|
||||||
@@ -1095,9 +1115,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
{t("copy_code")}
|
{t("copy_code")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<DialogClose asChild>
|
<DialogClose />
|
||||||
<Button color="secondary">{t("Close")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1144,7 +1162,7 @@ export const EmbedButton = <T extends React.ElementType>({
|
|||||||
...props
|
...props
|
||||||
}: EmbedButtonProps<T> & React.ComponentPropsWithoutRef<T>) => {
|
}: EmbedButtonProps<T> & React.ComponentPropsWithoutRef<T>) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
className = classNames(className, "hidden lg:inline-flex");
|
className = classNames("hidden lg:inline-flex", className);
|
||||||
const openEmbedModal = () => {
|
const openEmbedModal = () => {
|
||||||
goto(router, {
|
goto(router, {
|
||||||
dialog: "embed",
|
dialog: "embed",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
export function useViewerI18n() {
|
export function useViewerI18n() {
|
||||||
@@ -21,12 +22,16 @@ export function useViewerI18n() {
|
|||||||
*/
|
*/
|
||||||
const I18nLanguageHandler = (): null => {
|
const I18nLanguageHandler = (): null => {
|
||||||
const { i18n } = useTranslation("common");
|
const { i18n } = useTranslation("common");
|
||||||
const locale = useViewerI18n().data?.locale;
|
const locale = useViewerI18n().data?.locale || "en";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (locale && i18n.language && i18n.language !== locale) {
|
if (locale && i18n.language && i18n.language !== locale) {
|
||||||
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
|
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dir = getDirFromLang(locale);
|
||||||
|
document.documentElement.setAttribute("lang", locale);
|
||||||
|
document.documentElement.setAttribute("dir", dir);
|
||||||
}, [locale, i18n]);
|
}, [locale, i18n]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
import Cropper from "react-easy-crop";
|
import Cropper from "react-easy-crop";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
||||||
|
|
||||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import Slider from "@components/Slider";
|
import Slider from "@components/Slider";
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ export default function ImageUploader({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||||
<label className="mt-8 rounded-sm border border-gray-300 bg-white px-3 py-1 text-xs font-medium leading-4 text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
|
<label className="mt-8 rounded-sm border border-gray-300 bg-white px-3 py-1 text-xs font-medium leading-4 text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
|
||||||
<input
|
<input
|
||||||
onInput={onInputFile}
|
onInput={onInputFile}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -156,12 +156,8 @@ export default function ImageUploader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<DialogClose asChild>
|
<DialogClose onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</DialogClose>
|
||||||
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</Button>
|
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||||
</DialogClose>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
|||||||
const className = tab.className || "";
|
const className = tab.className || "";
|
||||||
return (
|
return (
|
||||||
<Component key={tab.name}>
|
<Component key={tab.name}>
|
||||||
<Link key={tab.name} href={href} {...linkProps}>
|
<Link key={tab.name} href={href} {...linkProps} legacyBehavior>
|
||||||
<a
|
<a
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isCurrent
|
isCurrent
|
||||||
? "border-neutral-900 text-neutral-900"
|
? "border-gray-900 text-gray-900"
|
||||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium",
|
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium",
|
||||||
className
|
className
|
||||||
@@ -76,7 +76,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
|||||||
{tab.icon && (
|
{tab.icon && (
|
||||||
<tab.icon
|
<tab.icon
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
isCurrent ? "text-gray-900" : "text-gray-400 group-hover:text-gray-500",
|
||||||
"-ml-0.5 hidden h-4 w-4 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
"-ml-0.5 hidden h-4 w-4 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
|
|
||||||
import { ErrorBoundary, Icon, Shell } from "@calcom/ui";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
|
import { ErrorBoundary } from "@calcom/ui";
|
||||||
|
import { FiCreditCard, FiKey, FiLock, FiTerminal, FiUser, FiUsers } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import NavTabs from "./NavTabs";
|
import NavTabs from "./NavTabs";
|
||||||
|
|
||||||
@@ -8,32 +10,32 @@ const tabs = [
|
|||||||
{
|
{
|
||||||
name: "profile",
|
name: "profile",
|
||||||
href: "/settings/profile",
|
href: "/settings/profile",
|
||||||
icon: Icon.FiUser,
|
icon: FiUser,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "teams",
|
name: "teams",
|
||||||
href: "/settings/teams",
|
href: "/settings/teams",
|
||||||
icon: Icon.FiUsers,
|
icon: FiUsers,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "security",
|
name: "security",
|
||||||
href: "/settings/security",
|
href: "/settings/security",
|
||||||
icon: Icon.FiKey,
|
icon: FiKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "developer",
|
name: "developer",
|
||||||
href: "/settings/developer",
|
href: "/settings/developer",
|
||||||
icon: Icon.FiTerminal,
|
icon: FiTerminal,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "billing",
|
name: "billing",
|
||||||
href: "/settings/billing",
|
href: "/settings/billing",
|
||||||
icon: Icon.FiCreditCard,
|
icon: FiCreditCard,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "admin",
|
name: "admin",
|
||||||
href: "/settings/admin",
|
href: "/settings/admin",
|
||||||
icon: Icon.FiLock,
|
icon: FiLock,
|
||||||
adminRequired: true,
|
adminRequired: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { InstallAppButton } from "@calcom/app-store/components";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
import { Button, Icon, Select } from "@calcom/ui";
|
import { Button, Select } from "@calcom/ui";
|
||||||
|
import { FiPlus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
|
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
|
||||||
<Icon.FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
|
<FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
|
||||||
<p>{t("install_new_calendar_app")}</p>
|
<p>{t("install_new_calendar_app")}</p>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -61,12 +62,10 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
|||||||
<Select
|
<Select
|
||||||
name="additionalCalendar"
|
name="additionalCalendar"
|
||||||
placeholder={
|
placeholder={
|
||||||
<div className="flex justify-start text-black">
|
<Button StartIcon={FiPlus} color="secondary">
|
||||||
<Icon.FiPlus className="-ml-1 h-4 w-4 ltr:mr-2 rtl:ml-2 rtl:-mr-1" />
|
{t("add")}
|
||||||
<p>{t("add")}</p>
|
</Button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
className="min-w-44"
|
|
||||||
options={options}
|
options={options}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -80,11 +79,16 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
|||||||
width: "max-content",
|
width: "max-content",
|
||||||
right: "0",
|
right: "0",
|
||||||
}),
|
}),
|
||||||
|
indicatorSeparator: (defaultStyles) => ({
|
||||||
|
...defaultStyles,
|
||||||
|
display: "none",
|
||||||
|
}),
|
||||||
control: (defaultStyles) => ({
|
control: (defaultStyles) => ({
|
||||||
...defaultStyles,
|
...defaultStyles,
|
||||||
padding: "0",
|
padding: "0",
|
||||||
border: "0",
|
border: "0",
|
||||||
borderRadius: "inherit",
|
borderRadius: "6px",
|
||||||
|
minHeight: "auto",
|
||||||
}),
|
}),
|
||||||
dropdownIndicator: (defaultStyles) => ({
|
dropdownIndicator: (defaultStyles) => ({
|
||||||
...defaultStyles,
|
...defaultStyles,
|
||||||
|
|||||||
@@ -4,22 +4,25 @@ import React, { useState } from "react";
|
|||||||
|
|
||||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||||
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { App as AppType } from "@calcom/types/App";
|
import { App as AppType } from "@calcom/types/App";
|
||||||
|
import { Button, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
|
||||||
import {
|
import {
|
||||||
Button,
|
FiBookOpen,
|
||||||
DisconnectIntegration,
|
FiCheck,
|
||||||
Icon,
|
FiExternalLink,
|
||||||
Shell,
|
FiFile,
|
||||||
showToast,
|
FiFlag,
|
||||||
SkeletonButton,
|
FiMail,
|
||||||
SkeletonText,
|
FiPlus,
|
||||||
} from "@calcom/ui";
|
FiShield,
|
||||||
|
} from "@calcom/ui/components/icon";
|
||||||
import HeadSeo from "@components/seo/head-seo";
|
|
||||||
|
|
||||||
const Component = ({
|
const Component = ({
|
||||||
name,
|
name,
|
||||||
@@ -41,6 +44,7 @@ const Component = ({
|
|||||||
privacy,
|
privacy,
|
||||||
isProOnly,
|
isProOnly,
|
||||||
images,
|
images,
|
||||||
|
isTemplate,
|
||||||
}: Parameters<typeof App>[0]) => {
|
}: Parameters<typeof App>[0]) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const hasImages = images && images.length > 0;
|
const hasImages = images && images.length > 0;
|
||||||
@@ -48,7 +52,7 @@ const Component = ({
|
|||||||
|
|
||||||
const mutation = useAddAppMutation(null, {
|
const mutation = useAddAppMutation(null, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.setupPending) return;
|
if (data?.setupPending) return;
|
||||||
showToast(t("app_successfully_installed"), "success");
|
showToast(t("app_successfully_installed"), "success");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -72,7 +76,9 @@ const Component = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
|
||||||
|
// Such apps, can only be installed once.
|
||||||
|
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||||
@@ -104,18 +110,25 @@ const Component = ({
|
|||||||
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-sm font-medium text-gray-600">
|
<h2 className="text-sm font-medium text-gray-600">
|
||||||
<Link href={`categories/${categories[0]}`}>
|
<Link
|
||||||
<a className="rounded-md bg-gray-100 p-1 text-xs capitalize text-gray-800">{categories[0]}</a>
|
href={`categories/${categories[0]}`}
|
||||||
|
className="rounded-md bg-gray-100 p-1 text-xs capitalize text-gray-800">
|
||||||
|
{categories[0]}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
• {t("published_by", { author })}
|
• {t("published_by", { author })}
|
||||||
</h2>
|
</h2>
|
||||||
|
{isTemplate && (
|
||||||
|
<Badge variant="red" className="mt-4">
|
||||||
|
Template - Available in Dev Environment only for testing
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
{!appCredentials.isLoading ? (
|
{!appCredentials.isLoading ? (
|
||||||
isGlobal ||
|
isGlobal ||
|
||||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
|
<Button StartIcon={FiCheck} color="secondary" disabled>
|
||||||
{existingCredentials.length > 0
|
{existingCredentials.length > 0
|
||||||
? t("active_install", { count: existingCredentials.length })
|
? t("active_install", { count: existingCredentials.length })
|
||||||
: t("default")}
|
: t("default")}
|
||||||
@@ -136,7 +149,7 @@ const Component = ({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
StartIcon={Icon.FiPlus}
|
StartIcon={FiPlus}
|
||||||
{...props}
|
{...props}
|
||||||
// @TODO: Overriding color and size prevent us from
|
// @TODO: Overriding color and size prevent us from
|
||||||
// having to duplicate InstallAppButton for now.
|
// having to duplicate InstallAppButton for now.
|
||||||
@@ -197,11 +210,11 @@ const Component = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="prose prose-sm mt-8">{body}</div>
|
<div className="prose-sm prose mt-8">{body}</div>
|
||||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||||
<span>
|
<span>
|
||||||
{price === 0 ? (
|
{price === 0 ? (
|
||||||
"Free"
|
t("free_to_use_apps")
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{Intl.NumberFormat("en-US", {
|
{Intl.NumberFormat("en-US", {
|
||||||
@@ -223,7 +236,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-sm font-normal text-black no-underline hover:underline"
|
className="text-sm font-normal text-black no-underline hover:underline"
|
||||||
href={docs}>
|
href={docs}>
|
||||||
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4 text-gray-500" />
|
<FiBookOpen className="mr-1 -mt-1 inline h-4 w-4 text-gray-500" />
|
||||||
{t("documentation")}
|
{t("documentation")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -235,7 +248,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-normal text-black no-underline hover:underline"
|
className="font-normal text-black no-underline hover:underline"
|
||||||
href={website}>
|
href={website}>
|
||||||
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
<FiExternalLink className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
{website.replace("https://", "")}
|
{website.replace("https://", "")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -247,7 +260,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-normal text-black no-underline hover:underline"
|
className="font-normal text-black no-underline hover:underline"
|
||||||
href={"mailto:" + email}>
|
href={"mailto:" + email}>
|
||||||
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
<FiMail className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
|
|
||||||
{email}
|
{email}
|
||||||
</a>
|
</a>
|
||||||
@@ -260,7 +273,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-normal text-black no-underline hover:underline"
|
className="font-normal text-black no-underline hover:underline"
|
||||||
href={tos}>
|
href={tos}>
|
||||||
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
<FiFile className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
{t("terms_of_service")}
|
{t("terms_of_service")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -272,16 +285,18 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-normal text-black no-underline hover:underline"
|
className="font-normal text-black no-underline hover:underline"
|
||||||
href={privacy}>
|
href={privacy}>
|
||||||
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
<FiShield className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
{t("privacy_policy")}
|
{t("privacy_policy")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<hr className="my-8" />
|
<hr className="my-8" />
|
||||||
<span className="leading-1 block text-xs text-gray-500">{t("every_app_published")}</span>
|
<span className="leading-1 block text-xs text-gray-500">
|
||||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
|
||||||
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
</span>
|
||||||
|
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||||
|
<FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,19 +326,16 @@ export default function App(props: {
|
|||||||
licenseRequired: AppType["licenseRequired"];
|
licenseRequired: AppType["licenseRequired"];
|
||||||
isProOnly: AppType["isProOnly"];
|
isProOnly: AppType["isProOnly"];
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
isTemplate?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell large isPublic heading={t("app_store")} backPath="/apps" withoutSeo>
|
<Shell smallHeading isPublic heading={t("app_store")} backPath="/apps" withoutSeo>
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
title={props.name}
|
title={props.name}
|
||||||
description={props.description}
|
description={props.description}
|
||||||
app={{ slug: props.logo, name: props.name, description: props.description }}
|
app={{ slug: props.logo, name: props.name, description: props.description }}
|
||||||
nextSeoProps={{
|
|
||||||
nofollow: true,
|
|
||||||
noindex: true,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{props.licenseRequired ? (
|
{props.licenseRequired ? (
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
|
|||||||
@@ -3,26 +3,26 @@ import Link from "next/link";
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
DisconnectIntegration,
|
|
||||||
EmptyScreen,
|
EmptyScreen,
|
||||||
Icon,
|
|
||||||
List,
|
List,
|
||||||
ShellSubHeading,
|
|
||||||
showToast,
|
showToast,
|
||||||
SkeletonLoader,
|
AppSkeletonLoader as SkeletonLoader,
|
||||||
Switch,
|
Switch,
|
||||||
|
ShellSubHeading,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
import { FiArrowLeft, FiCalendar, FiPlus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
|
import AppListCard from "@components/AppListCard";
|
||||||
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
|
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
|
||||||
import IntegrationListItem from "@components/apps/IntegrationListItem";
|
|
||||||
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -100,7 +100,7 @@ function CalendarSwitch(props: {
|
|||||||
/>
|
/>
|
||||||
{!!props.destination && (
|
{!!props.destination && (
|
||||||
<span className="ml-4 inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm font-normal text-gray-800">
|
<span className="ml-4 inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm font-normal text-gray-800">
|
||||||
<Icon.FiArrowLeft className="h-4 w-4" />
|
<FiArrowLeft className="h-4 w-4" />
|
||||||
{t("adding_events_to")}
|
{t("adding_events_to")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -118,13 +118,13 @@ function CalendarList(props: Props) {
|
|||||||
success={({ data }) => (
|
success={({ data }) => (
|
||||||
<List>
|
<List>
|
||||||
{data.items.map((item) => (
|
{data.items.map((item) => (
|
||||||
<IntegrationListItem
|
<AppListCard
|
||||||
name={item.name}
|
title={item.name}
|
||||||
slug={item.slug}
|
key={item.name}
|
||||||
key={item.title}
|
|
||||||
title={item.title}
|
|
||||||
logo={item.logo}
|
logo={item.logo}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
|
shouldHighlight
|
||||||
|
slug={item.slug}
|
||||||
actions={
|
actions={
|
||||||
<InstallAppButton
|
<InstallAppButton
|
||||||
type={item.type}
|
type={item.type}
|
||||||
@@ -159,17 +159,18 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
if (!data.connectedCalendars.length) {
|
if (!data.connectedCalendars.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
<List>
|
||||||
{data.connectedCalendars.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
<Fragment key={item.credentialId}>
|
<Fragment key={item.credentialId}>
|
||||||
{item.calendars ? (
|
{item.calendars ? (
|
||||||
<IntegrationListItem
|
<AppListCard
|
||||||
|
shouldHighlight
|
||||||
slug={item.integration.slug}
|
slug={item.integration.slug}
|
||||||
title={item.integration.title}
|
title={item.integration.name}
|
||||||
logo={item.integration.logo}
|
logo={item.integration.logo}
|
||||||
description={item.primary?.externalId || "No external Id"}
|
description={item.primary?.email ?? item.integration.description}
|
||||||
separate={true}
|
|
||||||
actions={
|
actions={
|
||||||
<div className="flex w-32 justify-end">
|
<div className="flex w-32 justify-end">
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
@@ -183,9 +184,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
<div className="border-t border-gray-200">
|
<div className="border-t border-gray-200">
|
||||||
{!fromOnboarding && (
|
{!fromOnboarding && (
|
||||||
<>
|
<>
|
||||||
<p className="px-4 pt-4 text-sm text-neutral-500">
|
<p className="px-4 pt-4 text-sm text-gray-500">{t("toggle_calendars_conflict")}</p>
|
||||||
{t("toggle_calendars_conflict")}
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 p-4">
|
<ul className="space-y-2 p-4">
|
||||||
{item.calendars.map((cal) => (
|
{item.calendars.map((cal) => (
|
||||||
<CalendarSwitch
|
<CalendarSwitch
|
||||||
@@ -201,7 +200,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</IntegrationListItem>
|
</AppListCard>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
severity="warning"
|
severity="warning"
|
||||||
@@ -283,8 +282,8 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
|||||||
<div className="flex justify-between rounded-md border border-gray-200 bg-gray-50 p-4">
|
<div className="flex justify-between rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||||
<div className="flex w-full flex-col items-start gap-4 md:flex-row md:items-center">
|
<div className="flex w-full flex-col items-start gap-4 md:flex-row md:items-center">
|
||||||
<div className="relative rounded-md border border-gray-200 bg-white p-1.5">
|
<div className="relative rounded-md border border-gray-200 bg-white p-1.5">
|
||||||
<Icon.FiCalendar className="h-8 w-8" strokeWidth="1" />
|
<FiCalendar className="h-8 w-8" strokeWidth="1" />
|
||||||
<Icon.FiPlus
|
<FiPlus
|
||||||
className="absolute left-4 top-1/2 ml-0.5 mt-[1px] h-2 w-2 text-black"
|
className="absolute left-4 top-1/2 ml-0.5 mt-[1px] h-2 w-2 text-black"
|
||||||
strokeWidth="4"
|
strokeWidth="4"
|
||||||
/>
|
/>
|
||||||
@@ -323,13 +322,16 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={Icon.FiCalendar}
|
Icon={FiCalendar}
|
||||||
headline={t("no_category_apps", {
|
headline={t("no_category_apps", {
|
||||||
category: t("calendar").toLowerCase(),
|
category: t("calendar").toLowerCase(),
|
||||||
})}
|
})}
|
||||||
description={t(`no_category_apps_description_calendar`)}
|
description={t(`no_category_apps_description_calendar`)}
|
||||||
buttonRaw={
|
buttonRaw={
|
||||||
<Button color="secondary" href="/apps/categories/calendar">
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
data-testid="connect-calendar-apps"
|
||||||
|
href="/apps/categories/calendar">
|
||||||
{t(`connect_calendar_apps`)}
|
{t(`connect_calendar_apps`)}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { Icon, ListItem, ListItemText, ListItemTitle, showToast } from "@calcom/ui";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
|
||||||
|
|
||||||
function IntegrationListItem(props: {
|
|
||||||
imageSrc?: string;
|
|
||||||
slug: string;
|
|
||||||
name?: string;
|
|
||||||
title?: string;
|
|
||||||
description: string;
|
|
||||||
actions?: ReactNode;
|
|
||||||
children?: ReactNode;
|
|
||||||
logo: string;
|
|
||||||
destination?: boolean;
|
|
||||||
separate?: boolean;
|
|
||||||
invalidCredential?: boolean;
|
|
||||||
}): JSX.Element {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const router = useRouter();
|
|
||||||
const { hl } = router.query;
|
|
||||||
const [highlight, setHighlight] = useState(hl === props.slug);
|
|
||||||
const title = props.name || props.title;
|
|
||||||
|
|
||||||
// The highlight is to show a newly installed app, coming from the app's
|
|
||||||
// redirection after installation, so we proceed to show the corresponding
|
|
||||||
// message
|
|
||||||
if (highlight) {
|
|
||||||
showToast(t("app_successfully_installed"), "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setHighlight(false), 3000);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<ListItem
|
|
||||||
expanded={!!props.children}
|
|
||||||
className={classNames(
|
|
||||||
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
|
||||||
"my-0 flex-col border transition-colors duration-500 ",
|
|
||||||
highlight ? "bg-yellow-100" : ""
|
|
||||||
)}>
|
|
||||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
|
||||||
{props.logo && <img className="h-11 w-11" src={props.logo} alt={title} />}
|
|
||||||
<div className="flex-grow truncate pl-2">
|
|
||||||
<ListItemTitle component="h3">
|
|
||||||
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
|
|
||||||
</ListItemTitle>
|
|
||||||
<ListItemText component="p">{props.description}</ListItemText>
|
|
||||||
{/* Alert error that key stopped working. */}
|
|
||||||
{props.invalidCredential && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
|
|
||||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
|
||||||
{t("invalid_credential")}
|
|
||||||
</ListItemText>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>{props.actions}</div>
|
|
||||||
</div>
|
|
||||||
{props.children && <div className="w-full">{props.children}</div>}
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IntegrationListItem;
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { EmptyScreen } from "@calcom/ui";
|
||||||
|
import { FiAlertCircle } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
type AppsLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
actions?: (className?: string) => JSX.Element;
|
||||||
|
emptyStore?: boolean;
|
||||||
|
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||||
|
|
||||||
|
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const session = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (session.status === "loading") return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell {...rest} actions={actions?.("block")}>
|
||||||
|
<div className="flex flex-col xl:flex-row">
|
||||||
|
<main className="w-full">
|
||||||
|
{emptyStore ? (
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={FiAlertCircle}
|
||||||
|
headline={t("no_apps")}
|
||||||
|
description={session.data?.user.role === "ADMIN" ? "You can enable apps in the settings" : ""}
|
||||||
|
buttonText={session.data?.user.role === "ADMIN" ? t("apps_settings") : ""}
|
||||||
|
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>{children}</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export const getLayout = (page: React.ReactElement) => <AppsLayout>{page}</AppsLayout>;
|
||||||
+18
-24
@@ -1,45 +1,42 @@
|
|||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import AppCategoryNavigation from "@calcom/app-store/_components/AppCategoryNavigation";
|
||||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||||
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
|
||||||
import { Icon } from "../../../Icon";
|
import { FiBarChart, FiCalendar, FiCreditCard, FiGrid, FiShare2, FiVideo } from "@calcom/ui/components/icon";
|
||||||
import Shell from "../Shell";
|
|
||||||
import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem";
|
|
||||||
import HorizontalTabs from "../navigation/tabs/HorizontalTabs";
|
|
||||||
import type { VerticalTabItemProps } from "../navigation/tabs/VerticalTabItem";
|
|
||||||
import VerticalTabs from "../navigation/tabs/VerticalTabs";
|
|
||||||
|
|
||||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||||
{
|
{
|
||||||
name: "calendar",
|
name: "calendar",
|
||||||
href: "/apps/installed/calendar",
|
href: "/apps/installed/calendar",
|
||||||
icon: Icon.FiCalendar,
|
icon: FiCalendar,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "conferencing",
|
name: "conferencing",
|
||||||
href: "/apps/installed/conferencing",
|
href: "/apps/installed/conferencing",
|
||||||
icon: Icon.FiVideo,
|
icon: FiVideo,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "payment",
|
name: "payment",
|
||||||
href: "/apps/installed/payment",
|
href: "/apps/installed/payment",
|
||||||
icon: Icon.FiCreditCard,
|
icon: FiCreditCard,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "automation",
|
name: "automation",
|
||||||
href: "/apps/installed/automation",
|
href: "/apps/installed/automation",
|
||||||
icon: Icon.FiShare2,
|
icon: FiShare2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "analytics",
|
name: "analytics",
|
||||||
href: "/apps/installed/analytics",
|
href: "/apps/installed/analytics",
|
||||||
icon: Icon.FiBarChart,
|
icon: FiBarChart,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "other",
|
name: "other",
|
||||||
href: "/apps/installed/other",
|
href: "/apps/installed/other",
|
||||||
icon: Icon.FiGrid,
|
icon: FiGrid,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,25 +44,22 @@ export default function InstalledAppsLayout({
|
|||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||||
|
const variant: typeof InstalledAppVariants[number] = "payment";
|
||||||
|
|
||||||
const query = trpc.viewer.integrations.useQuery({
|
const query = trpc.viewer.integrations.useQuery({
|
||||||
variant: InstalledAppVariants.payment,
|
variant,
|
||||||
onlyInstalled: true,
|
onlyInstalled: true,
|
||||||
});
|
});
|
||||||
let actualTabs = tabs;
|
let actualTabs = tabs;
|
||||||
if (query.data?.items.length === 0) {
|
if (query.data?.items.length === 0) {
|
||||||
actualTabs = tabs.filter((tab) => tab.name !== InstalledAppVariants.payment);
|
actualTabs = tabs.filter((tab) => tab.name !== variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell {...rest}>
|
<Shell {...rest}>
|
||||||
<div className="flex flex-col p-2 md:p-0 xl:flex-row">
|
<AppCategoryNavigation baseURL="/apps/installed" containerClassname="min-w-0 w-full">
|
||||||
<div className="hidden xl:block">
|
{children}
|
||||||
<VerticalTabs tabs={actualTabs} sticky linkProps={{ shallow: true }} />
|
</AppCategoryNavigation>
|
||||||
</div>
|
|
||||||
<div className="block xl:hidden">
|
|
||||||
<HorizontalTabs tabs={actualTabs} linkProps={{ shallow: true }} />
|
|
||||||
</div>
|
|
||||||
<main className="w-full xl:mx-5 xl:w-4/5 xl:pr-5">{children}</main>
|
|
||||||
</div>
|
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+8
-5
@@ -2,13 +2,15 @@ import { useSession } from "next-auth/react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { ComponentProps, useEffect } from "react";
|
import React, { ComponentProps, useEffect } from "react";
|
||||||
|
|
||||||
import ErrorBoundary from "../../../ErrorBoundary";
|
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import Shell from "../Shell";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
import SettingsLayout from "./SettingsLayout";
|
import { ErrorBoundary } from "@calcom/ui";
|
||||||
|
|
||||||
import { UserPermissionRole } from ".prisma/client";
|
import { UserPermissionRole } from ".prisma/client";
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
|
|
||||||
...rest
|
...rest
|
||||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
@@ -21,10 +23,11 @@ export default function AdminLayout({
|
|||||||
}
|
}
|
||||||
}, [session, router]);
|
}, [session, router]);
|
||||||
|
|
||||||
|
const isAppsPage = router.asPath.startsWith("/settings/admin/apps");
|
||||||
return (
|
return (
|
||||||
<SettingsLayout {...rest}>
|
<SettingsLayout {...rest}>
|
||||||
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200 md:px-12">
|
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200">
|
||||||
<div className="flex flex-1 [&>*]:flex-1">
|
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
|
||||||
<ErrorBoundary>{children}</ErrorBoundary>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6,7 +6,7 @@ import classNames from "@lib/classNames";
|
|||||||
|
|
||||||
function SkeletonLoader() {
|
function SkeletonLoader() {
|
||||||
return (
|
return (
|
||||||
<ul className="animate-pulse divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
<ul className="animate-pulse divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { Icon, Tooltip } from "@calcom/ui";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Tooltip } from "@calcom/ui";
|
||||||
|
import { FiLink } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { Props } from "./pages/AvailabilityPage";
|
import { Props } from "./pages/AvailabilityPage";
|
||||||
|
|
||||||
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
return locations.length ? (
|
return locations.length ? (
|
||||||
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
|
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
|
||||||
{locations.map((location) => {
|
{locations.map((location, index) => {
|
||||||
const eventLocationType = getEventLocationType(location.type);
|
const eventLocationType = getEventLocationType(location.type);
|
||||||
if (!eventLocationType) {
|
if (!eventLocationType) {
|
||||||
// It's possible that the location app got uninstalled
|
// It's possible that the location app got uninstalled
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||||
<Icon.FiLink className="dark:text-darkgray-600 mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 " />
|
<FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={eventLocationType.iconUrl}
|
src={eventLocationType.iconUrl}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
|
||||||
)}
|
)}
|
||||||
alt={`${eventLocationType.label} icon`}
|
alt={`${eventLocationType.label} icon`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={locationKeyToString(location)}>
|
<Tooltip content={t(locationKeyToString(location) ?? "")}>
|
||||||
<p className="truncate">{locationKeyToString(location)}</p>
|
<p className="truncate">{t(locationKeyToString(location) ?? "")}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-bookinglight font-medium">
|
<span className="text-bookinglight font-medium">
|
||||||
, {date.toDate().toLocaleString(i18n.language, { month: "long" })} {date.format(" D ")}
|
, {date.toDate().toLocaleString(i18n.language, { month: "short" })} {date.format(" D ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
@@ -71,7 +71,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
<div className="flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||||
{slots.length > 0 &&
|
{slots.length > 0 &&
|
||||||
slots.map((slot) => {
|
slots.map((slot) => {
|
||||||
type BookingURL = {
|
type BookingURL = {
|
||||||
@@ -87,7 +87,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
slug: eventTypeSlug,
|
slug: eventTypeSlug,
|
||||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||||
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||||
ethSignature,
|
...(ethSignature ? { ethSignature } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,7 +101,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={dayjs(slot.time).format()}>
|
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||||
|
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */}
|
||||||
{/* Current there is no way to disable Next.js Links */}
|
{/* Current there is no way to disable Next.js Links */}
|
||||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||||
<div
|
<div
|
||||||
@@ -113,29 +114,29 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
|
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link href={bookingUrl} prefetch={false}>
|
<Link
|
||||||
<a
|
href={bookingUrl}
|
||||||
className={classNames(
|
prefetch={false}
|
||||||
"text-primary-500 hover:border-gray-900 hover:bg-gray-50",
|
className={classNames(
|
||||||
"dark:bg-darkgray-200 dark:hover:bg-darkgray-300 dark:hover:border-darkmodebrand mb-2 block rounded-md border bg-white py-2 text-sm font-medium dark:border-transparent dark:text-neutral-200",
|
"text-primary-500 hover:border-gray-900 hover:bg-gray-50",
|
||||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
"dark:bg-darkgray-200 dark:hover:bg-darkgray-300 dark:hover:border-darkmodebrand dark:text-darkgray-800 mb-2 block rounded-md border bg-white py-2 text-sm font-medium dark:border-transparent",
|
||||||
)}
|
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||||
data-testid="time">
|
)}
|
||||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
data-testid="time">
|
||||||
{!!seatsPerTimeSlot && (
|
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||||
<p
|
{!!seatsPerTimeSlot && (
|
||||||
className={`${
|
<p
|
||||||
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
|
className={`${
|
||||||
? "text-rose-600"
|
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
|
||||||
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
|
? "text-rose-600"
|
||||||
? "text-yellow-500"
|
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
|
||||||
: "text-emerald-400"
|
? "text-yellow-500"
|
||||||
} text-sm`}>
|
: "text-emerald-400"
|
||||||
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
} text-sm`}>
|
||||||
{seatsPerTimeSlot} {t("seats_available")}
|
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
||||||
</p>
|
{seatsPerTimeSlot} {t("seats_available")}
|
||||||
)}
|
</p>
|
||||||
</a>
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { SchedulingType } from "@prisma/client";
|
import { SchedulingType } from "@prisma/client";
|
||||||
import { FC, ReactNode } from "react";
|
import { FC, ReactNode, useEffect } from "react";
|
||||||
|
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Icon } from "@calcom/ui";
|
import { Badge } from "@calcom/ui";
|
||||||
|
import { FiCheckSquare, FiClock, FiInfo } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
|
|
||||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||||
@@ -39,14 +43,41 @@ interface Props {
|
|||||||
|
|
||||||
const BookingDescription: FC<Props> = (props) => {
|
const BookingDescription: FC<Props> = (props) => {
|
||||||
const { profile, eventType, isBookingPage = false, children } = props;
|
const { profile, eventType, isBookingPage = false, children } = props;
|
||||||
|
const { date: bookingDate } = useRouterQuery("date");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const { duration = eventType.length.toString(), setQuery: setDuration } = useRouterQuery("duration");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
eventType.metadata?.multipleDuration &&
|
||||||
|
!eventType.metadata?.multipleDuration?.includes(Number(duration))
|
||||||
|
) {
|
||||||
|
setDuration(eventType.length.toString());
|
||||||
|
}
|
||||||
|
}, [duration, setDuration, eventType.length, eventType.metadata?.multipleDuration]);
|
||||||
|
|
||||||
|
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||||
|
let requiresConfirmationText = t("requires_confirmation");
|
||||||
|
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||||
|
if (rcThreshold) {
|
||||||
|
if (isBookingPage) {
|
||||||
|
if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
||||||
|
requiresConfirmation = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requiresConfirmationText = t("requires_confirmation_threshold", {
|
||||||
|
...rcThreshold,
|
||||||
|
unit: rcThreshold.unit.slice(0, -1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UserAvatars
|
<UserAvatars
|
||||||
profile={profile}
|
profile={profile}
|
||||||
users={eventType.users}
|
users={eventType.users}
|
||||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||||
size={10}
|
size="sm"
|
||||||
truncateAfter={3}
|
truncateAfter={3}
|
||||||
/>
|
/>
|
||||||
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||||
@@ -63,9 +94,9 @@ const BookingDescription: FC<Props> = (props) => {
|
|||||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
<Icon.FiInfo
|
<FiInfo
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mr-[10px] ml-[2px] inline-block h-4 w-4",
|
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
|
||||||
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
|
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -75,29 +106,62 @@ const BookingDescription: FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{eventType?.requiresConfirmation && (
|
{requiresConfirmation && (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex items-center",
|
"items-top flex",
|
||||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
<FiCheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||||
</div>
|
</div>
|
||||||
{t("requires_confirmation")}
|
{requiresConfirmationText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AvailableEventLocations
|
<AvailableEventLocations
|
||||||
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||||
/>
|
/>
|
||||||
<p
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"text-sm font-medium",
|
"flex flex-nowrap text-sm font-medium",
|
||||||
isBookingPage && "dark:text-darkgray-600 text-gray-600"
|
isBookingPage && "dark:text-darkgray-600 text-gray-600",
|
||||||
|
!eventType.metadata?.multipleDuration && "items-center"
|
||||||
)}>
|
)}>
|
||||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
<FiClock
|
||||||
{eventType.length} {t("minutes")}
|
className={classNames(
|
||||||
</p>
|
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||||
|
isBookingPage && "mt-[2px]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{eventType.metadata?.multipleDuration !== undefined ? (
|
||||||
|
!isBookingPage ? (
|
||||||
|
<ul className="-mt-1 flex flex-wrap gap-1">
|
||||||
|
{eventType.metadata.multipleDuration.map((dur, idx) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
size="lg"
|
||||||
|
className={classNames(
|
||||||
|
duration === dur.toString()
|
||||||
|
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
|
||||||
|
: "hover:bg-darkgray-200 dark:hover:bg-darkmodebrand hover:text-darkgray-900 dark:hover:text-darkmodebrandcontrast dark:bg-darkgray-200 bg-gray-200 text-gray-900 dark:text-white",
|
||||||
|
"cursor-pointer"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setDuration(dur);
|
||||||
|
}}>
|
||||||
|
{dur} {t("minute_timeUnit")}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
`${duration} ${t("minutes")}`
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
`${eventType.length} ${t("minutes")}`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { formatTime } from "@calcom/lib/date-fns";
|
import { formatTime } from "@calcom/lib/date-fns";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -16,21 +17,21 @@ import {
|
|||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
Icon,
|
|
||||||
MeetingTimeInTimezones,
|
MeetingTimeInTimezones,
|
||||||
showToast,
|
showToast,
|
||||||
TextArea,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
ActionType,
|
||||||
|
TableActions,
|
||||||
|
TextAreaField,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
import { FiCheck, FiClock, FiMapPin, FiRefreshCcw, FiSend, FiSlash, FiX } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
|
||||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
|
||||||
|
|
||||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["status"];
|
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||||
|
|
||||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||||
|
|
||||||
@@ -48,9 +49,10 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||||
|
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.status === BookingStatus.REJECTED) {
|
if (data?.status === BookingStatus.REJECTED) {
|
||||||
setRejectionDialogIsOpen(false);
|
setRejectionDialogIsOpen(false);
|
||||||
showToast(t("booking_rejection_success"), "success");
|
showToast(t("booking_rejection_success"), "success");
|
||||||
} else {
|
} else {
|
||||||
@@ -97,7 +99,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
setRejectionDialogIsOpen(true);
|
setRejectionDialogIsOpen(true);
|
||||||
},
|
},
|
||||||
icon: Icon.FiSlash,
|
icon: FiSlash,
|
||||||
disabled: mutation.isLoading,
|
disabled: mutation.isLoading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -106,22 +108,33 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
bookingConfirm(true);
|
bookingConfirm(true);
|
||||||
},
|
},
|
||||||
icon: Icon.FiCheck,
|
icon: FiCheck,
|
||||||
disabled: mutation.isLoading,
|
disabled: mutation.isLoading,
|
||||||
color: "primary",
|
color: "primary",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const showRecordingActions: ActionType[] = [
|
||||||
|
{
|
||||||
|
id: "view_recordings",
|
||||||
|
label: t("view_recordings"),
|
||||||
|
onClick: () => {
|
||||||
|
setViewRecordingsDialogIsOpen(true);
|
||||||
|
},
|
||||||
|
disabled: mutation.isLoading,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let bookedActions: ActionType[] = [
|
let bookedActions: ActionType[] = [
|
||||||
{
|
{
|
||||||
id: "cancel",
|
id: "cancel",
|
||||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
||||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||||
cancel all remaining bookings or just that booking instance. */
|
cancel all remaining bookings or just that booking instance. */
|
||||||
href: `/success?uid=${booking.uid}&cancel=true${
|
href: `/booking/${booking.uid}?cancel=true${
|
||||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||||
}`,
|
}`,
|
||||||
icon: Icon.FiX,
|
icon: FiX,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "edit_booking",
|
id: "edit_booking",
|
||||||
@@ -129,13 +142,13 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: "reschedule",
|
id: "reschedule",
|
||||||
icon: Icon.FiClock,
|
icon: FiClock,
|
||||||
label: t("reschedule_booking"),
|
label: t("reschedule_booking"),
|
||||||
href: `/reschedule/${booking.uid}`,
|
href: `/reschedule/${booking.uid}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "reschedule_request",
|
id: "reschedule_request",
|
||||||
icon: Icon.FiSend,
|
icon: FiSend,
|
||||||
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
|
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
|
||||||
label: t("send_reschedule_request"),
|
label: t("send_reschedule_request"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -148,7 +161,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
setIsOpenLocationDialog(true);
|
setIsOpenLocationDialog(true);
|
||||||
},
|
},
|
||||||
icon: Icon.FiMapPin,
|
icon: FiMapPin,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -158,10 +171,14 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPast && isPending && !isConfirmed) {
|
||||||
|
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
|
||||||
|
}
|
||||||
|
|
||||||
const RequestSentMessage = () => {
|
const RequestSentMessage = () => {
|
||||||
return (
|
return (
|
||||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||||
<Icon.FiSend className="-mt-[1px] w-4 rotate-45" />
|
<FiSend className="-mt-[1px] w-4 rotate-45" />
|
||||||
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
|
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -195,16 +212,15 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
|
|
||||||
const onClickTableData = () => {
|
const onClickTableData = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${booking.uid}`,
|
||||||
query: {
|
query: {
|
||||||
uid: booking.uid,
|
|
||||||
allRemainingBookings: isTabRecurring,
|
allRemainingBookings: isTabRecurring,
|
||||||
listingStatus: booking.listingStatus,
|
|
||||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const showRecordingsButtons =
|
||||||
|
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RescheduleDialog
|
<RescheduleDialog
|
||||||
@@ -218,29 +234,33 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
isOpenDialog={isOpenSetLocationDialog}
|
isOpenDialog={isOpenSetLocationDialog}
|
||||||
setShowLocationModal={setIsOpenLocationDialog}
|
setShowLocationModal={setIsOpenLocationDialog}
|
||||||
/>
|
/>
|
||||||
|
{showRecordingsButtons && (
|
||||||
|
<ViewRecordingsDialog
|
||||||
|
booking={booking}
|
||||||
|
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||||
|
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||||
|
timeFormat={user?.timeFormat ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||||
<DialogContent>
|
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||||
<DialogHeader title={t("rejection_reason_title")} />
|
<div>
|
||||||
|
<TextAreaField
|
||||||
<p className="-mt-4 text-sm text-gray-500">{t("rejection_reason_description")}</p>
|
name="rejectionReason"
|
||||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
label={
|
||||||
{t("rejection_reason")}
|
<>
|
||||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
{t("rejection_reason")}
|
||||||
</p>
|
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||||
<TextArea
|
</>
|
||||||
name={t("rejection_reason")}
|
}
|
||||||
value={rejectionReason}
|
value={rejectionReason}
|
||||||
onChange={(e) => setRejectionReason(e.target.value)}
|
onChange={(e) => setRejectionReason(e.target.value)}
|
||||||
className="mb-5 sm:mb-6"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose>
|
<DialogClose />
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={mutation.isLoading}
|
disabled={mutation.isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -252,7 +272,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<tr className="flex flex-col hover:bg-neutral-50 sm:flex-row">
|
<tr className="group flex flex-col hover:bg-gray-50 sm:flex-row">
|
||||||
<td
|
<td
|
||||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||||
onClick={onClickTableData}>
|
onClick={onClickTableData}>
|
||||||
@@ -279,9 +299,9 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
{booking.eventType.team.name}
|
{booking.eventType.team.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
{booking.paid && (
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||||
{t("pending_payment")}
|
{t("paid")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{recurringDates !== undefined && (
|
{recurringDates !== undefined && (
|
||||||
@@ -335,19 +355,21 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
<div
|
<div
|
||||||
title={booking.title}
|
title={booking.title}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"max-w-10/12 sm:max-w-56 text-sm font-medium leading-6 text-neutral-900 md:max-w-full",
|
"max-w-10/12 sm:max-w-56 text-sm font-medium leading-6 text-gray-900 md:max-w-full",
|
||||||
isCancelled ? "line-through" : ""
|
isCancelled ? "line-through" : ""
|
||||||
)}>
|
)}>
|
||||||
{booking.title}
|
{booking.title}
|
||||||
<span> </span>
|
<span> </span>
|
||||||
|
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
<Badge className="hidden ltr:ml-2 ltr:mr-2 rtl:ml-2 sm:inline-flex" variant="orange">
|
||||||
|
{t("pending_payment")}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{booking.description && (
|
{booking.description && (
|
||||||
<div
|
<div
|
||||||
className="max-w-10/12 sm:max-w-40 md:max-w-56 xl:max-w-80 lg:max-w-64 truncate text-sm text-gray-600"
|
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 truncate text-sm text-gray-600"
|
||||||
title={booking.description}>
|
title={booking.description}>
|
||||||
"{booking.description}"
|
"{booking.description}"
|
||||||
</div>
|
</div>
|
||||||
@@ -375,6 +397,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||||
|
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||||
{isCancelled && booking.rescheduled && (
|
{isCancelled && booking.rescheduled && (
|
||||||
<div className="hidden h-full items-center md:flex">
|
<div className="hidden h-full items-center md:flex">
|
||||||
<RequestSentMessage />
|
<RequestSentMessage />
|
||||||
@@ -430,7 +453,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
|||||||
);
|
);
|
||||||
})}>
|
})}>
|
||||||
<div className="text-gray-600 dark:text-white">
|
<div className="text-gray-600 dark:text-white">
|
||||||
<Icon.FiRefreshCcw
|
<FiRefreshCcw
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
className="float-left mr-1 mt-1.5 inline-block h-3 w-3 text-gray-400"
|
className="float-left mr-1 mt-1.5 inline-block h-3 w-3 text-gray-400"
|
||||||
/>
|
/>
|
||||||
@@ -530,13 +553,4 @@ const DisplayAttendees = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BookingListItem;
|
export default BookingListItem;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import { Button, Icon, TextArea } from "@calcom/ui";
|
import { Button, TextArea } from "@calcom/ui";
|
||||||
|
import { FiX } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
booking: {
|
booking: {
|
||||||
@@ -28,7 +29,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { booking, profile, team, allRemainingBookings } = props;
|
const { booking, allRemainingBookings } = props;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||||
@@ -39,7 +40,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
<Icon.FiX className="h-6 w-6 text-red-600" />
|
<FiX className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
<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" id="modal-title">
|
||||||
@@ -55,7 +56,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
placeholder={t("cancellation_reason_placeholder")}
|
placeholder={t("cancellation_reason_placeholder")}
|
||||||
value={cancellationReason}
|
value={cancellationReason}
|
||||||
onChange={(e) => setCancellationReason(e.target.value)}
|
onChange={(e) => setCancellationReason(e.target.value)}
|
||||||
className="mt-2 mb-4 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white "
|
className="dark:bg-darkgray-100 dark:border-darkgray-400 mt-2 mb-4 w-full dark:text-white "
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { SkeletonText } from "@calcom/ui";
|
|||||||
|
|
||||||
function SkeletonLoader() {
|
function SkeletonLoader() {
|
||||||
return (
|
return (
|
||||||
<ul className="animate-pulse divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:overflow-hidden">
|
<ul className="animate-pulse divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:overflow-hidden">
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
|
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
|
||||||
|
|
||||||
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
|
||||||
import { timeZone } from "../../lib/clock";
|
import { timeZone } from "../../lib/clock";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -11,7 +13,8 @@ type Props = {
|
|||||||
|
|
||||||
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
const { t } = useLocale();
|
const query = useMeQuery();
|
||||||
|
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTimeZone(timeZone());
|
setSelectedTimeZone(timeZone());
|
||||||
@@ -23,18 +26,60 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
|||||||
}
|
}
|
||||||
}, [selectedTimeZone, onSelectTimeZone]);
|
}, [selectedTimeZone, onSelectTimeZone]);
|
||||||
|
|
||||||
return selectedTimeZone !== "" ? (
|
const customStyles = {
|
||||||
<div className="dark:border-darkgray-300 dark:bg-darkgray-200 rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
|
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
|
||||||
<div className="mb-4 flex">
|
padding: "10px 12px !important",
|
||||||
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
|
||||||
</div>
|
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
|
||||||
<TimezoneSelect
|
...(userTheme === "dark" && {
|
||||||
id="timeZone"
|
color: state.isSelected || state.isFocused ? "white" : "#80868B",
|
||||||
value={selectedTimeZone}
|
}),
|
||||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
}),
|
||||||
className="focus:border-brand mt-1 mb-2 block w-full rounded-md border-gray-300 text-sm focus:ring-black"
|
control: () => ({
|
||||||
/>
|
display: "flex",
|
||||||
</div>
|
cursor: "pointer",
|
||||||
|
backgroundColor: "transparent !important",
|
||||||
|
minWidth: "5rem",
|
||||||
|
height: "24px",
|
||||||
|
minHeight: "24px !important",
|
||||||
|
boxShadow: "none !important",
|
||||||
|
}),
|
||||||
|
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||||
|
...provided,
|
||||||
|
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||||
|
...(userTheme === "dark" && {
|
||||||
|
color: "#a5a5a5 !important",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
menu: (provided: object) => ({
|
||||||
|
...provided,
|
||||||
|
minWidth: "20rem",
|
||||||
|
margin: "8px 0 0 -20px",
|
||||||
|
}),
|
||||||
|
valueContainer: (provided: object) => ({
|
||||||
|
...provided,
|
||||||
|
padding: "0 0 0 6px",
|
||||||
|
}),
|
||||||
|
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||||
|
...provided,
|
||||||
|
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
|
||||||
|
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||||
|
marginLeft: 4,
|
||||||
|
padding: 0,
|
||||||
|
...(userTheme === "dark" && {
|
||||||
|
color: "#80868B",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return !!selectedTimeZone ? (
|
||||||
|
<TimezoneSelect
|
||||||
|
id="timeZone"
|
||||||
|
value={selectedTimeZone}
|
||||||
|
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||||
|
className="flex h-6 text-sm font-medium"
|
||||||
|
styles={customStyles}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
|
import { AvatarGroup, AvatarGroupProps } from "@calcom/ui";
|
||||||
import AvatarGroup, { AvatarGroupProps } from "@components/ui/AvatarGroup";
|
|
||||||
|
|
||||||
export const UserAvatars = ({
|
export const UserAvatars = ({
|
||||||
profile,
|
profile,
|
||||||
@@ -14,7 +13,6 @@ export const UserAvatars = ({
|
|||||||
const showMembers = !users.find((user) => user.name === profile.name) && props.showMembers;
|
const showMembers = !users.find((user) => user.name === profile.name) && props.showMembers;
|
||||||
return (
|
return (
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
border="border-2 dark:border-darkgray-100 border-white"
|
|
||||||
items={
|
items={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -38,7 +36,7 @@ export const UserAvatars = ({
|
|||||||
href?: string;
|
href?: string;
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
size={props.size}
|
size="sm"
|
||||||
truncateAfter={props.truncateAfter}
|
truncateAfter={props.truncateAfter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { EventType } from "@prisma/client";
|
import { EventType } from "@prisma/client";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import { TFunction } from "next-i18next";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -14,55 +12,38 @@ import dayjs, { Dayjs } from "@calcom/dayjs";
|
|||||||
import {
|
import {
|
||||||
useEmbedNonStylesConfig,
|
useEmbedNonStylesConfig,
|
||||||
useEmbedStyles,
|
useEmbedStyles,
|
||||||
|
useEmbedUiConfig,
|
||||||
useIsBackgroundTransparent,
|
useIsBackgroundTransparent,
|
||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
|
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import notEmpty from "@calcom/lib/notEmpty";
|
import notEmpty from "@calcom/lib/notEmpty";
|
||||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon, DatePicker } from "@calcom/ui";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
|
import { FiCreditCard, FiGlobe, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||||
// import { timeZone } from "@lib/clock";
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
|
||||||
|
|
||||||
import Gates, { Gate, GateState } from "@components/Gates";
|
import Gates, { Gate, GateState } from "@components/Gates";
|
||||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||||
import BookingDescription from "@components/booking/BookingDescription";
|
import BookingDescription from "@components/booking/BookingDescription";
|
||||||
import TimeOptions from "@components/booking/TimeOptions";
|
import TimeOptions from "@components/booking/TimeOptions";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
|
||||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||||
|
|
||||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||||
|
|
||||||
// Get router variables
|
|
||||||
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const path = router.asPath.split("/");
|
|
||||||
path.pop(); // Remove the last item (where we currently are)
|
|
||||||
path.shift(); // Removes first item e.g. if we were visitng "/teams/test/30mins" the array will new look like ["teams","test"]
|
|
||||||
const slug = path.join("/");
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col justify-end">
|
|
||||||
<button title={t("profile")} onClick={() => router.replace(`${WEBSITE_URL}/${slug}`)}>
|
|
||||||
<Icon.FiArrowLeft className="dark:text-darkgray-600 h-4 w-4 text-black transition-opacity hover:cursor-pointer" />
|
|
||||||
<p className="sr-only">Go Back</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const useSlots = ({
|
const useSlots = ({
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
eventTypeSlug,
|
eventTypeSlug,
|
||||||
@@ -70,6 +51,8 @@ const useSlots = ({
|
|||||||
endTime,
|
endTime,
|
||||||
usernameList,
|
usernameList,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
eventTypeSlug: string;
|
eventTypeSlug: string;
|
||||||
@@ -77,6 +60,8 @@ const useSlots = ({
|
|||||||
endTime?: Dayjs;
|
endTime?: Dayjs;
|
||||||
usernameList: string[];
|
usernameList: string[];
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
|
duration?: string;
|
||||||
|
enabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||||
{
|
{
|
||||||
@@ -86,21 +71,17 @@ const useSlots = ({
|
|||||||
startTime: startTime?.toISOString() || "",
|
startTime: startTime?.toISOString() || "",
|
||||||
endTime: endTime?.toISOString() || "",
|
endTime: endTime?.toISOString() || "",
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!startTime && !!endTime,
|
enabled: !!startTime && !!endTime && enabled,
|
||||||
|
refetchInterval: 3000,
|
||||||
|
trpc: { context: { skipBatch: true } },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.slots) {
|
|
||||||
setCachedSlots((c) => ({ ...c, ...data?.slots }));
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
|
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
|
||||||
return { slots: cachedSlots, isLoading: isLoading || isPaused };
|
return { slots: data?.slots || {}, isLoading: isLoading || isPaused };
|
||||||
};
|
};
|
||||||
|
|
||||||
const SlotPicker = ({
|
const SlotPicker = ({
|
||||||
@@ -114,7 +95,10 @@ const SlotPicker = ({
|
|||||||
weekStart = 0,
|
weekStart = 0,
|
||||||
ethSignature,
|
ethSignature,
|
||||||
}: {
|
}: {
|
||||||
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
|
eventType: Pick<
|
||||||
|
EventType & { metadata: z.infer<typeof EventTypeMetaDataSchema> },
|
||||||
|
"id" | "schedulingType" | "slug" | "length" | "metadata"
|
||||||
|
>;
|
||||||
timeFormat: TimeFormat;
|
timeFormat: TimeFormat;
|
||||||
onTimeFormatChange: (is24Hour: boolean) => void;
|
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
@@ -126,10 +110,15 @@ const SlotPicker = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||||
|
let { duration = eventType.length.toString() } = useRouterQuery("duration");
|
||||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (!eventType.metadata?.multipleDuration) {
|
||||||
|
duration = eventType.length.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,27 +140,40 @@ const SlotPicker = ({
|
|||||||
setSelectedDate(dayjs.tz(date, timeZone));
|
setSelectedDate(dayjs.tz(date, timeZone));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [router.isReady, month, date, timeZone]);
|
}, [router.isReady, month, date, duration, timeZone]);
|
||||||
|
|
||||||
const { i18n, isLocaleReady } = useLocale();
|
const { i18n, isLocaleReady } = useLocale();
|
||||||
const { slots: _1 } = useSlots({
|
const { slots: monthSlots, isLoading } = useSlots({
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
eventTypeSlug: eventType.slug,
|
||||||
|
usernameList: users,
|
||||||
|
startTime:
|
||||||
|
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
|
||||||
|
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
|
||||||
|
: browsingDate?.startOf("month"),
|
||||||
|
endTime: browsingDate?.endOf("month"),
|
||||||
|
timeZone,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
const { slots: selectedDateSlots, isLoading: _isLoadingSelectedDateSlots } = useSlots({
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
usernameList: users,
|
usernameList: users,
|
||||||
startTime: selectedDate?.startOf("day"),
|
startTime: selectedDate?.startOf("day"),
|
||||||
endTime: selectedDate?.endOf("day"),
|
endTime: selectedDate?.endOf("day"),
|
||||||
timeZone,
|
timeZone,
|
||||||
});
|
duration,
|
||||||
const { slots: _2, isLoading } = useSlots({
|
/** Prevent refetching is we already have this data from month slots */
|
||||||
eventTypeId: eventType.id,
|
enabled: !!selectedDate,
|
||||||
eventTypeSlug: eventType.slug,
|
|
||||||
usernameList: users,
|
|
||||||
startTime: browsingDate?.startOf("month"),
|
|
||||||
endTime: browsingDate?.endOf("month"),
|
|
||||||
timeZone,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
/** Hide skeleton if we have the slot loaded in the month query */
|
||||||
|
const isLoadingSelectedDateSlots = (() => {
|
||||||
|
if (!selectedDate) return _isLoadingSelectedDateSlots;
|
||||||
|
if (!!selectedDateSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||||
|
if (!!monthSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -181,7 +183,7 @@ const SlotPicker = ({
|
|||||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
||||||
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||||
)}
|
)}
|
||||||
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
includedDates={Object.keys(monthSlots).filter((k) => monthSlots[k].length > 0)}
|
||||||
locale={isLocaleReady ? i18n.language : "en"}
|
locale={isLocaleReady ? i18n.language : "en"}
|
||||||
selected={selectedDate}
|
selected={selectedDate}
|
||||||
onChange={(newDate) => {
|
onChange={(newDate) => {
|
||||||
@@ -196,8 +198,12 @@ const SlotPicker = ({
|
|||||||
|
|
||||||
<div ref={slotPickerRef}>
|
<div ref={slotPickerRef}>
|
||||||
<AvailableTimes
|
<AvailableTimes
|
||||||
isLoading={isLoading}
|
isLoading={isLoadingSelectedDateSlots}
|
||||||
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
|
slots={
|
||||||
|
selectedDate &&
|
||||||
|
(selectedDateSlots[selectedDate.format("YYYY-MM-DD")] ||
|
||||||
|
monthSlots[selectedDate.format("YYYY-MM-DD")])
|
||||||
|
}
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
onTimeFormatChange={onTimeFormatChange}
|
onTimeFormatChange={onTimeFormatChange}
|
||||||
@@ -214,41 +220,22 @@ const SlotPicker = ({
|
|||||||
|
|
||||||
function TimezoneDropdown({
|
function TimezoneDropdown({
|
||||||
onChangeTimeZone,
|
onChangeTimeZone,
|
||||||
timeZone,
|
|
||||||
}: {
|
}: {
|
||||||
onChangeTimeZone: (newTimeZone: string) => void;
|
onChangeTimeZone: (newTimeZone: string) => void;
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||||
onChangeTimeZone(newTimeZone);
|
onChangeTimeZone(newTimeZone);
|
||||||
localStorageTimeZone(newTimeZone);
|
localStorageTimeZone(newTimeZone);
|
||||||
setIsTimeOptionsOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
<>
|
||||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 inline-block rounded-md px-2 py-2 text-left text-gray-600">
|
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
|
||||||
<p className="text-sm font-medium">
|
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
|
||||||
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||||
{timeZone}
|
</div>
|
||||||
{isTimeOptionsOpen ? (
|
</>
|
||||||
<Icon.FiChevronUp className="ml-1 inline-block h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
hideWhenDetached
|
|
||||||
align="start"
|
|
||||||
className="animate-fade-in-up absolute left-0 top-2 w-80 max-w-[calc(100vw_-_1.5rem)]">
|
|
||||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,23 +245,6 @@ const dateQuerySchema = z.object({
|
|||||||
timeZone: z.string().optional().default(""),
|
timeZone: z.string().optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const useRouterQuery = <T extends string>(name: T) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const existingQueryParams = router.asPath.split("?")[1];
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(existingQueryParams);
|
|
||||||
const query = Object.fromEntries(urlParams);
|
|
||||||
|
|
||||||
const setQuery = (newValue: string | number | null | undefined) => {
|
|
||||||
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
|
||||||
router.replace({ query: { ...router.query, ...query, [name]: newValue } }, undefined, { shallow: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return { [name]: query[name], setQuery } as {
|
|
||||||
[K in T]: string | undefined;
|
|
||||||
} & { setQuery: typeof setQuery };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||||
|
|
||||||
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||||
@@ -285,6 +255,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
useTheme(profile.theme);
|
useTheme(profile.theme);
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||||
|
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
|
||||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||||
@@ -309,9 +280,6 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// TODO: Improve this;
|
|
||||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
|
||||||
|
|
||||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
@@ -324,7 +292,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [telemetry]);
|
}, [telemetry]);
|
||||||
|
const embedUiConfig = useEmbedUiConfig();
|
||||||
// get dynamic user list here
|
// get dynamic user list here
|
||||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||||
|
|
||||||
@@ -332,11 +300,13 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
||||||
[timeZone]
|
[timeZone]
|
||||||
);
|
);
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
const paymentAppData = getPaymentAppData(eventType);
|
||||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||||
|
|
||||||
|
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||||
|
|
||||||
// Define conditional gates here
|
// Define conditional gates here
|
||||||
const gates = [
|
const gates = [
|
||||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||||
@@ -370,7 +340,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
<div>
|
<div>
|
||||||
<main
|
<main
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex-col md:mx-4 lg:flex",
|
"flex flex-col md:mx-4",
|
||||||
shouldAlignCentrally ? "items-center" : "items-start",
|
shouldAlignCentrally ? "items-center" : "items-start",
|
||||||
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||||
)}>
|
)}>
|
||||||
@@ -381,64 +351,59 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
isBackgroundTransparent
|
isBackgroundTransparent
|
||||||
? ""
|
? ""
|
||||||
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||||
"border-bookinglightest overflow-hidden md:rounded-md md:border",
|
"border-bookinglightest md:rounded-md md:border",
|
||||||
isEmbed && "mx-auto"
|
isEmbed && "mx-auto"
|
||||||
)}>
|
)}>
|
||||||
<div className="overflow-hidden md:flex">
|
<div className="md:flex">
|
||||||
<div
|
{showEventTypeDetails && (
|
||||||
className={classNames(
|
<div
|
||||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
className={classNames(
|
||||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||||
)}>
|
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
)}>
|
||||||
{!rescheduleUid && eventType.recurringEvent && (
|
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||||
<div className="flex items-start text-sm font-medium">
|
{!rescheduleUid && eventType.recurringEvent && (
|
||||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
<div className="flex items-start text-sm font-medium">
|
||||||
<div>
|
<FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
<div>
|
||||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
</p>
|
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||||
<input
|
</p>
|
||||||
type="number"
|
<input
|
||||||
min="1"
|
type="number"
|
||||||
max={eventType.recurringEvent.count}
|
min="1"
|
||||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
max={eventType.recurringEvent.count}
|
||||||
defaultValue={eventType.recurringEvent.count}
|
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||||
onChange={(event) => {
|
defaultValue={eventType.recurringEvent.count}
|
||||||
setRecurringEventCount(parseInt(event?.target.value));
|
onChange={(event) => {
|
||||||
}}
|
setRecurringEventCount(parseInt(event?.target.value));
|
||||||
/>
|
}}
|
||||||
<p className="inline">
|
/>
|
||||||
{t("occurrence", {
|
<p className="inline">
|
||||||
count: recurringEventCount,
|
{t("occurrence", {
|
||||||
})}
|
count: recurringEventCount,
|
||||||
</p>
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{paymentAppData.price > 0 && (
|
||||||
{stripeAppData.price > 0 && (
|
<p className="-ml-2 px-2 text-sm font-medium">
|
||||||
<p className="-ml-2 px-2 text-sm font-medium">
|
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<IntlProvider locale="en">
|
||||||
<IntlProvider locale="en">
|
<FormattedNumber
|
||||||
<FormattedNumber
|
value={paymentAppData.price / 100.0}
|
||||||
value={stripeAppData.price / 100.0}
|
style="currency"
|
||||||
style="currency"
|
currency={paymentAppData.currency?.toUpperCase()}
|
||||||
currency={stripeAppData.currency.toUpperCase()}
|
/>
|
||||||
/>
|
</IntlProvider>
|
||||||
</IntlProvider>
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
{timezoneDropdown}
|
||||||
{timezoneDropdown}
|
</BookingDescription>
|
||||||
</BookingDescription>
|
|
||||||
|
|
||||||
{!isEmbed && (
|
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||||
<div className="mt-auto hidden md:block">
|
|
||||||
<GoBackToPreviousPage t={t} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||||
@@ -446,12 +411,13 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
{t("former_time")}
|
{t("former_time")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 line-through dark:text-darkgray-600">
|
<p className="text-gray-500 line-through dark:text-darkgray-600">
|
||||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
||||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)*/}
|
)*/}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<SlotPicker
|
<SlotPicker
|
||||||
weekStart={
|
weekStart={
|
||||||
typeof profile.weekStart === "string"
|
typeof profile.weekStart === "string"
|
||||||
@@ -477,7 +443,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
{/* FIXME: We don't show branding in Embed yet because we need to place branding on top of the main content. Keeping it outside the main content would have visibility issues because outside main content background is transparent */}
|
||||||
|
{!restProps.isBrandingHidden && !isEmbed && <PoweredByCal />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { useSession } from "next-auth/react";
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { ReactMultiEmail } from "react-multi-email";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -26,27 +25,49 @@ import { LocationObject, LocationType } from "@calcom/core/location";
|
|||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import {
|
import {
|
||||||
useEmbedNonStylesConfig,
|
useEmbedNonStylesConfig,
|
||||||
|
useEmbedUiConfig,
|
||||||
useIsBackgroundTransparent,
|
useIsBackgroundTransparent,
|
||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { AddressInput, Button, EmailInput, Form, Icon, PhoneInput, Tooltip } from "@calcom/ui";
|
import {
|
||||||
|
AddressInput,
|
||||||
|
Button,
|
||||||
|
EmailField,
|
||||||
|
EmailInput,
|
||||||
|
Form,
|
||||||
|
Group,
|
||||||
|
PhoneInput,
|
||||||
|
RadioField,
|
||||||
|
Tooltip,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
import {
|
||||||
|
FiUserPlus,
|
||||||
|
FiCalendar,
|
||||||
|
FiX,
|
||||||
|
FiInfo,
|
||||||
|
FiCreditCard,
|
||||||
|
FiRefreshCw,
|
||||||
|
FiUser,
|
||||||
|
FiAlertTriangle,
|
||||||
|
} from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { ensureArray } from "@lib/ensureArray";
|
import { ensureArray } from "@lib/ensureArray";
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||||
import slugify from "@lib/slugify";
|
|
||||||
|
|
||||||
import Gates, { Gate, GateState } from "@components/Gates";
|
import Gates, { Gate, GateState } from "@components/Gates";
|
||||||
import BookingDescription from "@components/booking/BookingDescription";
|
import BookingDescription from "@components/booking/BookingDescription";
|
||||||
@@ -62,7 +83,7 @@ type BookingFormValues = {
|
|||||||
email: string;
|
email: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
locationType?: EventLocationType["type"];
|
locationType?: EventLocationType["type"];
|
||||||
guests?: string[];
|
guests?: { email: string }[];
|
||||||
address?: string;
|
address?: string;
|
||||||
attendeeAddress?: string;
|
attendeeAddress?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -85,10 +106,9 @@ const BookingPage = ({
|
|||||||
...restProps
|
...restProps
|
||||||
}: BookingPageProps) => {
|
}: BookingPageProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
const { duration: queryDuration } = useRouterQuery("duration");
|
||||||
const query = useMeQuery();
|
|
||||||
const user = query.data;
|
|
||||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||||
|
const embedUiConfig = useEmbedUiConfig();
|
||||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -102,7 +122,17 @@ const BookingPage = ({
|
|||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
const paymentAppData = getPaymentAppData(eventType);
|
||||||
|
// Define duration now that we support multiple duration eventTypes
|
||||||
|
let duration = eventType.length;
|
||||||
|
if (
|
||||||
|
queryDuration &&
|
||||||
|
!isNaN(Number(queryDuration)) &&
|
||||||
|
eventType.metadata?.multipleDuration &&
|
||||||
|
eventType.metadata?.multipleDuration.includes(Number(queryDuration))
|
||||||
|
) {
|
||||||
|
duration = Number(queryDuration);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (top !== window) {
|
if (top !== window) {
|
||||||
@@ -118,6 +148,7 @@ const BookingPage = ({
|
|||||||
const mutation = useMutation(createBooking, {
|
const mutation = useMutation(createBooking, {
|
||||||
onSuccess: async (responseData) => {
|
onSuccess: async (responseData) => {
|
||||||
const { uid, paymentUid } = responseData;
|
const { uid, paymentUid } = responseData;
|
||||||
|
|
||||||
if (paymentUid) {
|
if (paymentUid) {
|
||||||
return await router.push(
|
return await router.push(
|
||||||
createPaymentLink({
|
createPaymentLink({
|
||||||
@@ -131,12 +162,12 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return router.push({
|
return router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${uid}`,
|
||||||
query: {
|
query: {
|
||||||
uid,
|
|
||||||
isSuccessBookingPage: true,
|
isSuccessBookingPage: true,
|
||||||
email: bookingForm.getValues("email"),
|
email: bookingForm.getValues("email"),
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
|
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -147,12 +178,12 @@ const BookingPage = ({
|
|||||||
const { uid } = responseData[0] || {};
|
const { uid } = responseData[0] || {};
|
||||||
|
|
||||||
return router.push({
|
return router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${uid}`,
|
||||||
query: {
|
query: {
|
||||||
uid,
|
|
||||||
allRemainingBookings: true,
|
allRemainingBookings: true,
|
||||||
email: bookingForm.getValues("email"),
|
email: bookingForm.getValues("email"),
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
|
formerTime: booking?.startTime.toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -177,7 +208,9 @@ const BookingPage = ({
|
|||||||
|
|
||||||
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
||||||
const guestListEmails = !isDynamicGroupBooking
|
const guestListEmails = !isDynamicGroupBooking
|
||||||
? booking?.attendees.slice(1).map((attendee) => attendee.email)
|
? booking?.attendees.slice(1).map((attendee) => {
|
||||||
|
return { email: attendee.email };
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// There should only exists one default userData variable for primaryAttendee.
|
// There should only exists one default userData variable for primaryAttendee.
|
||||||
@@ -196,7 +229,9 @@ const BookingPage = ({
|
|||||||
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
||||||
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
||||||
notes: (router.query.notes as string) || "",
|
notes: (router.query.notes as string) || "",
|
||||||
guests: ensureArray(router.query.guest) as string[],
|
guests: ensureArray(router.query.guest).map((guest) => {
|
||||||
|
return { email: guest as string };
|
||||||
|
}),
|
||||||
customInputs: eventType.customInputs.reduce(
|
customInputs: eventType.customInputs.reduce(
|
||||||
(customInputs, input) => ({
|
(customInputs, input) => ({
|
||||||
...customInputs,
|
...customInputs,
|
||||||
@@ -237,7 +272,8 @@ const BookingPage = ({
|
|||||||
const bookingFormSchema = z
|
const bookingFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().trim().email(),
|
||||||
|
guests: z.array(z.object({ email: z.string().email() })).optional(),
|
||||||
phone: z
|
phone: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
@@ -256,6 +292,10 @@ const BookingPage = ({
|
|||||||
defaultValues: defaultValues(),
|
defaultValues: defaultValues(),
|
||||||
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||||
});
|
});
|
||||||
|
const guestsField = useFieldArray({
|
||||||
|
name: "guests",
|
||||||
|
control: bookingForm.control,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedLocationType = useWatch({
|
const selectedLocationType = useWatch({
|
||||||
control: bookingForm.control,
|
control: bookingForm.control,
|
||||||
@@ -294,6 +334,40 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookEvent = (booking: BookingFormValues) => {
|
const bookEvent = (booking: BookingFormValues) => {
|
||||||
|
bookingForm.clearErrors();
|
||||||
|
const bookingCustomInputs = Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||||
|
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||||
|
value: booking.customInputs && booking.customInputs[inputId] ? booking.customInputs[inputId] : "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Checking if custom inputs of type Phone number are valid to display error message on UI
|
||||||
|
if (eventType.customInputs.length) {
|
||||||
|
let isErrorFound = false;
|
||||||
|
eventType.customInputs.forEach((customInput) => {
|
||||||
|
if (customInput.required && customInput.type === EventTypeCustomInputType.PHONE) {
|
||||||
|
const input = bookingCustomInputs.find((i) => i.label === customInput.label);
|
||||||
|
try {
|
||||||
|
z.string({
|
||||||
|
errorMap: () => ({
|
||||||
|
message: `Missing ${customInput.type} customInput: '${customInput.label}'`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((val) => isValidPhoneNumber(val), {
|
||||||
|
message: "Phone number is invalid",
|
||||||
|
})
|
||||||
|
.parse(input?.value);
|
||||||
|
} catch (err) {
|
||||||
|
isErrorFound = true;
|
||||||
|
bookingForm.setError(`customInputs.${customInput.id}`, {
|
||||||
|
type: "custom",
|
||||||
|
message: "Invalid Phone number",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (isErrorFound) return;
|
||||||
|
}
|
||||||
|
|
||||||
telemetry.event(
|
telemetry.event(
|
||||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||||
{ isTeamBooking: document.URL.includes("team/") }
|
{ isTeamBooking: document.URL.includes("team/") }
|
||||||
@@ -312,13 +386,52 @@ const BookingPage = ({
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (eventType.customInputs.length > 0) {
|
||||||
|
// find all required custom inputs and ensure they are filled out in the booking form
|
||||||
|
const requiredCustomInputs = eventType.customInputs.filter((input) => input.required);
|
||||||
|
const missingRequiredCustomInputs = requiredCustomInputs.filter(
|
||||||
|
(input) => !booking?.customInputs?.[input.id]
|
||||||
|
);
|
||||||
|
if (missingRequiredCustomInputs.length > 0) {
|
||||||
|
missingRequiredCustomInputs.forEach((input) => {
|
||||||
|
bookingForm.setError(`customInputs.${input.id}`, {
|
||||||
|
type: "required",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that guests are unique
|
||||||
|
let alreadyInvited = false;
|
||||||
|
booking.guests?.forEach((guest, index) => {
|
||||||
|
if (guest.email === booking.email) {
|
||||||
|
bookingForm.setError(`guests.${index}`, { type: "validate", message: t("already_invited") });
|
||||||
|
alreadyInvited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.guests) {
|
||||||
|
let guestCount = 0;
|
||||||
|
for (const checkGuest of booking.guests) {
|
||||||
|
if (checkGuest.email === guest.email) guestCount++;
|
||||||
|
if (guestCount > 1) {
|
||||||
|
bookingForm.setError(`guests.${index}`, { type: "validate", message: t("already_invited") });
|
||||||
|
alreadyInvited = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alreadyInvited) return;
|
||||||
|
|
||||||
if (recurringDates.length) {
|
if (recurringDates.length) {
|
||||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||||
const recurringEventId = uuidv4();
|
const recurringEventId = uuidv4();
|
||||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||||
...booking,
|
...booking,
|
||||||
start: dayjs(recurringDate).format(),
|
start: dayjs(recurringDate).format(),
|
||||||
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
end: dayjs(recurringDate).add(duration, "minute").format(),
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
recurringEventId,
|
recurringEventId,
|
||||||
@@ -334,10 +447,7 @@ const BookingPage = ({
|
|||||||
attendeeAddress: booking.attendeeAddress,
|
attendeeAddress: booking.attendeeAddress,
|
||||||
}),
|
}),
|
||||||
metadata,
|
metadata,
|
||||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
customInputs: bookingCustomInputs,
|
||||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
|
||||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
|
||||||
})),
|
|
||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
smsReminderNumber:
|
smsReminderNumber:
|
||||||
@@ -345,13 +455,14 @@ const BookingPage = ({
|
|||||||
? booking.phone
|
? booking.phone
|
||||||
: booking.smsReminderNumber || undefined,
|
: booking.smsReminderNumber || undefined,
|
||||||
ethSignature: gateState.rainbowToken,
|
ethSignature: gateState.rainbowToken,
|
||||||
|
guests: booking.guests?.map((guest) => guest.email),
|
||||||
}));
|
}));
|
||||||
recurringMutation.mutate(recurringBookings);
|
recurringMutation.mutate(recurringBookings);
|
||||||
} else {
|
} else {
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
...booking,
|
...booking,
|
||||||
start: dayjs(date).format(),
|
start: dayjs(date).format(),
|
||||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
end: dayjs(date).add(duration, "minute").format(),
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
timeZone: timeZone(),
|
timeZone: timeZone(),
|
||||||
@@ -365,10 +476,7 @@ const BookingPage = ({
|
|||||||
attendeeAddress: booking.attendeeAddress,
|
attendeeAddress: booking.attendeeAddress,
|
||||||
}),
|
}),
|
||||||
metadata,
|
metadata,
|
||||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
customInputs: bookingCustomInputs,
|
||||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
|
||||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
|
||||||
})),
|
|
||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
smsReminderNumber:
|
smsReminderNumber:
|
||||||
@@ -376,6 +484,7 @@ const BookingPage = ({
|
|||||||
? booking.phone
|
? booking.phone
|
||||||
: booking.smsReminderNumber || undefined,
|
: booking.smsReminderNumber || undefined,
|
||||||
ethSignature: gateState.rainbowToken,
|
ethSignature: gateState.rainbowToken,
|
||||||
|
guests: booking.guests?.map((guest) => guest.email),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -403,6 +512,7 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||||
|
|
||||||
// Define conditional gates here
|
// Define conditional gates here
|
||||||
@@ -426,9 +536,9 @@ const BookingPage = ({
|
|||||||
eventTypeTitle: eventType.title,
|
eventTypeTitle: eventType.title,
|
||||||
profileName: profile.name,
|
profileName: profile.name,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
| Cal.com
|
| {APP_NAME}
|
||||||
</title>
|
</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favico.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<BookingPageTagManager eventType={eventType} />
|
<BookingPageTagManager eventType={eventType} />
|
||||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||||
@@ -436,104 +546,106 @@ const BookingPage = ({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
shouldAlignCentrally ? "mx-auto" : "",
|
shouldAlignCentrally ? "mx-auto" : "",
|
||||||
isEmbed ? "" : "sm:my-24",
|
isEmbed ? "" : "sm:my-24",
|
||||||
"my-0 max-w-3xl "
|
"my-0 max-w-3xl"
|
||||||
)}>
|
)}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"main overflow-hidden",
|
"main overflow-hidden",
|
||||||
isBackgroundTransparent ? "" : "dark:border-1 dark:bg-darkgray-100 bg-white",
|
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 bg-white dark:border",
|
||||||
"dark:border-darkgray-300 rounded-md sm:border"
|
"dark:border-darkgray-300 rounded-md sm:border"
|
||||||
)}>
|
)}>
|
||||||
<div className="sm:flex">
|
<div className="sm:flex">
|
||||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
{showEventTypeDetails && (
|
||||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||||
{stripeAppData.price > 0 && (
|
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
{paymentAppData.price > 0 && (
|
||||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||||
<IntlProvider locale="en">
|
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
<FormattedNumber
|
<IntlProvider locale="en">
|
||||||
value={stripeAppData.price / 100.0}
|
<FormattedNumber
|
||||||
style="currency"
|
value={paymentAppData.price / 100.0}
|
||||||
currency={stripeAppData.currency.toUpperCase()}
|
style="currency"
|
||||||
/>
|
currency={paymentAppData?.currency?.toUpperCase()}
|
||||||
</IntlProvider>
|
/>
|
||||||
</p>
|
</IntlProvider>
|
||||||
)}
|
|
||||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
|
||||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
|
||||||
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
|
||||||
<p className="-ml-2 inline-block items-center px-2">
|
|
||||||
{getEveryFreqFor({
|
|
||||||
t,
|
|
||||||
recurringEvent: eventType.recurringEvent,
|
|
||||||
recurringCount: recurringEventCount,
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||||
<div className="text-bookinghighlight flex items-start text-sm">
|
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
<FiRefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
<div className="text-sm font-medium">
|
<p className="-ml-2 inline-block items-center px-2">
|
||||||
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
{getEveryFreqFor({
|
||||||
{!rescheduleUid &&
|
t,
|
||||||
eventType.recurringEvent?.freq &&
|
recurringEvent: eventType.recurringEvent,
|
||||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
recurringCount: recurringEventCount,
|
||||||
return <p key={key}>{timeFormatted}</p>;
|
})}
|
||||||
})}
|
</p>
|
||||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
</div>
|
||||||
<div className="flex">
|
)}
|
||||||
<Tooltip
|
|
||||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
|
||||||
<p key={key}>{timeFormatted}</p>
|
|
||||||
))}>
|
|
||||||
<p className="dark:text-darkgray-600 text-sm">
|
|
||||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
|
||||||
</p>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{booking?.startTime && rescheduleUid && (
|
|
||||||
<div>
|
|
||||||
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
|
||||||
{t("former_time")}
|
|
||||||
</p>
|
|
||||||
<p className="line-through ">
|
|
||||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
|
||||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!!eventType.seatsPerTimeSlot && (
|
|
||||||
<div className="text-bookinghighlight flex items-start text-sm">
|
<div className="text-bookinghighlight flex items-start text-sm">
|
||||||
<Icon.FiUser
|
<FiCalendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
<div className="text-sm font-medium">
|
||||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
||||||
? "text-rose-600"
|
{!rescheduleUid &&
|
||||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
eventType.recurringEvent?.freq &&
|
||||||
? "text-yellow-500"
|
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||||
: "text-bookinghighlight"
|
return <p key={key}>{timeFormatted}</p>;
|
||||||
}`}
|
})}
|
||||||
/>
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||||
<p
|
<div className="flex">
|
||||||
className={`${
|
<Tooltip
|
||||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||||
? "text-rose-600"
|
<p key={key}>{timeFormatted}</p>
|
||||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
))}>
|
||||||
? "text-yellow-500"
|
<p className="dark:text-darkgray-600 text-sm">
|
||||||
: "text-bookinghighlight"
|
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||||
} mb-2 font-medium`}>
|
</p>
|
||||||
{booking
|
</Tooltip>
|
||||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
</div>
|
||||||
: eventType.seatsPerTimeSlot}{" "}
|
)}
|
||||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{booking?.startTime && rescheduleUid && (
|
||||||
</BookingDescription>
|
<div>
|
||||||
</div>
|
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
||||||
<div className="p-6 sm:w-1/2">
|
{t("former_time")}
|
||||||
|
</p>
|
||||||
|
<p className="line-through ">
|
||||||
|
<FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
|
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!eventType.seatsPerTimeSlot && (
|
||||||
|
<div className="text-bookinghighlight flex items-start text-sm">
|
||||||
|
<FiUser
|
||||||
|
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
|
||||||
|
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||||
|
? "text-rose-600"
|
||||||
|
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-bookinghighlight"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={`${
|
||||||
|
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||||
|
? "text-rose-600"
|
||||||
|
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-bookinghighlight"
|
||||||
|
} mb-2 font-medium`}>
|
||||||
|
{booking
|
||||||
|
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||||
|
: eventType.seatsPerTimeSlot}{" "}
|
||||||
|
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</BookingDescription>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={classNames("p-6", showEventTypeDetails ? "sm:w-1/2" : "w-full")}>
|
||||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
@@ -570,7 +682,7 @@ const BookingPage = ({
|
|||||||
/>
|
/>
|
||||||
{bookingForm.formState.errors.email && (
|
{bookingForm.formState.errors.email && (
|
||||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
<p>{t("email_validation_error")}</p>
|
<p>{t("email_validation_error")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -611,8 +723,8 @@ const BookingPage = ({
|
|||||||
value={location.type}
|
value={location.type}
|
||||||
defaultChecked={i === 0}
|
defaultChecked={i === 0}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
|
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
|
||||||
{locationKeyToString(location)}
|
{t(locationKeyToString(location) ?? "")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -636,7 +748,7 @@ const BookingPage = ({
|
|||||||
{selectedLocationType === LocationType.Phone
|
{selectedLocationType === LocationType.Phone
|
||||||
? t("phone_number")
|
? t("phone_number")
|
||||||
: selectedLocationType === LocationType.AttendeeInPerson
|
: selectedLocationType === LocationType.AttendeeInPerson
|
||||||
? t("Address")
|
? t("address")
|
||||||
: ""}
|
: ""}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
@@ -663,7 +775,7 @@ const BookingPage = ({
|
|||||||
</div>
|
</div>
|
||||||
{bookingForm.formState.errors.phone && (
|
{bookingForm.formState.errors.phone && (
|
||||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
<p>{t("invalid_number")}</p>
|
<p>{t("invalid_number")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -676,8 +788,11 @@ const BookingPage = ({
|
|||||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||||
<label
|
<label
|
||||||
htmlFor={"custom_" + input.id}
|
htmlFor={"custom_" + input.id}
|
||||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
className={classNames(
|
||||||
{input.label}
|
"mb-1 block text-sm font-medium text-gray-700 transition-colors dark:text-white",
|
||||||
|
bookingForm.formState.errors.customInputs?.[input.id] && "!text-red-700"
|
||||||
|
)}>
|
||||||
|
{input.label} {input.required && <span className="text-red-700">*</span>}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
||||||
@@ -741,62 +856,52 @@ const BookingPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{input.options && input.type === EventTypeCustomInputType.RADIO && (
|
||||||
))}
|
<div className="flex">
|
||||||
{!eventType.disableGuests && guestToggle && (
|
<Group
|
||||||
<div className="mb-4">
|
name={`customInputs.${input.id}`}
|
||||||
<div>
|
required={input.required}
|
||||||
<label
|
onValueChange={(e) => {
|
||||||
htmlFor="guests"
|
bookingForm.setValue(`customInputs.${input.id}`, e);
|
||||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
}}>
|
||||||
{t("guests")}
|
<>
|
||||||
</label>
|
{input.options.map((option, i) => (
|
||||||
{!disableInput && (
|
<RadioField
|
||||||
<Controller
|
label={option.label}
|
||||||
control={bookingForm.control}
|
key={`option.${input.id}.${i}.radio`}
|
||||||
name="guests"
|
value={option.label}
|
||||||
render={({ field: { onChange, value } }) => (
|
id={`option.${input.id}.${i}.radio`}
|
||||||
<ReactMultiEmail
|
/>
|
||||||
className="relative"
|
))}
|
||||||
placeholder={<span className="dark:text-darkgray-600">guest@example.com</span>}
|
</>
|
||||||
emails={value}
|
{bookingForm.formState.errors.customInputs?.[input.id] && (
|
||||||
onChange={onChange}
|
<div className="mt-px flex items-center text-xs text-red-700 ">
|
||||||
getLabel={(
|
<p>{t("required")}</p>
|
||||||
email: string,
|
|
||||||
index: number,
|
|
||||||
removeEmail: (index: number) => void
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<div data-tag key={index} className="cursor-pointer">
|
|
||||||
{email}
|
|
||||||
{!disableInput && (
|
|
||||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Custom code when guest emails should not be editable */}
|
|
||||||
{disableInput && guestListEmails && guestListEmails.length > 0 && (
|
|
||||||
<div data-tag className="react-multi-email">
|
|
||||||
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
|
|
||||||
{guestListEmails.map((email, index) => {
|
|
||||||
return (
|
|
||||||
<div key={index} className="cursor-pointer">
|
|
||||||
<span data-tag>{email}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</Group>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{input.type === EventTypeCustomInputType.PHONE && (
|
||||||
|
<div>
|
||||||
|
<PhoneInput<BookingFormValues>
|
||||||
|
name={`customInputs.${input.id}`}
|
||||||
|
control={bookingForm.control}
|
||||||
|
placeholder={t("enter_phone_number")}
|
||||||
|
id={`customInputs.${input.id}`}
|
||||||
|
required={input.required}
|
||||||
|
/>
|
||||||
|
{bookingForm.formState.errors?.customInputs?.[input.id] && (
|
||||||
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
|
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
|
<p>{t("invalid_number")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
|
||||||
{isSmsReminderNumberNeeded && selectedLocationType !== LocationType.Phone && (
|
{isSmsReminderNumberNeeded && selectedLocationType !== LocationType.Phone && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label
|
<label
|
||||||
@@ -815,7 +920,7 @@ const BookingPage = ({
|
|||||||
</div>
|
</div>
|
||||||
{bookingForm.formState.errors.smsReminderNumber && (
|
{bookingForm.formState.errors.smsReminderNumber && (
|
||||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
<p>{t("invalid_number")}</p>
|
<p>{t("invalid_number")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -839,7 +944,7 @@ const BookingPage = ({
|
|||||||
) : (
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
{...bookingForm.register("notes")}
|
{...bookingForm.register("notes")}
|
||||||
required={!!eventType.metadata.additionalNotesRequired}
|
required={!!eventType.metadata?.additionalNotesRequired}
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -849,25 +954,85 @@ const BookingPage = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!eventType.disableGuests && guestsField.fields.length ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="guests"
|
||||||
|
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
|
{t("guests")}
|
||||||
|
</label>
|
||||||
|
<ul>
|
||||||
|
{guestsField.fields.map((field, index) => (
|
||||||
|
<li key={field.id}>
|
||||||
|
<EmailField
|
||||||
|
{...bookingForm.register(`guests.${index}.email` as const)}
|
||||||
|
className={classNames(
|
||||||
|
inputClassName,
|
||||||
|
bookingForm.formState.errors.guests?.[index] &&
|
||||||
|
"!focus:ring-red-700 !border-red-700",
|
||||||
|
"border-r-0"
|
||||||
|
)}
|
||||||
|
addOnClassname={classNames(
|
||||||
|
"border-gray-300 border block border-l-0 disabled:bg-gray-200 disabled:hover:cursor-not-allowed bg-transparent disabled:text-gray-500 dark:border-darkgray-300 ",
|
||||||
|
bookingForm.formState.errors.guests?.[index] &&
|
||||||
|
"!focus:ring-red-700 !border-red-700"
|
||||||
|
)}
|
||||||
|
placeholder="guest@example.com"
|
||||||
|
label={<></>}
|
||||||
|
required
|
||||||
|
addOnSuffix={
|
||||||
|
<Tooltip content="Remove guest">
|
||||||
|
<button
|
||||||
|
className="m-1 disabled:hover:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
onClick={() => guestsField.remove(index)}>
|
||||||
|
<FiX className="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{bookingForm.formState.errors.guests?.[index] && (
|
||||||
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
|
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
|
<p className="text-red-700">
|
||||||
|
{bookingForm.formState.errors.guests?.[index]?.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={FiUserPlus}
|
||||||
|
className="my-2.5"
|
||||||
|
// className="mb-1 block text-sm font-medium text-gray-700 dark:text-white"
|
||||||
|
onClick={() => guestsField.append({ email: "" })}>
|
||||||
|
{t("add_another")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 rtl:space-x-reverse">
|
{!eventType.disableGuests && !guestsField.fields.length && (
|
||||||
{!eventType.disableGuests && !guestToggle && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
color="minimal"
|
|
||||||
size="icon"
|
|
||||||
tooltip={t("additional_guests")}
|
|
||||||
StartIcon={Icon.FiUserPlus}
|
|
||||||
onClick={() => setGuestToggle(!guestToggle)}
|
|
||||||
className="mr-auto"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
color="minimal"
|
color="minimal"
|
||||||
type="button"
|
variant="button"
|
||||||
onClick={() => router.back()}
|
StartIcon={FiUserPlus}
|
||||||
// We override this for this component only for now - as we don't support darkmode everywhere in the app
|
onClick={() => {
|
||||||
className="dark:hover:bg-darkgray-200 dark:border-none dark:text-white">
|
guestsField.append({ email: "" });
|
||||||
|
}}
|
||||||
|
className="mr-auto">
|
||||||
|
{t("additional_guests")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||||
|
<Button color="minimal" type="button" onClick={() => router.back()}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -899,7 +1064,7 @@ function ErrorMessage({ error }: { error: unknown }) {
|
|||||||
<div data-testid="booking-fail" className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
<div data-testid="booking-fail" className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Icon.FiAlertTriangle className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
<FiAlertTriangle className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ltr:ml-3 rtl:mr-3">
|
<div className="ltr:ml-3 rtl:mr-3">
|
||||||
<p className="text-sm text-yellow-700">
|
<p className="text-sm text-yellow-700">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ErrorMessage } from "@hookform/error-message";
|
import { ErrorMessage } from "@hookform/error-message";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { components } from "react-select";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,32 +15,26 @@ import {
|
|||||||
LocationObject,
|
LocationObject,
|
||||||
LocationType,
|
LocationType,
|
||||||
} from "@calcom/app-store/locations";
|
} from "@calcom/app-store/locations";
|
||||||
import { classNames } from "@calcom/lib";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui";
|
||||||
|
import { FiMapPin } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
import Select from "@components/ui/form/Select";
|
import LocationSelect, { LocationOption } from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||||
|
|
||||||
type OptionTypeBase = {
|
|
||||||
label: string;
|
|
||||||
value: EventLocationType["type"];
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ISetLocationDialog {
|
interface ISetLocationDialog {
|
||||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||||
selection?: OptionTypeBase;
|
selection?: LocationOption;
|
||||||
booking?: BookingItem;
|
booking?: BookingItem;
|
||||||
defaultValues?: LocationObject[];
|
defaultValues?: LocationObject[];
|
||||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isOpenDialog: boolean;
|
isOpenDialog: boolean;
|
||||||
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
||||||
setEditingLocationType?: (param: string) => void;
|
setEditingLocationType?: (param: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,11 +206,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={(open) => setShowLocationModal(open)}>
|
||||||
<DialogContent useOwnActionButtons>
|
<DialogContent>
|
||||||
<div className="flex flex-row space-x-3">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
|
<FiMapPin className="text-primary-600 h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||||
@@ -223,7 +218,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
{t("edit_location")}
|
{t("edit_location")}
|
||||||
</h3>
|
</h3>
|
||||||
{!booking && (
|
{!booking && (
|
||||||
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||||
|
Can't find the right video app? Visit our
|
||||||
|
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||||
|
App Store
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
||||||
@@ -296,57 +299,31 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
name="locationType"
|
name="locationType"
|
||||||
control={locationFormMethods.control}
|
control={locationFormMethods.control}
|
||||||
render={() => (
|
render={() => (
|
||||||
<Select<{ label: string; value: string; icon?: string }>
|
<div className="py-4">
|
||||||
maxMenuHeight={150}
|
<LocationSelect
|
||||||
name="location"
|
maxMenuHeight={300}
|
||||||
defaultValue={selection}
|
name="location"
|
||||||
options={locationOptions}
|
defaultValue={selection}
|
||||||
components={{
|
options={locationOptions}
|
||||||
Option: (props) => (
|
isSearchable
|
||||||
<components.Option {...props}>
|
onChange={(val) => {
|
||||||
<div className="flex items-center gap-3">
|
if (val) {
|
||||||
{props.data.icon && (
|
locationFormMethods.setValue("locationType", val.value);
|
||||||
<img src={props.data.icon} alt="cover" className="h-3.5 w-3.5" />
|
locationFormMethods.unregister([
|
||||||
)}
|
"locationLink",
|
||||||
<span
|
"locationAddress",
|
||||||
className={classNames(
|
"locationPhoneNumber",
|
||||||
"text-sm font-medium",
|
]);
|
||||||
props.isSelected ? "text-white" : "text-gray-900"
|
locationFormMethods.clearErrors([
|
||||||
)}>
|
"locationLink",
|
||||||
{props.data.label}
|
"locationPhoneNumber",
|
||||||
</span>
|
"locationAddress",
|
||||||
</div>
|
]);
|
||||||
</components.Option>
|
setSelectedLocation?.(val);
|
||||||
),
|
}
|
||||||
}}
|
}}
|
||||||
formatOptionLabel={(e) => (
|
/>
|
||||||
<div className="flex items-center gap-3">
|
</div>
|
||||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
|
||||||
<span>{e.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
formatGroupLabel={(e) => (
|
|
||||||
<p className="text-xs font-medium text-gray-600">{e.label}</p>
|
|
||||||
)}
|
|
||||||
isSearchable
|
|
||||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val) {
|
|
||||||
locationFormMethods.setValue("locationType", val.value);
|
|
||||||
locationFormMethods.unregister([
|
|
||||||
"locationLink",
|
|
||||||
"locationAddress",
|
|
||||||
"locationPhoneNumber",
|
|
||||||
]);
|
|
||||||
locationFormMethods.clearErrors([
|
|
||||||
"locationLink",
|
|
||||||
"locationPhoneNumber",
|
|
||||||
"locationAddress",
|
|
||||||
]);
|
|
||||||
setSelectedLocation?.(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -354,13 +331,13 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
/>
|
/>
|
||||||
{selectedLocation && LocationOptions}
|
{selectedLocation && LocationOptions}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="mt-4 flex justify-end space-x-2">
|
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowLocationModal(false);
|
setShowLocationModal(false);
|
||||||
setSelectedLocation?.(undefined);
|
setSelectedLocation?.(undefined);
|
||||||
setEditingLocationType?.("");
|
setEditingLocationType?.("");
|
||||||
locationFormMethods.unregister("locationType");
|
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
color="secondary">
|
color="secondary">
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ import { Dispatch, SetStateAction, useState } from "react";
|
|||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, Dialog, DialogContent, Icon, showToast, TextArea } from "@calcom/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
showToast,
|
||||||
|
TextArea,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
import { FiClock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
interface IRescheduleDialog {
|
interface IRescheduleDialog {
|
||||||
isOpenDialog: boolean;
|
isOpenDialog: boolean;
|
||||||
@@ -30,44 +40,41 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent
|
<DialogContent>
|
||||||
Icon={Icon.FiClock}
|
<div className="flex flex-row space-x-3">
|
||||||
useOwnActionButtons
|
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||||
title={t("send_reschedule_request")}
|
<FiClock className="m-auto h-6 w-6" />
|
||||||
description={t("reschedule_modal_description")}>
|
</div>
|
||||||
<div>
|
<div className="pt-1">
|
||||||
<p className="text-sm font-medium text-black">
|
<DialogHeader title={t("send_reschedule_request")} />
|
||||||
{t("reason_for_reschedule_request")}
|
<p className="text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
||||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||||
</p>
|
{t("reason_for_reschedule_request")}
|
||||||
<TextArea
|
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||||
data-testid="reschedule_reason"
|
</p>
|
||||||
name={t("reschedule_reason")}
|
<TextArea
|
||||||
value={rescheduleReason}
|
data-testid="reschedule_reason"
|
||||||
onChange={(e) => setRescheduleReason(e.target.value)}
|
name={t("reschedule_reason")}
|
||||||
className="mt-2"
|
value={rescheduleReason}
|
||||||
/>
|
onChange={(e) => setRescheduleReason(e.target.value)}
|
||||||
</div>
|
className="mb-5 sm:mb-6"
|
||||||
<div className="flex justify-end space-x-2">
|
/>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
<DialogFooter>
|
||||||
setRescheduleReason("");
|
<DialogClose />
|
||||||
setIsOpenDialog(false);
|
<Button
|
||||||
}}
|
data-testid="send_request"
|
||||||
color="secondary">
|
disabled={isLoading}
|
||||||
{t("cancel")}
|
onClick={() => {
|
||||||
</Button>
|
rescheduleApi({
|
||||||
<Button
|
bookingId,
|
||||||
data-testid="send_request"
|
rescheduleReason,
|
||||||
disabled={isLoading}
|
});
|
||||||
onClick={() => {
|
}}>
|
||||||
rescheduleApi({
|
{t("send_reschedule_request")}
|
||||||
bookingId,
|
</Button>
|
||||||
rescheduleReason,
|
</DialogFooter>
|
||||||
});
|
</div>
|
||||||
}}>
|
|
||||||
{t("send_reschedule_request")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { weekdayNames } from "@calcom/lib/weekday";
|
import { weekdayNames } from "@calcom/lib/weekday";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
import { Badge, Button, Icon, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
||||||
|
import { FiExternalLink, FiGlobe } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||||
|
|
||||||
@@ -20,12 +21,13 @@ type AvailabilityOption = {
|
|||||||
|
|
||||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||||
const { label, isDefault } = props.data;
|
const { label, isDefault } = props.data;
|
||||||
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<components.Option {...props}>
|
<components.Option {...props}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<Badge variant="blue" className="ml-2">
|
<Badge variant="blue" className="ml-2">
|
||||||
Default
|
{t("default")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</components.Option>
|
</components.Option>
|
||||||
@@ -34,12 +36,13 @@ const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
|||||||
|
|
||||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||||
const { label, isDefault } = props.data;
|
const { label, isDefault } = props.data;
|
||||||
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<components.SingleValue {...props}>
|
<components.SingleValue {...props}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<Badge variant="blue" className="ml-2">
|
<Badge variant="blue" className="ml-2">
|
||||||
Default
|
{t("default")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</components.SingleValue>
|
</components.SingleValue>
|
||||||
@@ -57,6 +60,8 @@ const AvailabilitySelect = ({
|
|||||||
onChange: (value: AvailabilityOption | null) => void;
|
onChange: (value: AvailabilityOption | null) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SelectSkeletonLoader />;
|
return <SelectSkeletonLoader />;
|
||||||
}
|
}
|
||||||
@@ -77,6 +82,7 @@ const AvailabilitySelect = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
placeholder={t("select")}
|
||||||
options={options}
|
options={options}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
@@ -93,100 +99,105 @@ const format = (date: Date, hour12: boolean) =>
|
|||||||
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
|
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
const EventTypeScheduleDetails = () => {
|
||||||
|
const { data: loggedInUser } = useMeQuery();
|
||||||
|
const timeFormat = loggedInUser?.timeFormat;
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const { watch } = useFormContext<FormValues>();
|
const { watch } = useFormContext<FormValues>();
|
||||||
|
|
||||||
const EventTypeSchedule = () => {
|
|
||||||
const me = useMeQuery();
|
|
||||||
const timeFormat = me?.data?.timeFormat;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="min-w-4 mb-2">
|
|
||||||
<label htmlFor="availability" className="mt-0 flex text-sm font-medium text-neutral-700">
|
|
||||||
{t("availability")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Controller
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => (
|
|
||||||
<AvailabilitySelect
|
|
||||||
value={field.value}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
onChange={(selected) => {
|
|
||||||
field.onChange(selected?.value || null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4 rounded border p-4 py-6 pt-2 md:p-8">
|
|
||||||
<ol className="table border-collapse text-sm">
|
|
||||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
|
||||||
const isAvailable = !!filterDays(index).length;
|
|
||||||
return (
|
|
||||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
"w-20 font-medium sm:w-32",
|
|
||||||
!isAvailable && "text-gray-500 opacity-50"
|
|
||||||
)}>
|
|
||||||
{day}
|
|
||||||
</span>
|
|
||||||
{isLoading ? (
|
|
||||||
<SkeletonText className="block h-5 w-60" />
|
|
||||||
) : isAvailable ? (
|
|
||||||
<div className="space-y-3 text-right">
|
|
||||||
{filterDays(index).map((dayRange, i) => (
|
|
||||||
<div key={i} className="flex items-center leading-4">
|
|
||||||
<span className="w-16 sm:w-28 sm:text-left">
|
|
||||||
{format(dayRange.startTime, timeFormat === 12)}
|
|
||||||
</span>
|
|
||||||
<span className="ml-4">-</span>
|
|
||||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
<hr />
|
|
||||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
|
||||||
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
|
||||||
<Icon.FiGlobe className="mr-2" />
|
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
href={`/availability/${schedule?.schedule.id}`}
|
|
||||||
color="minimal"
|
|
||||||
EndIcon={Icon.FiExternalLink}
|
|
||||||
target="_blank"
|
|
||||||
className="justify-center border sm:border-0"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
{t("edit_availability")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleId = watch("schedule");
|
const scheduleId = watch("schedule");
|
||||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId });
|
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
||||||
|
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
|
||||||
|
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
|
||||||
|
);
|
||||||
|
|
||||||
const filterDays = (dayNum: number) =>
|
const filterDays = (dayNum: number) =>
|
||||||
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||||
|
|
||||||
if (!isTeamEvent) {
|
return (
|
||||||
return <EventTypeSchedule />;
|
<div className="space-y-4 rounded border px-6 pb-4">
|
||||||
}
|
<ol className="table border-collapse text-sm">
|
||||||
|
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||||
|
const isAvailable = !!filterDays(index).length;
|
||||||
|
return (
|
||||||
|
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
"w-20 font-medium sm:w-32",
|
||||||
|
!isAvailable && "text-gray-500 opacity-50"
|
||||||
|
)}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<SkeletonText className="block h-5 w-60" />
|
||||||
|
) : isAvailable ? (
|
||||||
|
<div className="space-y-3 text-right">
|
||||||
|
{filterDays(index).map((dayRange, i) => (
|
||||||
|
<div key={i} className="flex items-center leading-4">
|
||||||
|
<span className="w-16 sm:w-28 sm:text-left">
|
||||||
|
{format(dayRange.startTime, timeFormat === 12)}
|
||||||
|
</span>
|
||||||
|
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||||
|
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<hr />
|
||||||
|
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||||
|
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||||
|
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||||
|
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
href={`/availability/${schedule?.schedule.id}`}
|
||||||
|
color="minimal"
|
||||||
|
EndIcon={FiExternalLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
{t("edit_availability")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventTypeSchedule = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="availability" className="mb-2 block text-sm font-medium leading-none text-gray-700">
|
||||||
|
{t("availability")}
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="schedule"
|
||||||
|
render={({ field }) => (
|
||||||
|
<AvailabilitySelect
|
||||||
|
value={field.value}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
onChange={(selected) => {
|
||||||
|
field.onChange(selected?.value || null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<EventTypeScheduleDetails />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UseCommonScheduleSettingsToggle = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { resetField, setValue } = useFormContext<FormValues>();
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||||
@@ -195,6 +206,11 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
checked={!value}
|
checked={!value}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
onChange(!checked);
|
onChange(!checked);
|
||||||
|
if (checked) {
|
||||||
|
resetField("schedule");
|
||||||
|
} else {
|
||||||
|
setValue("schedule", null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title={t("choose_common_schedule_team_event")}
|
title={t("choose_common_schedule_team_event")}
|
||||||
description={t("choose_common_schedule_team_event_description")}>
|
description={t("choose_common_schedule_team_event_description")}>
|
||||||
@@ -204,3 +220,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||||
|
return isTeamEvent ? <UseCommonScheduleSettingsToggle /> : <EventTypeSchedule />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,352 +0,0 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { SchedulingType } from "@prisma/client";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
Dropdown,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Form,
|
|
||||||
Icon,
|
|
||||||
InputLeading,
|
|
||||||
showToast,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
|
||||||
import { slugify } from "@lib/slugify";
|
|
||||||
|
|
||||||
import Avatar from "@components/ui/Avatar";
|
|
||||||
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
|
|
||||||
export interface EventTypeParent {
|
|
||||||
teamId: number | null | undefined; // if undefined, then it's a profile
|
|
||||||
name?: string | null;
|
|
||||||
slug?: string | null;
|
|
||||||
image?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateEventTypeBtnProps {
|
|
||||||
// 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: CreateEventTypeBtnProps) {
|
|
||||||
const { t, isLocaleReady } = useLocale();
|
|
||||||
const router = useRouter();
|
|
||||||
// URL encoded params
|
|
||||||
const teamId: number | undefined =
|
|
||||||
typeof router.query.teamId === "string" && router.query.teamId
|
|
||||||
? parseInt(router.query.teamId)
|
|
||||||
: undefined;
|
|
||||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
|
||||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
|
||||||
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
|
||||||
resolver: zodResolver(createEventTypeInput),
|
|
||||||
});
|
|
||||||
const { setValue, watch, register } = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!router.isReady) return;
|
|
||||||
|
|
||||||
const title: string =
|
|
||||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
|
||||||
const length: number =
|
|
||||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
|
||||||
const description: string =
|
|
||||||
typeof router.query.description === "string" && router.query.description
|
|
||||||
? router.query.description
|
|
||||||
: "";
|
|
||||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
|
||||||
|
|
||||||
setValue("title", title);
|
|
||||||
setValue("length", length);
|
|
||||||
setValue("description", description);
|
|
||||||
setValue("slug", slug);
|
|
||||||
// If query params change, update the form
|
|
||||||
}, [router.isReady, router.query, setValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = watch((value, { name, type }) => {
|
|
||||||
if (name === "title" && type === "change") {
|
|
||||||
if (value.title) setValue("slug", slugify(value.title));
|
|
||||||
else setValue("slug", "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => subscription.unsubscribe();
|
|
||||||
}, [watch, setValue]);
|
|
||||||
|
|
||||||
const createMutation = trpc.viewer.eventTypes.create.useMutation({
|
|
||||||
onSuccess: async ({ eventType }) => {
|
|
||||||
await router.replace("/event-types/" + eventType.id);
|
|
||||||
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
if (err instanceof HttpError) {
|
|
||||||
const message = `${err.statusCode}: ${err.message}`;
|
|
||||||
showToast(message, "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.data?.code === "BAD_REQUEST") {
|
|
||||||
const message = `${err.data.code}: URL already exists.`;
|
|
||||||
showToast(message, "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.data?.code === "UNAUTHORIZED") {
|
|
||||||
const message = `${err.data.code}: You are not able to create this event`;
|
|
||||||
showToast(message, "error");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// inject selection data into url for correct router history
|
|
||||||
const openModal = (option: EventTypeParent) => {
|
|
||||||
const query = {
|
|
||||||
...router.query,
|
|
||||||
dialog: "new-eventtype",
|
|
||||||
eventPage: option.slug,
|
|
||||||
teamId: option.teamId,
|
|
||||||
};
|
|
||||||
if (!option.teamId) {
|
|
||||||
delete query.teamId;
|
|
||||||
}
|
|
||||||
router.push(
|
|
||||||
{
|
|
||||||
pathname: router.pathname,
|
|
||||||
query,
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ shallow: true }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
if (!isLocaleReady) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
name="new-eventtype"
|
|
||||||
clearQueryParamsOnClose={["eventPage", "teamId", "type", "description", "title", "length", "slug"]}>
|
|
||||||
<CreateEventTypeTrigger
|
|
||||||
hasTeams={hasTeams}
|
|
||||||
canAddEvents={props.canAddEvents}
|
|
||||||
isIndividualTeam={props.isIndividualTeam}
|
|
||||||
openModal={openModal}
|
|
||||||
options={props.options}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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) => {
|
|
||||||
createMutation.mutate(values);
|
|
||||||
}}>
|
|
||||||
<div className="mt-3 space-y-4">
|
|
||||||
{teamId && (
|
|
||||||
<TextField
|
|
||||||
type="hidden"
|
|
||||||
labelProps={{ style: { display: "none" } }}
|
|
||||||
{...register("teamId", { valueAsNumber: true })}
|
|
||||||
value={teamId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
|
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
|
|
||||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
|
|
||||||
<TextField
|
|
||||||
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
|
|
||||||
required
|
|
||||||
addOnLeading={<InputLeading>/{pageSlug}/</InputLeading>}
|
|
||||||
{...register("slug")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextField
|
|
||||||
label={t("url")}
|
|
||||||
required
|
|
||||||
addOnLeading={
|
|
||||||
<InputLeading>
|
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
|
||||||
</InputLeading>
|
|
||||||
}
|
|
||||||
{...register("slug")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextAreaField
|
|
||||||
label={t("description")}
|
|
||||||
placeholder={t("quick_video_meeting")}
|
|
||||||
{...register("description")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
required
|
|
||||||
min="10"
|
|
||||||
placeholder="15"
|
|
||||||
label={t("length")}
|
|
||||||
className="pr-20"
|
|
||||||
{...register("length", { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-y-0 right-0 mt-1.5 flex items-center pt-4 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>
|
|
||||||
{form.formState.errors.schedulingType && (
|
|
||||||
<Alert
|
|
||||||
className="mt-1"
|
|
||||||
severity="error"
|
|
||||||
message={form.formState.errors.schedulingType.message}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<RadioArea.Group
|
|
||||||
{...register("schedulingType")}
|
|
||||||
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
|
|
||||||
className="relative mt-1 flex space-x-6 rounded-sm rtl:space-x-reverse">
|
|
||||||
<RadioArea.Item
|
|
||||||
value={SchedulingType.COLLECTIVE}
|
|
||||||
defaultChecked={type === SchedulingType.COLLECTIVE}
|
|
||||||
className="w-1/2 text-sm">
|
|
||||||
<strong className="mb-1 block">{t("collective")}</strong>
|
|
||||||
<p>{t("collective_description")}</p>
|
|
||||||
</RadioArea.Item>
|
|
||||||
<RadioArea.Item
|
|
||||||
value={SchedulingType.ROUND_ROBIN}
|
|
||||||
defaultChecked={type === SchedulingType.ROUND_ROBIN}
|
|
||||||
className="w-1/2 text-sm">
|
|
||||||
<strong className="mb-1 block">{t("round_robin")}</strong>
|
|
||||||
<p>{t("round_robin_description")}</p>
|
|
||||||
</RadioArea.Item>
|
|
||||||
</RadioArea.Group>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex flex-row-reverse 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateEventTypeTrigger = {
|
|
||||||
isIndividualTeam?: boolean;
|
|
||||||
// EventTypeParent can be a profile (as first option) or a team for the rest.
|
|
||||||
options: EventTypeParent[];
|
|
||||||
hasTeams: boolean;
|
|
||||||
// set true for use on the team settings page
|
|
||||||
canAddEvents: boolean;
|
|
||||||
openModal: (option: EventTypeParent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CreateEventTypeTrigger(props: CreateEventTypeTrigger) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!props.hasTeams || props.isIndividualTeam ? (
|
|
||||||
<Button
|
|
||||||
onClick={() => props.openModal(props.options[0])}
|
|
||||||
data-testid="new-event-type"
|
|
||||||
StartIcon={Icon.FiPlus}
|
|
||||||
disabled={!props.canAddEvents}>
|
|
||||||
{t("new_event_type_btn")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Dropdown>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button EndIcon={Icon.FiChevronDown}>{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) => (
|
|
||||||
<CreateEventTeamsItem
|
|
||||||
key={option.slug}
|
|
||||||
option={option}
|
|
||||||
openModal={() => props.openModal(option)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateEventTeamsItem(props: {
|
|
||||||
openModal: (option: EventTypeParent) => void;
|
|
||||||
option: EventTypeParent;
|
|
||||||
}) {
|
|
||||||
const session = useSession();
|
|
||||||
const membershipQuery = trpc.viewer.teams.getMembershipbyUser.useQuery({
|
|
||||||
memberId: session.data?.user.id as number,
|
|
||||||
teamId: props.option.teamId as number,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDisabled = membershipQuery.data?.role === "MEMBER";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={props.option.slug}
|
|
||||||
className={classNames(
|
|
||||||
"cursor-pointer px-3 py-2 focus:outline-none",
|
|
||||||
isDisabled ? "cursor-default !text-gray-300" : "hover:bg-neutral-100"
|
|
||||||
)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onSelect={() => props.openModal(props.option)}>
|
|
||||||
<Avatar
|
|
||||||
alt={props.option.name || ""}
|
|
||||||
imageSrc={props.option.image || WEBAPP_URL + "/" + props.option.slug + "/avatar.png"}
|
|
||||||
size={6}
|
|
||||||
className="inline ltr:mr-2 rtl:ml-2"
|
|
||||||
/>
|
|
||||||
{props.option.name ? props.option.name : props.option.slug}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,34 @@
|
|||||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import { EventTypeCustomInputType } from "@prisma/client";
|
||||||
|
import type { CustomInputParsed } from "pages/event-types/[type]";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
import { Control, Controller, useFieldArray, useForm, UseFormRegister, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
import { Button, Select, TextField } from "@calcom/ui";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button, Label, Select, TextField } from "@calcom/ui";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { FiPlus, FiX } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
interface OptionTypeBase {
|
interface OptionTypeBase {
|
||||||
label: string;
|
label: string;
|
||||||
value: EventTypeCustomInputType;
|
value: EventTypeCustomInputType;
|
||||||
|
options?: { label: string; type: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: SubmitHandler<IFormInput>;
|
onSubmit: (output: CustomInputParsed) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
selectedCustomInput?: EventTypeCustomInput;
|
selectedCustomInput?: CustomInputParsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IFormInput = EventTypeCustomInput;
|
type IFormInput = CustomInputParsed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getting a random ID gives us the option to know WHICH field is changed
|
||||||
|
* when the user edits a custom field.
|
||||||
|
* This UUID is only used to check for changes in the UI and not the ID we use in the DB
|
||||||
|
* There is very very very slim chance that this will cause a collision
|
||||||
|
* */
|
||||||
|
const randomId = () => Math.floor(Math.random() * 1000000 + new Date().getTime());
|
||||||
|
|
||||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -26,10 +37,23 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||||
|
{
|
||||||
|
value: EventTypeCustomInputType.RADIO,
|
||||||
|
label: t("radio"),
|
||||||
|
},
|
||||||
|
{ value: EventTypeCustomInputType.PHONE, label: t("phone_number") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { selectedCustomInput } = props;
|
const { selectedCustomInput } = props;
|
||||||
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
|
|
||||||
const { register, control, handleSubmit } = useForm<IFormInput>({
|
const defaultValues = selectedCustomInput
|
||||||
|
? { ...selectedCustomInput, id: selectedCustomInput?.id || randomId() }
|
||||||
|
: {
|
||||||
|
id: randomId(),
|
||||||
|
type: EventTypeCustomInputType.TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { register, control, getValues } = useForm<IFormInput>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
const selectedInputType = useWatch({ name: "type", control });
|
const selectedInputType = useWatch({ name: "type", control });
|
||||||
@@ -40,7 +64,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(props.onSubmit)} className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||||
{t("input_type")}
|
{t("input_type")}
|
||||||
@@ -54,7 +78,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
defaultValue={selectedInputOption}
|
defaultValue={selectedInputOption}
|
||||||
options={inputOptions}
|
options={inputOptions}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
||||||
onChange={(option) => option && field.onChange(option.value)}
|
onChange={(option) => option && field.onChange(option.value)}
|
||||||
value={selectedInputOption}
|
value={selectedInputOption}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
@@ -83,6 +107,10 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
{...register("placeholder")}
|
{...register("placeholder")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{selectedInputType === EventTypeCustomInputType.RADIO && (
|
||||||
|
<RadioInputHandler control={control} register={register} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex h-5 items-center">
|
<div className="flex h-5 items-center">
|
||||||
<input
|
<input
|
||||||
id="required"
|
id="required"
|
||||||
@@ -107,14 +135,79 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
value={selectedCustomInput?.id || -1}
|
value={selectedCustomInput?.id || -1}
|
||||||
{...register("id", { valueAsNumber: true })}
|
{...register("id", { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-4">
|
<div className="mt-5 flex justify-end space-x-2 rtl:space-x-reverse sm:mt-4">
|
||||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2 rtl:ml-2">
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">{t("save")}</Button>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
props.onSubmit(getValues());
|
||||||
|
}}>
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RadioInputHandler({
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
register: UseFormRegister<IFormInput>;
|
||||||
|
control: Control<IFormInput>;
|
||||||
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { fields, append, remove } = useFieldArray<IFormInput>({
|
||||||
|
control,
|
||||||
|
name: "options",
|
||||||
|
shouldUnregister: true,
|
||||||
|
});
|
||||||
|
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col ">
|
||||||
|
<Label htmlFor="radio_options">{t("options")}</Label>
|
||||||
|
<ul
|
||||||
|
className="flex max-h-80 w-full flex-col space-y-1 overflow-y-scroll rounded-md bg-gray-50 p-4"
|
||||||
|
ref={animateRef}>
|
||||||
|
<>
|
||||||
|
{fields.map((option, index) => (
|
||||||
|
<li key={`${option.id}`}>
|
||||||
|
<TextField
|
||||||
|
id={option.id}
|
||||||
|
placeholder={t("enter_option", { index: index + 1 })}
|
||||||
|
addOnFilled={false}
|
||||||
|
label={t("option", { index: index + 1 })}
|
||||||
|
labelSrOnly
|
||||||
|
{...register(`options.${index}.label` as const, { required: true })}
|
||||||
|
addOnSuffix={
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={FiX}
|
||||||
|
onClick={() => {
|
||||||
|
remove(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={FiPlus}
|
||||||
|
className="!text-sm !font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
append({ label: "", type: "text" });
|
||||||
|
}}>
|
||||||
|
{t("add_an_option")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default CustomInputTypeForm;
|
export default CustomInputTypeForm;
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
import { ErrorMessage } from "@hookform/error-message";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
|
||||||
import { Trans } from "next-i18next";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
|
||||||
import { components } from "react-select";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import {
|
|
||||||
EventLocationType,
|
|
||||||
getEventLocationType,
|
|
||||||
getHumanReadableLocationValue,
|
|
||||||
getMessageForOrganizer,
|
|
||||||
LocationObject,
|
|
||||||
LocationType,
|
|
||||||
} from "@calcom/app-store/locations";
|
|
||||||
import { classNames } from "@calcom/lib";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
|
||||||
import { Button, Dialog, DialogContent, Form, Icon, Label, PhoneInput, Select } from "@calcom/ui";
|
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
|
||||||
|
|
||||||
import { LinkText } from "@components/ui/LinkText";
|
|
||||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
|
||||||
|
|
||||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
|
||||||
|
|
||||||
type OptionTypeBase = {
|
|
||||||
label: string;
|
|
||||||
value: EventLocationType["type"];
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ISetLocationDialog {
|
|
||||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
|
||||||
selection?: OptionTypeBase;
|
|
||||||
booking?: BookingItem;
|
|
||||||
defaultValues?: LocationObject[];
|
|
||||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
isOpenDialog: boolean;
|
|
||||||
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationInput = (props: {
|
|
||||||
eventLocationType: EventLocationType;
|
|
||||||
locationFormMethods: ReturnType<typeof useForm>;
|
|
||||||
id: string;
|
|
||||||
required: boolean;
|
|
||||||
placeholder: string;
|
|
||||||
className?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
}): JSX.Element | null => {
|
|
||||||
const { eventLocationType, locationFormMethods, ...remainingProps } = props;
|
|
||||||
if (eventLocationType?.organizerInputType === "text") {
|
|
||||||
return (
|
|
||||||
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
|
||||||
);
|
|
||||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
|
||||||
return (
|
|
||||||
<PhoneInput
|
|
||||||
name={eventLocationType.variable}
|
|
||||||
control={locationFormMethods.control}
|
|
||||||
{...remainingProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|
||||||
const {
|
|
||||||
saveLocation,
|
|
||||||
selection,
|
|
||||||
booking,
|
|
||||||
setShowLocationModal,
|
|
||||||
isOpenDialog,
|
|
||||||
defaultValues,
|
|
||||||
setSelectedLocation,
|
|
||||||
} = props;
|
|
||||||
const { t } = useLocale();
|
|
||||||
const locationsQuery = trpc.viewer.locationOptions.useQuery();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selection) {
|
|
||||||
locationFormMethods.setValue("locationType", selection?.value);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selection]);
|
|
||||||
|
|
||||||
const locationFormSchema = z.object({
|
|
||||||
locationType: z.string(),
|
|
||||||
phone: z.string().optional(),
|
|
||||||
locationAddress: z.string().optional(),
|
|
||||||
locationLink: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.superRefine((val, ctx) => {
|
|
||||||
if (
|
|
||||||
eventLocationType &&
|
|
||||||
!eventLocationType.default &&
|
|
||||||
eventLocationType.linkType === "static" &&
|
|
||||||
eventLocationType.urlRegExp
|
|
||||||
) {
|
|
||||||
const valid = z
|
|
||||||
.string()
|
|
||||||
.regex(new RegExp(eventLocationType.urlRegExp || ""))
|
|
||||||
.safeParse(val).success;
|
|
||||||
if (!valid) {
|
|
||||||
const sampleUrl = eventLocationType.organizerInputPlaceholder;
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `Invalid URL for ${eventLocationType.label}. ${
|
|
||||||
sampleUrl ? "Sample URL: " + sampleUrl : ""
|
|
||||||
}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = z.string().url().optional().safeParse(val).success;
|
|
||||||
if (!valid) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `Invalid URL`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}),
|
|
||||||
displayLocationPublicly: z.boolean().optional(),
|
|
||||||
locationPhoneNumber: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const locationFormMethods = useForm({
|
|
||||||
mode: "onSubmit",
|
|
||||||
resolver: zodResolver(locationFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedLocation = useWatch({
|
|
||||||
control: locationFormMethods.control,
|
|
||||||
name: "locationType",
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventLocationType = getEventLocationType(selectedLocation);
|
|
||||||
|
|
||||||
const defaultLocation = defaultValues?.find(
|
|
||||||
(location: { type: EventLocationType["type"] }) => location.type === eventLocationType?.type
|
|
||||||
);
|
|
||||||
|
|
||||||
const LocationOptions = (() => {
|
|
||||||
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
|
|
||||||
if (!eventLocationType.variable) {
|
|
||||||
console.error("eventLocationType.variable can't be undefined");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="locationInput">{t(eventLocationType.messageForOrganizer || "")}</Label>
|
|
||||||
<LocationInput
|
|
||||||
locationFormMethods={locationFormMethods}
|
|
||||||
eventLocationType={eventLocationType}
|
|
||||||
id="locationInput"
|
|
||||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
|
||||||
required
|
|
||||||
className="block w-full rounded-md border-gray-300 text-sm"
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
defaultValue={
|
|
||||||
(defaultLocation && defaultLocation[eventLocationType.defaultValueVariable]) || ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ErrorMessage
|
|
||||||
errors={locationFormMethods.formState.errors}
|
|
||||||
name={eventLocationType.variable}
|
|
||||||
className="mt-1 text-sm text-red-500"
|
|
||||||
as="p"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!booking && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<Controller
|
|
||||||
name="displayLocationPublicly"
|
|
||||||
control={locationFormMethods.control}
|
|
||||||
render={() => (
|
|
||||||
<CheckboxField
|
|
||||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
|
||||||
description={t("display_location_label")}
|
|
||||||
onChange={(e) =>
|
|
||||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
|
||||||
}
|
|
||||||
informationIconText={t("display_location_info_badge")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <p className="text-sm">{getMessageForOrganizer(selectedLocation, t)}</p>;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpenDialog}>
|
|
||||||
<DialogContent
|
|
||||||
type="creation"
|
|
||||||
Icon={Icon.FiMapPin}
|
|
||||||
useOwnActionButtons
|
|
||||||
title={t("edit_location")}
|
|
||||||
description={!booking ? t("this_input_will_shown_booking_this_event") : undefined}>
|
|
||||||
{booking && (
|
|
||||||
<>
|
|
||||||
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
|
||||||
<p className="mb-2 ml-1 text-sm text-black">
|
|
||||||
{getHumanReadableLocationValue(booking.location, t)}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Form
|
|
||||||
form={locationFormMethods}
|
|
||||||
className="space-y-4"
|
|
||||||
handleSubmit={async (values) => {
|
|
||||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
|
||||||
|
|
||||||
let details = {};
|
|
||||||
if (newLocation === LocationType.AttendeeInPerson) {
|
|
||||||
details = {
|
|
||||||
address: values.locationAddress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newLocation === LocationType.InPerson) {
|
|
||||||
details = {
|
|
||||||
address: values.locationAddress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const eventLocationType = getEventLocationType(newLocation);
|
|
||||||
|
|
||||||
// TODO: There can be a property that tells if it is to be saved in `link`
|
|
||||||
if (
|
|
||||||
newLocation === LocationType.Link ||
|
|
||||||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
|
||||||
) {
|
|
||||||
details = { link: values.locationLink };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newLocation === LocationType.UserPhone) {
|
|
||||||
details = { hostPhoneNumber: values.locationPhoneNumber };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventLocationType?.organizerInputType) {
|
|
||||||
details = {
|
|
||||||
...details,
|
|
||||||
displayLocationPublicly,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
saveLocation(newLocation, details);
|
|
||||||
setShowLocationModal(false);
|
|
||||||
setSelectedLocation?.(undefined);
|
|
||||||
locationFormMethods.unregister([
|
|
||||||
"locationType",
|
|
||||||
"locationLink",
|
|
||||||
"locationAddress",
|
|
||||||
"locationPhoneNumber",
|
|
||||||
]);
|
|
||||||
}}>
|
|
||||||
<QueryCell
|
|
||||||
query={locationsQuery}
|
|
||||||
success={({ data: locationOptions }) => {
|
|
||||||
if (!locationOptions.length) return null;
|
|
||||||
if (booking) {
|
|
||||||
locationOptions.forEach((location) => {
|
|
||||||
if (location.label === "phone") {
|
|
||||||
location.options.filter((l) => l.value !== "phone");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name="locationType"
|
|
||||||
control={locationFormMethods.control}
|
|
||||||
render={() => (
|
|
||||||
<Select<{ label: string; value: string; icon?: string }>
|
|
||||||
maxMenuHeight={150}
|
|
||||||
name="location"
|
|
||||||
defaultValue={selection}
|
|
||||||
options={locationOptions}
|
|
||||||
components={{
|
|
||||||
Option: (props) => (
|
|
||||||
<components.Option {...props}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{props.data.icon && (
|
|
||||||
<img src={props.data.icon} alt="cover" className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
"text-sm font-medium",
|
|
||||||
props.isSelected ? "text-white" : "text-gray-900"
|
|
||||||
)}>
|
|
||||||
{props.data.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</components.Option>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
formatOptionLabel={(e) => (
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
|
||||||
<span>{e.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
formatGroupLabel={(e) => <p className="text-xs font-medium text-gray-600">{e.label}</p>}
|
|
||||||
isSearchable={false}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val) {
|
|
||||||
locationFormMethods.setValue("locationType", val.value);
|
|
||||||
locationFormMethods.unregister([
|
|
||||||
"locationLink",
|
|
||||||
"locationAddress",
|
|
||||||
"locationPhoneNumber",
|
|
||||||
]);
|
|
||||||
locationFormMethods.clearErrors([
|
|
||||||
"locationLink",
|
|
||||||
"locationPhoneNumber",
|
|
||||||
"locationAddress",
|
|
||||||
]);
|
|
||||||
setSelectedLocation?.(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{selectedLocation && LocationOptions}
|
|
||||||
<div className="mt-5">
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
|
||||||
Can't find the right video app? Visit our
|
|
||||||
<LinkText href="/apps/categories/video" classNameChildren="text-blue-400" target="_blank">
|
|
||||||
App Store
|
|
||||||
</LinkText>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowLocationModal(false);
|
|
||||||
setSelectedLocation?.(undefined);
|
|
||||||
locationFormMethods.unregister("locationType");
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
color="secondary">
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{t("update")}</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,31 +1,37 @@
|
|||||||
import { EventTypeCustomInput } from "@prisma/client/";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import type { CustomInputParsed, EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
|
import type { EventNameObjectType } from "@calcom/core/event";
|
||||||
|
import { getEventName } from "@calcom/core/event";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
|
||||||
|
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CustomInputItem,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
Icon,
|
DialogFooter,
|
||||||
Label,
|
Label,
|
||||||
SettingsToggle,
|
SettingsToggle,
|
||||||
showToast,
|
showToast,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
import { FiEdit, FiCopy, FiPlus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
||||||
|
|
||||||
|
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||||
|
|
||||||
const generateHashedLink = (id: number) => {
|
const generateHashedLink = (id: number) => {
|
||||||
const translator = short();
|
const translator = short();
|
||||||
const seed = `${id}:${new Date().getTime()}`;
|
const seed = `${id}:${new Date().getTime()}`;
|
||||||
@@ -33,7 +39,19 @@ const generateHashedLink = (id: number) => {
|
|||||||
return uid;
|
return uid;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
|
const getRandomId = (length = 8) => {
|
||||||
|
return (
|
||||||
|
-1 *
|
||||||
|
parseInt(
|
||||||
|
Math.ceil(Math.random() * Date.now())
|
||||||
|
.toPrecision(length)
|
||||||
|
.toString()
|
||||||
|
.replace(".", "")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -41,14 +59,22 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
const eventNameObject: EventNameObjectType = {
|
||||||
|
attendeeName: t("scheduler"),
|
||||||
|
eventType: eventType.title,
|
||||||
|
eventName: eventType.eventName,
|
||||||
|
host: eventType.users[0]?.name || "Nameless",
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
const [previewText, setPreviewText] = useState(getEventName(eventNameObject));
|
||||||
|
const [customInputs, setCustomInputs] = useState<CustomInputParsed[]>(
|
||||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||||
);
|
);
|
||||||
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
const [selectedCustomInput, setSelectedCustomInput] = useState<CustomInputParsed | undefined>(undefined);
|
||||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||||
|
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||||
|
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
|
||||||
|
|
||||||
const removeCustom = (index: number) => {
|
const removeCustom = (index: number) => {
|
||||||
formMethods.getValues("customInputs").splice(index, 1);
|
formMethods.getValues("customInputs").splice(index, 1);
|
||||||
@@ -56,10 +82,25 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
setCustomInputs([...customInputs]);
|
setCustomInputs([...customInputs]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changePreviewText = (eventNameObject: EventNameObjectType, previewEventName: string) => {
|
||||||
|
setPreviewText(
|
||||||
|
previewEventName
|
||||||
|
.replace("{Event type title}", eventNameObject.eventType)
|
||||||
|
.replace("{Scheduler}", eventNameObject.attendeeName)
|
||||||
|
.replace("{Organiser}", eventNameObject.host)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||||
}, [eventType.users, hashedUrl, team?.id]);
|
}, [eventType.users, hashedUrl, team?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eventType.customInputs) {
|
||||||
|
setCustomInputs(eventType.customInputs.sort((a, b) => a.id - b.id));
|
||||||
|
}
|
||||||
|
}, [eventType.customInputs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-8">
|
<div className="flex flex-col space-y-8">
|
||||||
{/**
|
{/**
|
||||||
@@ -71,10 +112,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Label>{t("add_to_calendar")}</Label>
|
<Label>{t("add_to_calendar")}</Label>
|
||||||
<Link href="/apps/categories/calendar">
|
<Link
|
||||||
<a target="_blank" className="text-sm text-gray-600 hover:text-gray-900">
|
href="/apps/categories/calendar"
|
||||||
{t("add_another_calendar")}
|
target="_blank"
|
||||||
</a>
|
className="text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
{t("add_another_calendar")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="-mt-1 w-full">
|
<div className="-mt-1 w-full">
|
||||||
@@ -97,16 +139,23 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
)}
|
)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<TextField
|
<TextField
|
||||||
label={t("event_name")}
|
label={t("event_name_in_calendar")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user")}
|
placeholder={t("meeting_with_user")}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName", {
|
||||||
|
onChange: (e) => {
|
||||||
|
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||||
|
return setPreviewText(getEventName(eventNameObject));
|
||||||
|
}
|
||||||
|
changePreviewText(eventNameObject, e.target.value);
|
||||||
|
},
|
||||||
|
})}
|
||||||
addOnSuffix={
|
addOnSuffix={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
StartIcon={Icon.FiEdit}
|
StartIcon={FiEdit}
|
||||||
size="icon"
|
variant="icon"
|
||||||
color="minimal"
|
color="minimal"
|
||||||
className="hover:stroke-3 min-w-fit px-0 hover:bg-transparent hover:text-black"
|
className="hover:stroke-3 min-w-fit px-0 hover:bg-transparent hover:text-black"
|
||||||
onClick={() => setShowEventNameTip((old) => !old)}
|
onClick={() => setShowEventNameTip((old) => !old)}
|
||||||
@@ -123,15 +172,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
if (e && customInputs.length === 0) {
|
if (e && customInputs.length === 0) {
|
||||||
// Push a placeholders
|
// Push a placeholders
|
||||||
setSelectedCustomInput(undefined);
|
|
||||||
setSelectedCustomInputModalOpen(true);
|
setSelectedCustomInputModalOpen(true);
|
||||||
} else if (!e) {
|
} else if (!e) {
|
||||||
setCustomInputs([]);
|
|
||||||
formMethods.setValue("customInputs", []);
|
formMethods.setValue("customInputs", []);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<ul className="my-4">
|
<ul className="my-4 rounded-md border">
|
||||||
{customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
|
{customInputs.map((customInput, idx) => (
|
||||||
<CustomInputItem
|
<CustomInputItem
|
||||||
key={idx}
|
key={idx}
|
||||||
question={customInput.label}
|
question={customInput.label}
|
||||||
@@ -147,31 +194,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
</ul>
|
</ul>
|
||||||
{customInputs.length > 0 && (
|
{customInputs.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
StartIcon={Icon.FiPlus}
|
StartIcon={FiPlus}
|
||||||
color="minimal"
|
color="minimal"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCustomInput(undefined);
|
setSelectedCustomInput(undefined);
|
||||||
setSelectedCustomInputModalOpen(true);
|
setSelectedCustomInputModalOpen(true);
|
||||||
}}>
|
}}>
|
||||||
Add an input
|
{t("add_input")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<Controller
|
<RequiresConfirmationController
|
||||||
name="requiresConfirmation"
|
seatsEnabled={seatsEnabled}
|
||||||
defaultValue={eventType.requiresConfirmation}
|
metadata={eventType.metadata}
|
||||||
render={({ field: { value, onChange } }) => (
|
requiresConfirmation={requiresConfirmation}
|
||||||
<SettingsToggle
|
onRequiresConfirmation={setRequiresConfirmation}
|
||||||
title={t("requires_confirmation")}
|
|
||||||
description={t("requires_confirmation_description")}
|
|
||||||
checked={value}
|
|
||||||
onCheckedChange={(e) => onChange(e)}
|
|
||||||
disabled={seatsEnabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<Controller
|
<Controller
|
||||||
@@ -244,17 +284,32 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
defaultValue={eventType.successRedirectUrl || ""}
|
defaultValue={eventType.successRedirectUrl || ""}
|
||||||
{...formMethods.register("successRedirectUrl")}
|
{...formMethods.register("successRedirectUrl")}
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-2 flex">
|
||||||
|
<Checkbox
|
||||||
|
description={t("disable_success_page")}
|
||||||
|
// Disable if it's not Self Hosted or if the redirect url is not set
|
||||||
|
disabled={!IS_SELF_HOSTED || !formMethods.watch("successRedirectUrl")}
|
||||||
|
{...formMethods.register("metadata.disableSuccessPage")}
|
||||||
|
/>
|
||||||
|
{/*TODO: Extract it out into a component when used more than once*/}
|
||||||
|
{!IS_SELF_HOSTED && (
|
||||||
|
<Link href="https://cal.com/pricing" target="_blank">
|
||||||
|
<Badge variant="orange" className="ml-2">
|
||||||
|
Platform Only
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
data-testid="hashedLinkCheck"
|
data-testid="hashedLinkCheck"
|
||||||
title={t("private_link")}
|
title={t("private_link")}
|
||||||
description={t("private_link_description")}
|
description={t("private_link_description", { appName: APP_NAME })}
|
||||||
checked={hashedLinkVisible}
|
checked={hashedLinkVisible}
|
||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||||
@@ -285,7 +340,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
}}
|
}}
|
||||||
className="hover:stroke-3 hover:bg-transparent hover:text-black"
|
className="hover:stroke-3 hover:bg-transparent hover:text-black"
|
||||||
type="button">
|
type="button">
|
||||||
<Icon.FiCopy />
|
<FiCopy />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
@@ -307,6 +362,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
if (e) {
|
if (e) {
|
||||||
formMethods.setValue("disableGuests", true);
|
formMethods.setValue("disableGuests", true);
|
||||||
formMethods.setValue("requiresConfirmation", false);
|
formMethods.setValue("requiresConfirmation", false);
|
||||||
|
setRequiresConfirmation(false);
|
||||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||||
} else {
|
} else {
|
||||||
formMethods.setValue("seatsPerTimeSlot", null);
|
formMethods.setValue("seatsPerTimeSlot", null);
|
||||||
@@ -352,60 +408,115 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
title={t("custom_event_name")}
|
title={t("custom_event_name")}
|
||||||
description={t("custom_event_name_description")}
|
description={t("custom_event_name_description")}
|
||||||
type="creation"
|
type="creation">
|
||||||
actionText="Create"
|
|
||||||
// Set event name back to what it was on close
|
|
||||||
actionOnClose={() => formMethods.setValue("eventName", eventType.eventName ?? "")}
|
|
||||||
actionOnClick={() => setShowEventNameTip(false)}>
|
|
||||||
<TextField
|
<TextField
|
||||||
label={t("event_name")}
|
label={t("event_name_in_calendar")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user")}
|
placeholder={t("meeting_with_user")}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName", {
|
||||||
|
onChange: (e) => {
|
||||||
|
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||||
|
return setPreviewText(getEventName(eventNameObject));
|
||||||
|
}
|
||||||
|
changePreviewText(eventNameObject, e.target.value);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="mb-0"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 text-gray-500">
|
<div className="text-sm">
|
||||||
<p>{`{HOST} = ${t("your_name")}`}</p>
|
<div className="mb-6 rounded-md bg-gray-100 p-2">
|
||||||
<p>{`{ATTENDEE} = ${t("attendee_name")}`}</p>
|
<h1 className="mb-2 ml-1 font-medium text-gray-900">{t("available_variables")}</h1>
|
||||||
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
<div className="mb-2.5 flex font-normal">
|
||||||
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Event type title}`}</p>
|
||||||
|
<p className="text-gray-900">{t("event_name_info")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Organiser}`}</p>
|
||||||
|
<p className="text-gray-900">{t("your_full_name")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Scheduler}`}</p>
|
||||||
|
<p className="text-gray-900">{t("scheduler_full_name")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 flex font-normal">
|
||||||
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Location}`}</p>
|
||||||
|
<p className="text-gray-900">{t("location_info")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||||
|
<div
|
||||||
|
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url(/calendar-preview.svg)",
|
||||||
|
}}>
|
||||||
|
<div className="m-auto flex items-center justify-center self-stretch">
|
||||||
|
<div className="mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black bg-gray-100 text-[12px] leading-3">
|
||||||
|
<p className="overflow-hidden text-ellipsis p-1.5 font-medium text-gray-900">
|
||||||
|
{previewText}
|
||||||
|
</p>
|
||||||
|
<p className="ml-1.5 text-[10px] font-normal text-gray-600">8 - 10 AM</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")}>
|
||||||
|
{t("cancel")}
|
||||||
|
</DialogClose>
|
||||||
|
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||||
|
{t("create")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
<Controller
|
<Controller
|
||||||
name="customInputs"
|
name="customInputs"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.customInputs.sort((a, b) => a.id - b.id) || []}
|
defaultValue={customInputs}
|
||||||
render={() => (
|
render={() => (
|
||||||
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
|
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
type="creation"
|
type="creation"
|
||||||
Icon={Icon.FiPlus}
|
Icon={FiPlus}
|
||||||
title={t("add_new_custom_input_field")}
|
title={t("add_new_custom_input_field")}
|
||||||
useOwnActionButtons
|
|
||||||
description={t("this_input_will_shown_booking_this_event")}>
|
description={t("this_input_will_shown_booking_this_event")}>
|
||||||
<CustomInputTypeForm
|
<CustomInputTypeForm
|
||||||
selectedCustomInput={selectedCustomInput}
|
selectedCustomInput={selectedCustomInput}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
const customInput: EventTypeCustomInput = {
|
const customInput: CustomInputParsed = {
|
||||||
id: -1,
|
id: getRandomId(),
|
||||||
eventTypeId: -1,
|
eventTypeId: -1,
|
||||||
label: values.label,
|
label: values.label,
|
||||||
placeholder: values.placeholder,
|
placeholder: values.placeholder,
|
||||||
required: values.required,
|
required: values.required,
|
||||||
type: values.type,
|
type: values.type,
|
||||||
|
options: values.options,
|
||||||
|
hasToBeCreated: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (selectedCustomInput) {
|
if (selectedCustomInput) {
|
||||||
selectedCustomInput.label = customInput.label;
|
selectedCustomInput.label = customInput.label;
|
||||||
selectedCustomInput.placeholder = customInput.placeholder;
|
selectedCustomInput.placeholder = customInput.placeholder;
|
||||||
selectedCustomInput.required = customInput.required;
|
selectedCustomInput.required = customInput.required;
|
||||||
selectedCustomInput.type = customInput.type;
|
selectedCustomInput.type = customInput.type;
|
||||||
|
selectedCustomInput.options = customInput.options || undefined;
|
||||||
|
selectedCustomInput.hasToBeCreated = false;
|
||||||
|
// Update by id
|
||||||
|
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
|
||||||
|
customInputs[inputIndex] = selectedCustomInput;
|
||||||
|
setCustomInputs(customInputs);
|
||||||
|
formMethods.setValue("customInputs", customInputs);
|
||||||
} else {
|
} else {
|
||||||
setCustomInputs(customInputs.concat(customInput));
|
const concatted = customInputs.concat({
|
||||||
formMethods.setValue("customInputs", customInputs.concat(customInput));
|
...customInput,
|
||||||
|
options: customInput.options,
|
||||||
|
});
|
||||||
|
console.log(concatted);
|
||||||
|
setCustomInputs(concatted);
|
||||||
|
formMethods.setValue("customInputs", concatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedCustomInputModalOpen(false);
|
setSelectedCustomInputModalOpen(false);
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -1,43 +1,18 @@
|
|||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
import { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||||
import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
|
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
|
||||||
import { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
import { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||||
import { EventTypeAppsList } from "@calcom/app-store/utils";
|
import { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, EmptyScreen, ErrorBoundary, Icon } from "@calcom/ui";
|
import { Button, EmptyScreen } from "@calcom/ui";
|
||||||
|
import { FiGrid } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
|
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||||
EventTypeAppCardComponentProps["eventType"];
|
EventTypeAppCardComponentProps["eventType"];
|
||||||
|
|
||||||
function AppCardWrapper({
|
|
||||||
app,
|
|
||||||
eventType,
|
|
||||||
getAppData,
|
|
||||||
setAppData,
|
|
||||||
}: {
|
|
||||||
app: RouterOutputs["viewer"]["apps"][number];
|
|
||||||
eventType: EventType;
|
|
||||||
getAppData: GetAppData;
|
|
||||||
setAppData: SetAppData;
|
|
||||||
}) {
|
|
||||||
const dirName = app.slug === "stripe" ? "stripepayment" : app.slug;
|
|
||||||
const Component = EventTypeAddonMap[dirName as keyof typeof EventTypeAddonMap];
|
|
||||||
|
|
||||||
if (!Component) {
|
|
||||||
throw new Error('No component found for "' + dirName + '"');
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
|
|
||||||
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
|
|
||||||
<Component key={app.slug} app={app} eventType={eventType} />
|
|
||||||
</EventTypeAppContext.Provider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
|
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
|
||||||
@@ -86,7 +61,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
<div className="before:border-0">
|
<div className="before:border-0">
|
||||||
{!isLoading && !installedApps?.length ? (
|
{!isLoading && !installedApps?.length ? (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={Icon.FiGrid}
|
Icon={FiGrid}
|
||||||
headline={t("empty_installed_apps_headline")}
|
headline={t("empty_installed_apps_headline")}
|
||||||
description={t("empty_installed_apps_description")}
|
description={t("empty_installed_apps_description")}
|
||||||
buttonRaw={
|
buttonRaw={
|
||||||
@@ -97,7 +72,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{installedApps?.map((app) => (
|
{installedApps?.map((app) => (
|
||||||
<AppCardWrapper
|
<EventTypeAppCard
|
||||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||||
key={app.slug}
|
key={app.slug}
|
||||||
@@ -113,7 +88,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="before:border-0">
|
<div className="before:border-0">
|
||||||
{notInstalledApps?.map((app) => (
|
{notInstalledApps?.map((app) => (
|
||||||
<AppCardWrapper
|
<EventTypeAppCard
|
||||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||||
key={app.slug}
|
key={app.slug}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
import { Controller, useFormContext, UseFormRegisterReturn, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import convertToNewDurationType, { DurationType } from "@calcom/lib/convertToNewDurationType";
|
import convertToNewDurationType, { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||||
@@ -10,15 +10,98 @@ import findDurationType from "@calcom/lib/findDurationType";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { PeriodType } from "@calcom/prisma/client";
|
import { PeriodType } from "@calcom/prisma/client";
|
||||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||||
import { Button, DateRangePicker, Icon, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
import { Button, DateRangePicker, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
||||||
|
import { FiPlus, FiTrash } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
const MinimumBookingNoticeInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
Omit<UseFormRegisterReturn<"minimumBookingNotice">, "ref">
|
||||||
|
>(function MinimumBookingNoticeInput({ ...passThroughProps }, ref) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { setValue, getValues } = useFormContext<FormValues>();
|
||||||
|
const durationTypeOptions: {
|
||||||
|
value: DurationType;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
label: t("minutes"),
|
||||||
|
value: "minutes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("hours"),
|
||||||
|
value: "hours",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("days"),
|
||||||
|
value: "days",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const [minimumBookingNoticeDisplayValues, setMinimumBookingNoticeDisplayValues] = useState<{
|
||||||
|
type: DurationType;
|
||||||
|
value: number;
|
||||||
|
}>({
|
||||||
|
type: findDurationType(getValues(passThroughProps.name)),
|
||||||
|
value: convertToNewDurationType(
|
||||||
|
"minutes",
|
||||||
|
findDurationType(getValues(passThroughProps.name)),
|
||||||
|
getValues(passThroughProps.name)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// keep hidden field in sync with minimumBookingNoticeDisplayValues
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(
|
||||||
|
passThroughProps.name,
|
||||||
|
convertToNewDurationType(
|
||||||
|
minimumBookingNoticeDisplayValues.type,
|
||||||
|
"minutes",
|
||||||
|
minimumBookingNoticeDisplayValues.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [minimumBookingNoticeDisplayValues, setValue, passThroughProps.name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
<div className="w-1/2 md:w-full">
|
||||||
|
<InputField
|
||||||
|
required
|
||||||
|
defaultValue={minimumBookingNoticeDisplayValues.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMinimumBookingNoticeDisplayValues({
|
||||||
|
...minimumBookingNoticeDisplayValues,
|
||||||
|
value: parseInt(e.target.value || "0", 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label={t("minimum_booking_notice")}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||||
|
/>
|
||||||
|
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
isSearchable={false}
|
||||||
|
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||||
|
defaultValue={durationTypeOptions.find(
|
||||||
|
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
||||||
|
)}
|
||||||
|
onChange={(input) => {
|
||||||
|
if (input) {
|
||||||
|
setMinimumBookingNoticeDisplayValues({
|
||||||
|
...minimumBookingNoticeDisplayValues,
|
||||||
|
type: input.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={durationTypeOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const minimumBookingNoticeType = useRef<DurationType>(findDurationType(eventType.minimumBookingNotice));
|
|
||||||
const prevBookingNoticeType = useRef<DurationType>(minimumBookingNoticeType.current);
|
|
||||||
|
|
||||||
const minimumBookingNoticeInDurationTypeFormValue = formMethods.watch("minimumBookingNoticeInDurationType");
|
|
||||||
|
|
||||||
const PERIOD_TYPES = [
|
const PERIOD_TYPES = [
|
||||||
{
|
{
|
||||||
@@ -49,212 +132,115 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
|||||||
defaultValue: periodType?.type,
|
defaultValue: periodType?.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onMinimumNoticeDurationTypeChange = useMemo(
|
|
||||||
() => (durationType?: DurationType) => {
|
|
||||||
if (typeof durationType === "undefined") return;
|
|
||||||
|
|
||||||
// Store current selected type in ref to use in previous run for comparrison.
|
|
||||||
minimumBookingNoticeType.current = durationType;
|
|
||||||
|
|
||||||
// Uses the previous selected type and converts the value to the new type.
|
|
||||||
// Eg day was selected before, user selects hours now, so we multiple * 24 and round up.
|
|
||||||
const minimumBookingNoticeInDurationType = convertToNewDurationType(
|
|
||||||
prevBookingNoticeType.current,
|
|
||||||
durationType,
|
|
||||||
Number(minimumBookingNoticeInDurationTypeFormValue)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Uses the round up value in the new type to calculate the value in minutes.
|
|
||||||
// We should NOT use minimumBookingNoticeInDurationTypeFormValue here, since that
|
|
||||||
// would break in the case that the user enters 90 minutes, changes the type to
|
|
||||||
// hours (that will show 2 hours), but the value in minutes would then remain 90,
|
|
||||||
// which is something else than the user things they save.
|
|
||||||
const minimumBookingNoticeInMinutes = convertToNewDurationType(
|
|
||||||
durationType,
|
|
||||||
"minutes",
|
|
||||||
Number(minimumBookingNoticeInDurationType)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Updates both form values as well as stores the current type as previous type in ref.
|
|
||||||
formMethods.setValue("minimumBookingNotice", minimumBookingNoticeInMinutes);
|
|
||||||
formMethods.setValue("minimumBookingNoticeInDurationType", minimumBookingNoticeInDurationType);
|
|
||||||
prevBookingNoticeType.current = durationType;
|
|
||||||
},
|
|
||||||
[formMethods, minimumBookingNoticeInDurationTypeFormValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the user inputs a new value for minimumBookingNoticeInDurationType,
|
|
||||||
* we calculate the value in minutes, and update this hidden field as well.
|
|
||||||
*/
|
|
||||||
const onMinimumNoticeChange = useMemo(
|
|
||||||
() => (duration: string) => {
|
|
||||||
const minimumBookingNoticeInMinutes = convertToNewDurationType(
|
|
||||||
minimumBookingNoticeType.current,
|
|
||||||
"minutes",
|
|
||||||
Number(duration)
|
|
||||||
);
|
|
||||||
|
|
||||||
formMethods.setValue("minimumBookingNotice", minimumBookingNoticeInMinutes);
|
|
||||||
},
|
|
||||||
[formMethods]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
<div className="space-y-4 lg:space-y-8">
|
||||||
<div className="w-full">
|
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||||
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
|
<div className="w-full">
|
||||||
<Controller
|
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
|
||||||
name="beforeBufferTime"
|
<Controller
|
||||||
control={formMethods.control}
|
name="beforeBufferTime"
|
||||||
defaultValue={eventType.beforeEventBuffer || 0}
|
control={formMethods.control}
|
||||||
render={({ field: { onChange, value } }) => {
|
defaultValue={eventType.beforeEventBuffer || 0}
|
||||||
const beforeBufferOptions = [
|
render={({ field: { onChange, value } }) => {
|
||||||
{
|
const beforeBufferOptions = [
|
||||||
label: t("event_buffer_default"),
|
{
|
||||||
value: 0,
|
label: t("event_buffer_default"),
|
||||||
},
|
value: 0,
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
},
|
||||||
label: minutes + " " + t("minutes"),
|
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||||
value: minutes,
|
label: minutes + " " + t("minutes"),
|
||||||
})),
|
value: minutes,
|
||||||
];
|
})),
|
||||||
return (
|
];
|
||||||
<Select
|
return (
|
||||||
isSearchable={false}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val) onChange(val.value);
|
|
||||||
}}
|
|
||||||
defaultValue={
|
|
||||||
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
|
||||||
}
|
|
||||||
options={beforeBufferOptions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
|
|
||||||
<Controller
|
|
||||||
name="afterBufferTime"
|
|
||||||
control={formMethods.control}
|
|
||||||
defaultValue={eventType.afterEventBuffer || 0}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
const afterBufferOptions = [
|
|
||||||
{
|
|
||||||
label: t("event_buffer_default"),
|
|
||||||
value: 0,
|
|
||||||
},
|
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
|
||||||
label: minutes + " " + t("minutes"),
|
|
||||||
value: minutes,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
isSearchable={false}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val) onChange(val.value);
|
|
||||||
}}
|
|
||||||
defaultValue={
|
|
||||||
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
|
|
||||||
}
|
|
||||||
options={afterBufferOptions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col space-y-4 pt-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
|
||||||
<div className="flex w-full items-end justify-end">
|
|
||||||
<Controller
|
|
||||||
name="minimumBookingNotice"
|
|
||||||
control={formMethods.control}
|
|
||||||
render={() => {
|
|
||||||
const durationTypeOptions = [
|
|
||||||
{
|
|
||||||
label: t("minutes"),
|
|
||||||
value: "minutes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("hours"),
|
|
||||||
value: "hours",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("days"),
|
|
||||||
value: "days",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-1/2 md:w-3/4">
|
|
||||||
<InputField
|
|
||||||
required
|
|
||||||
label={t("minimum_booking_notice")}
|
|
||||||
type="number"
|
|
||||||
placeholder="120"
|
|
||||||
className="mr-2 mb-0 h-[38px] rounded-[4px]"
|
|
||||||
{...formMethods.register("minimumBookingNoticeInDurationType", {
|
|
||||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
onMinimumNoticeChange(event.target.value),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<input type="hidden" {...formMethods.register("minimumBookingNotice")} />
|
|
||||||
</div>
|
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
onChange={(val) => {
|
||||||
defaultValue={durationTypeOptions.find(
|
if (val) onChange(val.value);
|
||||||
(option) => option.value === minimumBookingNoticeType.current
|
}}
|
||||||
)}
|
defaultValue={
|
||||||
onChange={(input) => onMinimumNoticeDurationTypeChange(input?.value as DurationType)}
|
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
||||||
options={durationTypeOptions}
|
}
|
||||||
|
options={beforeBufferOptions}
|
||||||
/>
|
/>
|
||||||
</>
|
);
|
||||||
);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
|
||||||
|
<Controller
|
||||||
|
name="afterBufferTime"
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={eventType.afterEventBuffer || 0}
|
||||||
|
render={({ field: { onChange, value } }) => {
|
||||||
|
const afterBufferOptions = [
|
||||||
|
{
|
||||||
|
label: t("event_buffer_default"),
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||||
|
label: minutes + " " + t("minutes"),
|
||||||
|
value: minutes,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
isSearchable={false}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (val) onChange(val.value);
|
||||||
|
}}
|
||||||
|
defaultValue={
|
||||||
|
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
|
||||||
|
}
|
||||||
|
options={afterBufferOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||||
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
<div className="w-full">
|
||||||
<Controller
|
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
||||||
name="slotInterval"
|
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||||
control={formMethods.control}
|
</div>
|
||||||
render={() => {
|
<div className="w-full">
|
||||||
const slotIntervalOptions = [
|
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
||||||
{
|
<Controller
|
||||||
label: t("slot_interval_default"),
|
name="slotInterval"
|
||||||
value: -1,
|
control={formMethods.control}
|
||||||
},
|
render={() => {
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
const slotIntervalOptions = [
|
||||||
label: minutes + " " + t("minutes"),
|
{
|
||||||
value: minutes,
|
label: t("slot_interval_default"),
|
||||||
})),
|
value: -1,
|
||||||
];
|
},
|
||||||
return (
|
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||||
<Select
|
label: minutes + " " + t("minutes"),
|
||||||
isSearchable={false}
|
value: minutes,
|
||||||
onChange={(val) => {
|
})),
|
||||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
];
|
||||||
}}
|
return (
|
||||||
defaultValue={
|
<Select
|
||||||
slotIntervalOptions.find((option) => option.value === eventType.slotInterval) ||
|
isSearchable={false}
|
||||||
slotIntervalOptions[0]
|
onChange={(val) => {
|
||||||
}
|
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||||
options={slotIntervalOptions}
|
}}
|
||||||
/>
|
defaultValue={
|
||||||
);
|
slotIntervalOptions.find((option) => option.value === eventType.slotInterval) ||
|
||||||
}}
|
slotIntervalOptions[0]
|
||||||
/>
|
}
|
||||||
|
options={slotIntervalOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hr />
|
||||||
<hr className="my-8" />
|
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="bookingLimits"
|
name="bookingLimits"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -276,8 +262,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
|||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<hr />
|
||||||
<hr className="my-8" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="periodType"
|
name="periodType"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -327,7 +312,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{period.type === "RANGE" && (
|
{period.type === "RANGE" && (
|
||||||
<div className="inline-flex space-x-2 ltr:ml-2 rtl:mr-2 rtl:space-x-reverse">
|
<div className="inline-flex space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
|
||||||
<Controller
|
<Controller
|
||||||
name="periodDates"
|
name="periodDates"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -347,7 +332,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{period.suffix ? <span className="ltr:ml-2 rtl:mr-2"> {period.suffix}</span> : null}
|
{period.suffix ? (
|
||||||
|
<span className="ltr:ml-2 ltr:mr-2 rtl:ml-2"> {period.suffix}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -410,7 +397,9 @@ const BookingLimits = () => {
|
|||||||
.map(([key, bookingAmount]) => {
|
.map(([key, bookingAmount]) => {
|
||||||
const bookingLimitKey = key as BookingLimitsKey;
|
const bookingLimitKey = key as BookingLimitsKey;
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex items-center space-x-2 text-sm" key={bookingLimitKey}>
|
<div
|
||||||
|
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse"
|
||||||
|
key={bookingLimitKey}>
|
||||||
<Input
|
<Input
|
||||||
id={`${bookingLimitKey}-limit`}
|
id={`${bookingLimitKey}-limit`}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -431,19 +420,21 @@ const BookingLimits = () => {
|
|||||||
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
const current = currentBookingLimits;
|
const current = currentBookingLimits;
|
||||||
|
const currentValue = watchBookingLimits[bookingLimitKey];
|
||||||
|
|
||||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||||
delete current[bookingLimitKey];
|
delete current[bookingLimitKey];
|
||||||
const newData = {
|
const newData = {
|
||||||
...current,
|
...current,
|
||||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||||
[val?.value as BookingLimitsKey]: watchBookingLimits[bookingLimitKey],
|
[val?.value as BookingLimitsKey]: currentValue,
|
||||||
};
|
};
|
||||||
onChange(newData);
|
onChange(newData);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
variant="icon"
|
||||||
StartIcon={Icon.FiTrash}
|
StartIcon={FiTrash}
|
||||||
color="destructive"
|
color="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const current = currentBookingLimits;
|
const current = currentBookingLimits;
|
||||||
@@ -457,7 +448,7 @@ const BookingLimits = () => {
|
|||||||
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
|
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
|
||||||
<Button
|
<Button
|
||||||
color="minimal"
|
color="minimal"
|
||||||
StartIcon={Icon.FiPlus}
|
StartIcon={FiPlus}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentBookingLimits || !watchBookingLimits) return;
|
if (!currentBookingLimits || !watchBookingLimits) return;
|
||||||
const currentKeys = Object.keys(watchBookingLimits);
|
const currentKeys = Object.keys(watchBookingLimits);
|
||||||
@@ -470,7 +461,7 @@ const BookingLimits = () => {
|
|||||||
|
|
||||||
setValue("bookingLimits", {
|
setValue("bookingLimits", {
|
||||||
...watchBookingLimits,
|
...watchBookingLimits,
|
||||||
[rest[0].value]: undefined,
|
[rest[0].value]: 1,
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
{t("add_limit")}
|
{t("add_limit")}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { EventTypeSetupInfered } from "pages/event-types/[type]";
|
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||||
|
|
||||||
import RecurringEventController from "./RecurringEventController";
|
import RecurringEventController from "./RecurringEventController";
|
||||||
|
|
||||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
const paymentAppData = getPaymentAppData(eventType);
|
||||||
|
|
||||||
const requirePayment = stripeAppData.price > 0;
|
const requirePayment = paymentAppData.price > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
|
|||||||
@@ -1,45 +1,95 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { Trans } from "next-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||||
|
import { MultiValue } from "react-select";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
import {
|
||||||
|
EventLocationType,
|
||||||
|
getEventLocationType,
|
||||||
|
MeetLocationType,
|
||||||
|
LocationType,
|
||||||
|
} from "@calcom/app-store/locations";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Icon, Label, Select, Skeleton, TextField } from "@calcom/ui";
|
import { slugify } from "@calcom/lib/slugify";
|
||||||
|
import { Button, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
|
||||||
import { slugify } from "@lib/slugify";
|
import { FiEdit2, FiCheck, FiX, FiPlus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
|
import LocationSelect, {
|
||||||
|
SingleValueLocationOption,
|
||||||
|
LocationOption,
|
||||||
|
} from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
type OptionTypeBase = {
|
const getLocationFromType = (
|
||||||
label: string;
|
type: EventLocationType["type"],
|
||||||
value: EventLocationType["type"];
|
locationOptions: Pick<EventTypeSetupProps, "locationOptions">["locationOptions"]
|
||||||
disabled?: boolean;
|
) => {
|
||||||
|
for (const locationOption of locationOptions) {
|
||||||
|
const option = locationOption.options.find((option) => option.value === type);
|
||||||
|
if (option) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultLocationValue = (options: EventTypeSetupProps["locationOptions"], type: string) => {
|
||||||
|
for (const locationType of options) {
|
||||||
|
for (const location of locationType.options) {
|
||||||
|
if (location.value === type && location.disabled === false) {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventSetupTab = (
|
export const EventSetupTab = (
|
||||||
props: Pick<EventTypeSetupInfered, "eventType" | "locationOptions" | "team" | "teamMembers">
|
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||||
) => {
|
) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { eventType, locationOptions, team } = props;
|
const { eventType, locationOptions, team } = props;
|
||||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||||
|
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
|
||||||
|
|
||||||
|
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||||
|
value: mins,
|
||||||
|
label: t("multiple_duration_mins", { count: mins }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||||
|
MultiValue<{
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}>
|
||||||
|
>(multipleDurationOptions.filter((mdOpt) => multipleDuration?.includes(mdOpt.value)));
|
||||||
|
const [defaultDuration, setDefaultDuration] = useState(
|
||||||
|
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
const openLocationModal = (type: EventLocationType["type"]) => {
|
const openLocationModal = (type: EventLocationType["type"]) => {
|
||||||
setSelectedLocation(locationOptions.find((option) => option.value === type));
|
const option = getLocationFromType(type, locationOptions);
|
||||||
|
setSelectedLocation(option);
|
||||||
setShowLocationModal(true);
|
setShowLocationModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
|
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
|
||||||
formMethods.setValue(
|
formMethods.setValue(
|
||||||
"locations",
|
"locations",
|
||||||
formMethods.getValues("locations").filter((location) => location.type !== selectedLocation.type),
|
formMethods.getValues("locations").filter((location) => {
|
||||||
|
if (location.type === LocationType.InPerson) {
|
||||||
|
return location.address !== selectedLocation.address;
|
||||||
|
}
|
||||||
|
return location.type !== selectedLocation.type;
|
||||||
|
}),
|
||||||
{ shouldValidate: true }
|
{ shouldValidate: true }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -54,14 +104,14 @@ export const EventSetupTab = (
|
|||||||
...details,
|
...details,
|
||||||
type: newLocationType,
|
type: newLocationType,
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
copy[existingIdx] = {
|
|
||||||
...formMethods.getValues("locations")[existingIdx],
|
|
||||||
...details,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
formMethods.setValue("locations", copy);
|
formMethods.setValue("locations", [
|
||||||
|
...copy,
|
||||||
|
...(newLocationType === LocationType.InPerson && editingLocationType === ""
|
||||||
|
? [{ ...details, type: newLocationType }]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
formMethods.setValue(
|
formMethods.setValue(
|
||||||
"locations",
|
"locations",
|
||||||
@@ -108,17 +158,22 @@ export const EventSetupTab = (
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultValue = getDefaultLocationValue(locationOptions, "integrations:daily");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{validLocations.length === 0 && (
|
{validLocations.length === 0 && (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Select
|
<LocationSelect
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
placeholder={t("select")}
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
onChange={(e) => {
|
menuPlacement="auto"
|
||||||
|
onChange={(e: SingleValueLocationOption) => {
|
||||||
if (e?.value) {
|
if (e?.value) {
|
||||||
const newLocationType: EventLocationType["type"] = e.value;
|
const newLocationType = e.value;
|
||||||
const eventLocationType = getEventLocationType(newLocationType);
|
const eventLocationType = getEventLocationType(newLocationType);
|
||||||
if (!eventLocationType) {
|
if (!eventLocationType) {
|
||||||
return;
|
return;
|
||||||
@@ -141,8 +196,16 @@ export const EventSetupTab = (
|
|||||||
if (!eventLocationType) {
|
if (!eventLocationType) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// We dont want to translate the string link - it doesnt exist in common.json and it gets prefixed/suffixed with __ or //
|
||||||
|
const eventLabel =
|
||||||
|
eventLocationType.defaultValueVariable === "link"
|
||||||
|
? eventLocationType.label
|
||||||
|
: t(location[eventLocationType.defaultValueVariable] || eventLocationType.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={location.type} className="mb-2 rounded-md border border-neutral-300 py-1.5 px-2">
|
<li
|
||||||
|
key={`${location.type}${index}`}
|
||||||
|
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
|
||||||
<div className="flex max-w-full justify-between">
|
<div className="flex max-w-full justify-between">
|
||||||
<div key={index} className="flex flex-grow items-center">
|
<div key={index} className="flex flex-grow items-center">
|
||||||
<img
|
<img
|
||||||
@@ -150,9 +213,7 @@ export const EventSetupTab = (
|
|||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
alt={`${eventLocationType.label} logo`}
|
alt={`${eventLocationType.label} logo`}
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
|
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">{eventLabel}</span>
|
||||||
{location[eventLocationType.defaultValueVariable] || eventLocationType.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
@@ -167,19 +228,36 @@ export const EventSetupTab = (
|
|||||||
}}
|
}}
|
||||||
aria-label={t("edit")}
|
aria-label={t("edit")}
|
||||||
className="mr-1 p-1 text-gray-500 hover:text-gray-900">
|
className="mr-1 p-1 text-gray-500 hover:text-gray-900">
|
||||||
<Icon.FiEdit2 className="h-4 w-4" />
|
<FiEdit2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
|
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
|
||||||
<Icon.FiX className="border-l-1 h-6 w-6 pl-1 text-gray-500 hover:text-gray-900 " />
|
<FiX className="border-l-1 h-6 w-6 pl-1 text-gray-500 hover:text-gray-900 " />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{validLocations.some((location) => location.type === MeetLocationType) && (
|
||||||
|
<div className="flex text-sm text-gray-600">
|
||||||
|
<FiCheck className="mt-0.5 mr-1.5 h-2 w-2.5" />
|
||||||
|
<Trans i18nKey="event_type_requres_google_cal">
|
||||||
|
<p>
|
||||||
|
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||||
|
Change it{" "}
|
||||||
|
<Link
|
||||||
|
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
|
||||||
|
className="underline">
|
||||||
|
here.
|
||||||
|
</Link>{" "}
|
||||||
|
We will fall back to Cal video if you do not change it.
|
||||||
|
</p>
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{validLocations.length > 0 && validLocations.length !== locationOptions.length && (
|
{validLocations.length > 0 && validLocations.length !== locationOptions.length && (
|
||||||
<li>
|
<li>
|
||||||
<Button StartIcon={Icon.FiPlus} color="minimal" onClick={() => setShowLocationModal(true)}>
|
<Button StartIcon={FiPlus} color="minimal" onClick={() => setShowLocationModal(true)}>
|
||||||
{t("add_location")}
|
{t("add_location")}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
@@ -195,7 +273,7 @@ export const EventSetupTab = (
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
label={t("Title")}
|
label={t("title")}
|
||||||
defaultValue={eventType.title}
|
defaultValue={eventType.title}
|
||||||
{...formMethods.register("title")}
|
{...formMethods.register("title")}
|
||||||
/>
|
/>
|
||||||
@@ -219,21 +297,96 @@ export const EventSetupTab = (
|
|||||||
setValueAs: (v) => slugify(v),
|
setValueAs: (v) => slugify(v),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<TextField
|
{multipleDuration ? (
|
||||||
required
|
<div className="space-y-4">
|
||||||
name="length"
|
<div>
|
||||||
type="number"
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
label={t("duration")}
|
{t("available_durations")}
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
</Skeleton>
|
||||||
defaultValue={eventType.length ?? 15}
|
<Select
|
||||||
onChange={(e) => {
|
isMulti
|
||||||
formMethods.setValue("length", Number(e.target.value));
|
defaultValue={selectedMultipleDuration}
|
||||||
}}
|
name="metadata.multipleDuration"
|
||||||
/>
|
isSearchable={false}
|
||||||
|
className="h-auto !min-h-[36px] text-sm"
|
||||||
|
options={multipleDurationOptions}
|
||||||
|
value={selectedMultipleDuration}
|
||||||
|
onChange={(options) => {
|
||||||
|
let newOptions = [...options];
|
||||||
|
newOptions = newOptions.sort((a, b) => {
|
||||||
|
return a?.value - b?.value;
|
||||||
|
});
|
||||||
|
const values = newOptions.map((opt) => opt.value);
|
||||||
|
setMultipleDuration(values);
|
||||||
|
setSelectedMultipleDuration(newOptions);
|
||||||
|
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||||
|
if (newOptions.length > 0) {
|
||||||
|
setDefaultDuration(newOptions[0]);
|
||||||
|
formMethods.setValue("length", newOptions[0].value);
|
||||||
|
} else {
|
||||||
|
setDefaultDuration(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newOptions.length === 1 && defaultDuration === null) {
|
||||||
|
setDefaultDuration(newOptions[0]);
|
||||||
|
formMethods.setValue("length", newOptions[0].value);
|
||||||
|
}
|
||||||
|
formMethods.setValue("metadata.multipleDuration", values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
|
{t("default_duration")}
|
||||||
|
</Skeleton>
|
||||||
|
<Select
|
||||||
|
value={defaultDuration}
|
||||||
|
isSearchable={false}
|
||||||
|
name="length"
|
||||||
|
className="text-sm"
|
||||||
|
noOptionsMessage={() => t("default_duration_no_options")}
|
||||||
|
options={selectedMultipleDuration}
|
||||||
|
onChange={(option) => {
|
||||||
|
setDefaultDuration(
|
||||||
|
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||||
|
);
|
||||||
|
if (option) formMethods.setValue("length", option.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
label={t("duration")}
|
||||||
|
defaultValue={eventType.length ?? 15}
|
||||||
|
{...formMethods.register("length")}
|
||||||
|
addOnSuffix={<>{t("minutes")}</>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||||
|
<SettingsToggle
|
||||||
|
title={t("allow_booker_to_select_duration")}
|
||||||
|
checked={multipleDuration !== undefined}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (multipleDuration !== undefined) {
|
||||||
|
setMultipleDuration(undefined);
|
||||||
|
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||||
|
formMethods.setValue("length", eventType.length);
|
||||||
|
} else {
|
||||||
|
setMultipleDuration([]);
|
||||||
|
formMethods.setValue("metadata.multipleDuration", []);
|
||||||
|
formMethods.setValue("length", 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
{t("location")}
|
{t("location")}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="locations"
|
name="locations"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -250,7 +403,9 @@ export const EventSetupTab = (
|
|||||||
saveLocation={saveLocation}
|
saveLocation={saveLocation}
|
||||||
defaultValues={formMethods.getValues("locations")}
|
defaultValues={formMethods.getValues("locations")}
|
||||||
selection={
|
selection={
|
||||||
selectedLocation ? { value: selectedLocation.value, label: selectedLocation.label } : undefined
|
selectedLocation
|
||||||
|
? { value: selectedLocation.value, label: t(selectedLocation.label), icon: selectedLocation.icon }
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
setSelectedLocation={setSelectedLocation}
|
setSelectedLocation={setSelectedLocation}
|
||||||
setEditingLocationType={setEditingLocationType}
|
setEditingLocationType={setEditingLocationType}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { SchedulingType } from "@prisma/client/";
|
import { SchedulingType } from "@prisma/client";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useMemo } from "react";
|
import { ComponentProps } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext, useWatch, Control } from "react-hook-form";
|
||||||
|
import { Options } from "react-select";
|
||||||
|
|
||||||
|
import CheckedTeamSelect, {
|
||||||
|
CheckedSelectOption,
|
||||||
|
} from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Avatar, Button, CheckedTeamSelect, Icon, Label, Select } from "@calcom/ui";
|
import { Label, Select } from "@calcom/ui";
|
||||||
|
|
||||||
interface IMemberToValue {
|
interface IMemberToValue {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
@@ -21,15 +25,76 @@ const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
|
|||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const EventTeamTab = ({
|
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
|
||||||
eventType,
|
if (a.label < b.label) {
|
||||||
team,
|
return -1;
|
||||||
teamMembers,
|
}
|
||||||
currentUserMembership,
|
if (a.label > b.label) {
|
||||||
}: Pick<EventTypeSetupInfered, "eventType" | "teamMembers" | "team" | "currentUserMembership">) => {
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FixedHosts = ({
|
||||||
|
control,
|
||||||
|
labelText,
|
||||||
|
placeholder,
|
||||||
|
options = [],
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
control: Control<FormValues>;
|
||||||
|
labelText: string;
|
||||||
|
placeholder: string;
|
||||||
|
options?: Options<CheckedSelectOption>;
|
||||||
|
} & Partial<ComponentProps<typeof CheckedTeamSelect>>) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||||
|
<div>
|
||||||
|
<Label>{labelText}</Label>
|
||||||
|
<Controller
|
||||||
|
name="hostsFixed"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange, value } }) => {
|
||||||
|
return (
|
||||||
|
<CheckedTeamSelect
|
||||||
|
isDisabled={false}
|
||||||
|
onChange={(options) => {
|
||||||
|
onChange(
|
||||||
|
options.map((option) => ({
|
||||||
|
isFixed: true,
|
||||||
|
userId: parseInt(option.value, 10),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={value
|
||||||
|
.map(
|
||||||
|
(host) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
options.find((member) => member.value === host.userId.toString())!
|
||||||
|
)
|
||||||
|
.filter(Boolean)}
|
||||||
|
controlShouldRenderValue={false}
|
||||||
|
options={options}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "teamMembers" | "team">) => {
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const schedulingType = useWatch({
|
||||||
|
control: formMethods.control,
|
||||||
|
name: "schedulingType",
|
||||||
|
});
|
||||||
|
|
||||||
const schedulingTypeOptions: {
|
const schedulingTypeOptions: {
|
||||||
value: SchedulingType;
|
value: SchedulingType;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -47,14 +112,12 @@ export const EventTeamTab = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const teamMembersToValues = useMemo(() => {
|
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||||
return teamMembers.map(mapUserToValue);
|
|
||||||
}, [teamMembers]);
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{team && (
|
{team && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col pb-8">
|
<div className="flex flex-col">
|
||||||
<Label>{t("scheduling_type")}</Label>
|
<Label>{t("scheduling_type")}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="schedulingType"
|
name="schedulingType"
|
||||||
@@ -72,75 +135,74 @@ export const EventTeamTab = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
{schedulingType === SchedulingType.COLLECTIVE && (
|
||||||
<Label>{t("team")}</Label>
|
<FixedHosts
|
||||||
<Controller
|
options={teamMembersOptions.sort(sortByLabel)}
|
||||||
name="users"
|
placeholder={t("add_attendees")}
|
||||||
|
labelText={t("team")}
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.users.map((user) => user.id.toString())}
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<CheckedTeamSelect
|
|
||||||
isDisabled={false}
|
|
||||||
onChange={(options) => onChange(options.map((user) => user.value))}
|
|
||||||
value={value
|
|
||||||
.map(
|
|
||||||
(userId) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
teamMembers.map(mapUserToValue).find((member) => member.value === userId)!
|
|
||||||
)
|
|
||||||
.filter(Boolean)}
|
|
||||||
controlShouldRenderValue={false}
|
|
||||||
options={teamMembersToValues}
|
|
||||||
placeholder={t("add_attendees")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</div>
|
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||||
)}
|
<>
|
||||||
</div>
|
<FixedHosts
|
||||||
);
|
options={teamMembersOptions.sort(sortByLabel)}
|
||||||
};
|
isOptionDisabled={(option) =>
|
||||||
|
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
|
||||||
const TeamProfileCard = ({
|
}
|
||||||
id,
|
placeholder={t("add_fixed_hosts")}
|
||||||
name,
|
labelText={t("fixed_hosts")}
|
||||||
hasPermsToDelete,
|
control={formMethods.control}
|
||||||
email,
|
/>
|
||||||
username,
|
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||||
}: {
|
<div>
|
||||||
id: number;
|
<Label>{t("round_robin_hosts")}</Label>
|
||||||
name: string | null;
|
<Controller
|
||||||
email: string;
|
name="hosts"
|
||||||
username: string | null;
|
control={formMethods.control}
|
||||||
hasPermsToDelete: boolean;
|
render={({ field: { onChange, value } }) => (
|
||||||
}) => {
|
<CheckedTeamSelect
|
||||||
const formMethods = useFormContext<FormValues>();
|
isDisabled={false}
|
||||||
const avatar = `${WEBAPP_URL}/${username}/avatar.png`;
|
onChange={(options) =>
|
||||||
|
onChange(
|
||||||
return (
|
options.map((option) => ({
|
||||||
<div className="flex items-center border border-gray-300 px-6 py-4 first:rounded-t-md last:rounded-b-md only:rounded-md">
|
isFixed: false,
|
||||||
<Avatar imageSrc={avatar} gravatarFallbackMd5="ajsdkwakhsd1231" size="md" alt="Team Image" />
|
userId: parseInt(option.value, 10),
|
||||||
<div className="flex flex-col pl-3">
|
}))
|
||||||
<div className="">
|
)
|
||||||
<span className="pr-2 text-sm font-semibold leading-none text-black">{name}</span>
|
}
|
||||||
</div>
|
value={value
|
||||||
<p className="text-sm font-normal leading-normal text-gray-700">{email}</p>
|
.map(
|
||||||
</div>
|
(host) =>
|
||||||
{hasPermsToDelete && (
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
<div className="ml-auto">
|
teamMembers
|
||||||
<Button
|
.map(mapUserToValue)
|
||||||
type="button"
|
.find((member) => member.value === host.userId.toString())!
|
||||||
size="icon"
|
)
|
||||||
color="destructive"
|
.filter(Boolean)}
|
||||||
StartIcon={Icon.FiTrash}
|
controlShouldRenderValue={false}
|
||||||
onClick={() => {
|
options={teamMembersOptions.sort(sortByLabel)}
|
||||||
formMethods.setValue(
|
isOptionDisabled={(option) =>
|
||||||
"users",
|
!!formMethods
|
||||||
formMethods.getValues("users").filter((user) => user !== id.toString())
|
.getValues("hostsFixed")
|
||||||
);
|
.find((host) => host.userId.toString() === option.value)
|
||||||
}}
|
}
|
||||||
/>
|
placeholder={t("add_attendees")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/*<TextField
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
label={t("minimum_round_robin_hosts_count")}
|
||||||
|
defaultValue={1}
|
||||||
|
{...formMethods.register("minimumHostCount")}
|
||||||
|
addOnSuffix={<>{t("hosts")}</>}
|
||||||
|
/>*/}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TbWebhook } from "react-icons/tb";
|
||||||
|
|
||||||
|
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||||
|
import { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||||
|
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Webhook } from "@calcom/prisma/client";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||||
|
import { FiPlus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
export const EventTeamWebhooksTab = ({
|
||||||
|
eventType,
|
||||||
|
team,
|
||||||
|
}: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
|
const { data: webhooks } = trpc.viewer.webhook.list.useQuery({ eventTypeId: eventType.id });
|
||||||
|
|
||||||
|
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||||
|
variant: "other",
|
||||||
|
onlyInstalled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
|
||||||
|
|
||||||
|
const subscriberUrlReserved = (subscriberUrl: string, id?: string): boolean => {
|
||||||
|
return !!webhooks?.find(
|
||||||
|
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||||
|
async onSuccess() {
|
||||||
|
setEditModalOpen(false);
|
||||||
|
await utils.viewer.webhook.list.invalidate();
|
||||||
|
showToast(t("webhook_updated_successfully"), "success");
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
showToast(`${error.message}`, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
|
||||||
|
async onSuccess() {
|
||||||
|
showToast(t("webhook_created_successfully"), "success");
|
||||||
|
await utils.viewer.webhook.list.invalidate();
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
showToast(`${error.message}`, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
|
||||||
|
if (subscriberUrlReserved(values.subscriberUrl, values.id)) {
|
||||||
|
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.payloadTemplate) {
|
||||||
|
values.payloadTemplate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createWebhookMutation.mutate({
|
||||||
|
subscriberUrl: values.subscriberUrl,
|
||||||
|
eventTriggers: values.eventTriggers,
|
||||||
|
active: values.active,
|
||||||
|
payloadTemplate: values.payloadTemplate,
|
||||||
|
secret: values.secret,
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewWebhookButton = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
data-testid="new_webhook"
|
||||||
|
StartIcon={FiPlus}
|
||||||
|
onClick={() => setCreateModalOpen(true)}>
|
||||||
|
{t("new_webhook")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{team && webhooks && !isLoading && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<>
|
||||||
|
{webhooks.length ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-8 rounded-md border">
|
||||||
|
{webhooks.map((webhook, index) => {
|
||||||
|
return (
|
||||||
|
<WebhookListItem
|
||||||
|
key={webhook.id}
|
||||||
|
webhook={webhook}
|
||||||
|
lastItem={webhooks.length === index + 1}
|
||||||
|
onEditWebhook={() => {
|
||||||
|
setEditModalOpen(true);
|
||||||
|
setWebhookToEdit(webhook);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<NewWebhookButton />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={TbWebhook}
|
||||||
|
headline={t("create_your_first_webhook")}
|
||||||
|
description={t("create_your_first_team_webhook_description", { appName: APP_NAME })}
|
||||||
|
buttonRaw={<NewWebhookButton />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New webhook dialog */}
|
||||||
|
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
|
||||||
|
<DialogContent
|
||||||
|
enableOverflow
|
||||||
|
title={t("create_webhook")}
|
||||||
|
description={t("create_webhook_team_event_type")}>
|
||||||
|
<WebhookForm
|
||||||
|
onSubmit={onCreateWebhook}
|
||||||
|
onCancel={() => setCreateModalOpen(false)}
|
||||||
|
apps={installedApps?.items.map((app) => app.slug)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{/* Edit webhook dialog */}
|
||||||
|
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||||
|
<DialogContent title={t("edit_webhook")}>
|
||||||
|
<WebhookForm
|
||||||
|
webhook={webhookToEdit}
|
||||||
|
apps={installedApps?.items.map((app) => app.slug)}
|
||||||
|
onCancel={() => setEditModalOpen(false)}
|
||||||
|
onSubmit={(values: WebhookFormSubmitData) => {
|
||||||
|
if (subscriberUrlReserved(values.subscriberUrl, webhookToEdit?.id || "")) {
|
||||||
|
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.changeSecret) {
|
||||||
|
values.secret = values.newSecret.length ? values.newSecret : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.payloadTemplate) {
|
||||||
|
values.payloadTemplate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
editWebhookMutation.mutate({
|
||||||
|
id: webhookToEdit?.id || "",
|
||||||
|
subscriberUrl: values.subscriberUrl,
|
||||||
|
eventTriggers: values.eventTriggers,
|
||||||
|
active: values.active,
|
||||||
|
payloadTemplate: values.payloadTemplate,
|
||||||
|
secret: values.secret,
|
||||||
|
eventTypeId: webhookToEdit?.eventTypeId || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { TFunction } from "next-i18next";
|
import { TFunction } from "next-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, Suspense } from "react";
|
||||||
import { Loader } from "react-feather";
|
|
||||||
import { UseFormReturn } from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
import { TbWebhook } from "react-icons/tb";
|
||||||
|
|
||||||
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -15,15 +16,14 @@ import {
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
ConfirmationDialogContent,
|
ConfirmationDialogContent,
|
||||||
Dialog,
|
Dialog,
|
||||||
Divider,
|
DropdownMenuSeparator,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
HorizontalTabs,
|
HorizontalTabs,
|
||||||
Icon,
|
|
||||||
Label,
|
Label,
|
||||||
Shell,
|
|
||||||
showToast,
|
showToast,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -31,15 +31,29 @@ import {
|
|||||||
VerticalDivider,
|
VerticalDivider,
|
||||||
VerticalTabs,
|
VerticalTabs,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
import {
|
||||||
|
FiLink,
|
||||||
|
FiCalendar,
|
||||||
|
FiClock,
|
||||||
|
FiSliders,
|
||||||
|
FiRepeat,
|
||||||
|
FiGrid,
|
||||||
|
FiZap,
|
||||||
|
FiUsers,
|
||||||
|
FiExternalLink,
|
||||||
|
FiCode,
|
||||||
|
FiTrash,
|
||||||
|
FiMoreHorizontal,
|
||||||
|
FiLoader,
|
||||||
|
} from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { ClientSuspense } from "@components/ClientSuspense";
|
|
||||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
eventType: EventTypeSetupInfered["eventType"];
|
eventType: EventTypeSetupProps["eventType"];
|
||||||
currentUserMembership: EventTypeSetupInfered["currentUserMembership"];
|
currentUserMembership: EventTypeSetupProps["currentUserMembership"];
|
||||||
team: EventTypeSetupInfered["team"];
|
team: EventTypeSetupProps["team"];
|
||||||
disableBorder?: boolean;
|
disableBorder?: boolean;
|
||||||
enabledAppsNumber: number;
|
enabledAppsNumber: number;
|
||||||
installedAppsNumber: number;
|
installedAppsNumber: number;
|
||||||
@@ -56,48 +70,51 @@ function getNavigation(props: {
|
|||||||
installedAppsNumber: number;
|
installedAppsNumber: number;
|
||||||
}) {
|
}) {
|
||||||
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props;
|
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props;
|
||||||
|
const duration =
|
||||||
|
eventType.metadata?.multipleDuration?.map((duration) => ` ${duration}`) || eventType.length;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "event_setup_tab_title",
|
name: "event_setup_tab_title",
|
||||||
href: `/event-types/${eventType.id}?tabName=setup`,
|
href: `/event-types/${eventType.id}?tabName=setup`,
|
||||||
icon: Icon.FiLink,
|
icon: FiLink,
|
||||||
info: `${eventType.length} Mins`, // TODO: Get this from props
|
info: `${duration} ${t("minute_timeUnit")}`, // TODO: Get this from props
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "availability",
|
name: "availability",
|
||||||
href: `/event-types/${eventType.id}?tabName=availability`,
|
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||||
icon: Icon.FiCalendar,
|
icon: FiCalendar,
|
||||||
info: `Working Hours`, // TODO: Get this from props
|
info: `default_schedule_name`, // TODO: Get this from props
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "event_limit_tab_title",
|
name: "event_limit_tab_title",
|
||||||
href: `/event-types/${eventType.id}?tabName=limits`,
|
href: `/event-types/${eventType.id}?tabName=limits`,
|
||||||
icon: Icon.FiClock,
|
icon: FiClock,
|
||||||
info: `event_limit_tab_description`,
|
info: `event_limit_tab_description`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "event_advanced_tab_title",
|
name: "event_advanced_tab_title",
|
||||||
href: `/event-types/${eventType.id}?tabName=advanced`,
|
href: `/event-types/${eventType.id}?tabName=advanced`,
|
||||||
icon: Icon.FiSliders,
|
icon: FiSliders,
|
||||||
info: `event_advanced_tab_description`,
|
info: `event_advanced_tab_description`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "recurring",
|
name: "recurring",
|
||||||
href: `/event-types/${eventType.id}?tabName=recurring`,
|
href: `/event-types/${eventType.id}?tabName=recurring`,
|
||||||
icon: Icon.FiRepeat,
|
icon: FiRepeat,
|
||||||
info: `recurring_event_tab_description`,
|
info: `recurring_event_tab_description`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "apps",
|
name: "apps",
|
||||||
href: `/event-types/${eventType.id}?tabName=apps`,
|
href: `/event-types/${eventType.id}?tabName=apps`,
|
||||||
icon: Icon.FiGrid,
|
icon: FiGrid,
|
||||||
//TODO: Handle proper translation with count handling
|
//TODO: Handle proper translation with count handling
|
||||||
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
|
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "workflows",
|
name: "workflows",
|
||||||
href: `/event-types/${eventType.id}?tabName=workflows`,
|
href: `/event-types/${eventType.id}?tabName=workflows`,
|
||||||
icon: Icon.FiZap,
|
icon: FiZap,
|
||||||
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -150,13 +167,20 @@ function EventTypeSingleLayout({
|
|||||||
enabledWorkflowsNumber,
|
enabledWorkflowsNumber,
|
||||||
});
|
});
|
||||||
// If there is a team put this navigation item within the tabs
|
// If there is a team put this navigation item within the tabs
|
||||||
if (team)
|
if (team) {
|
||||||
navigation.splice(2, 0, {
|
navigation.splice(2, 0, {
|
||||||
name: "scheduling_type",
|
name: "assignment",
|
||||||
href: `/event-types/${eventType.id}?tabName=team`,
|
href: `/event-types/${eventType.id}?tabName=team`,
|
||||||
icon: Icon.FiUsers,
|
icon: FiUsers,
|
||||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||||
});
|
});
|
||||||
|
navigation.push({
|
||||||
|
name: "webhooks",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=webhooks`,
|
||||||
|
icon: TbWebhook,
|
||||||
|
info: `${eventType.webhooks.filter((webhook) => webhook.active).length} ${t("active")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
return navigation;
|
return navigation;
|
||||||
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||||
|
|
||||||
@@ -171,7 +195,6 @@ function EventTypeSingleLayout({
|
|||||||
backPath="/event-types"
|
backPath="/event-types"
|
||||||
title={eventType.title + " | " + t("event_type")}
|
title={eventType.title + " | " + t("event_type")}
|
||||||
heading={eventType.title}
|
heading={eventType.title}
|
||||||
subtitle={eventType.description || ""}
|
|
||||||
CTA={
|
CTA={
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<div className="hidden items-center rounded-md px-2 sm:flex sm:hover:bg-gray-100">
|
<div className="hidden items-center rounded-md px-2 sm:flex sm:hover:bg-gray-100">
|
||||||
@@ -198,17 +221,17 @@ function EventTypeSingleLayout({
|
|||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
size="icon"
|
variant="icon"
|
||||||
href={permalink}
|
href={permalink}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
StartIcon={Icon.FiExternalLink}
|
StartIcon={FiExternalLink}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="icon"
|
variant="icon"
|
||||||
StartIcon={Icon.FiLink}
|
StartIcon={FiLink}
|
||||||
tooltip={t("copy_link")}
|
tooltip={t("copy_link")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(permalink);
|
navigator.clipboard.writeText(permalink);
|
||||||
@@ -217,15 +240,15 @@ function EventTypeSingleLayout({
|
|||||||
/>
|
/>
|
||||||
<EmbedButton
|
<EmbedButton
|
||||||
embedUrl={encodeURIComponent(embedLink)}
|
embedUrl={encodeURIComponent(embedLink)}
|
||||||
StartIcon={Icon.FiCode}
|
StartIcon={FiCode}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="icon"
|
variant="icon"
|
||||||
tooltip={t("embed")}
|
tooltip={t("embed")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="icon"
|
variant="icon"
|
||||||
StartIcon={Icon.FiTrash}
|
StartIcon={FiTrash}
|
||||||
tooltip={t("delete")}
|
tooltip={t("delete")}
|
||||||
disabled={!hasPermsToDelete}
|
disabled={!hasPermsToDelete}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
@@ -235,37 +258,42 @@ function EventTypeSingleLayout({
|
|||||||
<VerticalDivider className="hidden lg:block" />
|
<VerticalDivider className="hidden lg:block" />
|
||||||
|
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger className="block h-9 w-9 justify-center rounded-md border border-gray-200 bg-transparent text-gray-700 lg:hidden">
|
<DropdownMenuTrigger asChild>
|
||||||
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
|
<Button className="lg:hidden" StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem className="focus:ring-gray-100">
|
<DropdownMenuItem className="focus:ring-gray-100">
|
||||||
<Button
|
<DropdownItem
|
||||||
color="minimal"
|
|
||||||
StartIcon={Icon.FiExternalLink}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
type="button"
|
||||||
|
StartIcon={FiExternalLink}
|
||||||
href={permalink}
|
href={permalink}
|
||||||
rel="noreferrer"
|
rel="noreferrer">
|
||||||
className="min-w-full">
|
|
||||||
{t("preview")}
|
{t("preview")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="focus:ring-gray-100">
|
<DropdownMenuItem className="focus:ring-gray-100">
|
||||||
<Button color="minimal" StartIcon={Icon.FiLink}>
|
<DropdownItem
|
||||||
|
type="button"
|
||||||
|
StartIcon={FiLink}
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(permalink);
|
||||||
|
showToast("Link copied!", "success");
|
||||||
|
}}>
|
||||||
{t("copy_link")}
|
{t("copy_link")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="focus:ring-gray-100">
|
<DropdownMenuItem className="focus:ring-gray-100">
|
||||||
<Button
|
<DropdownItem
|
||||||
color="minimal"
|
type="button"
|
||||||
StartIcon={Icon.FiTrash}
|
color="destructive"
|
||||||
|
StartIcon={FiTrash}
|
||||||
disabled={!hasPermsToDelete}
|
disabled={!hasPermsToDelete}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}>
|
||||||
className="min-w-full">
|
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<Divider />
|
<DropdownMenuSeparator className="block sm:hidden" />
|
||||||
<div className="flex items-center rounded-md py-1.5 px-4 sm:hidden sm:hover:bg-gray-100">
|
<div className="flex items-center rounded-md py-1.5 px-4 sm:hidden sm:hover:bg-gray-100">
|
||||||
<Skeleton
|
<Skeleton
|
||||||
as={Label}
|
as={Label}
|
||||||
@@ -294,8 +322,8 @@ function EventTypeSingleLayout({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<ClientSuspense fallback={<Loader />}>
|
<Suspense fallback={<FiLoader />}>
|
||||||
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
<div className="flex flex-col xl:flex-row xl:space-x-6">
|
||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
<VerticalTabs
|
<VerticalTabs
|
||||||
className="primary-navigation"
|
className="primary-navigation"
|
||||||
@@ -310,14 +338,14 @@ function EventTypeSingleLayout({
|
|||||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mt-4 rounded-md border-neutral-200 bg-white sm:mx-0 xl:mt-0",
|
"mt-4 rounded-md border-gray-200 bg-white sm:mx-0 xl:mt-0",
|
||||||
disableBorder ? "border-0 xl:-mt-4 " : "p-2 md:border md:p-6"
|
disableBorder ? "border-0 " : "p-2 md:border md:p-6"
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ClientSuspense>
|
</Suspense>
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<ConfirmationDialogContent
|
<ConfirmationDialogContent
|
||||||
isLoading={deleteMutation.isLoading}
|
isLoading={deleteMutation.isLoading}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function RecurringEventController({
|
|||||||
{recurringEventState && (
|
{recurringEventState && (
|
||||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
<p className="text-gray-900 ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -89,7 +89,7 @@ export default function RecurringEventController({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center">
|
<div className="mt-4 flex items-center">
|
||||||
<p className="mr-2 text-neutral-900">{t("for_a_maximum_of")}</p>
|
<p className="text-gray-900 ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -105,7 +105,7 @@ export default function RecurringEventController({
|
|||||||
setRecurringEventState(newVal);
|
setRecurringEventState(newVal);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="mr-2 text-neutral-900">
|
<p className="text-gray-900 ltr:mr-2 rtl:ml-2">
|
||||||
{t("events", {
|
{t("events", {
|
||||||
count: recurringEventState.count,
|
count: recurringEventState.count,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
|
import { UnitTypeLongPlural } from "dayjs";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
|
import type { FormValues } from "pages/event-types/[type]";
|
||||||
|
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||||
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
import { Alert, Input, Label, SettingsToggle } from "@calcom/ui";
|
||||||
|
|
||||||
|
type RequiresConfirmationControllerProps = {
|
||||||
|
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||||
|
requiresConfirmation: boolean;
|
||||||
|
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
|
||||||
|
seatsEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RequiresConfirmationController({
|
||||||
|
metadata,
|
||||||
|
requiresConfirmation,
|
||||||
|
onRequiresConfirmation,
|
||||||
|
seatsEnabled,
|
||||||
|
}: RequiresConfirmationControllerProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [requiresConfirmationSetup, setRequiresConfirmationSetup] = useState(
|
||||||
|
metadata?.requiresConfirmationThreshold
|
||||||
|
);
|
||||||
|
const defaultRequiresConfirmationSetup = { time: 30, unit: "minutes" as UnitTypeLongPlural };
|
||||||
|
const formMethods = useFormContext<FormValues>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requiresConfirmation) {
|
||||||
|
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||||
|
}
|
||||||
|
}, [requiresConfirmation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="block items-start sm:flex">
|
||||||
|
<div className={!seatsEnabled ? "w-full" : ""}>
|
||||||
|
{seatsEnabled ? (
|
||||||
|
<Alert severity="warning" title="Seats option doesn't support confirmation requirement" />
|
||||||
|
) : (
|
||||||
|
<Controller
|
||||||
|
name="requiresConfirmation"
|
||||||
|
control={formMethods.control}
|
||||||
|
render={() => (
|
||||||
|
<SettingsToggle
|
||||||
|
title={t("requires_confirmation")}
|
||||||
|
description={t("requires_confirmation_description")}
|
||||||
|
checked={requiresConfirmation}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
formMethods.setValue("requiresConfirmation", val);
|
||||||
|
onRequiresConfirmation(val);
|
||||||
|
}}>
|
||||||
|
<RadioGroup.Root
|
||||||
|
defaultValue={
|
||||||
|
requiresConfirmation
|
||||||
|
? requiresConfirmationSetup === undefined
|
||||||
|
? "always"
|
||||||
|
: "notice"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val === "always") {
|
||||||
|
formMethods.setValue("requiresConfirmation", true);
|
||||||
|
onRequiresConfirmation(true);
|
||||||
|
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||||
|
setRequiresConfirmationSetup(undefined);
|
||||||
|
} else if (val === "notice") {
|
||||||
|
formMethods.setValue("requiresConfirmation", true);
|
||||||
|
onRequiresConfirmation(true);
|
||||||
|
formMethods.setValue(
|
||||||
|
"metadata.requiresConfirmationThreshold",
|
||||||
|
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div className="-ml-2 flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RadioGroup.Item
|
||||||
|
id="always"
|
||||||
|
value="always"
|
||||||
|
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||||
|
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||||
|
</RadioGroup.Item>
|
||||||
|
<Label htmlFor="always" className="!m-0 flex items-center">
|
||||||
|
{t("always_requires_confirmation")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RadioGroup.Item
|
||||||
|
id="notice"
|
||||||
|
value="notice"
|
||||||
|
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||||
|
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||||
|
</RadioGroup.Item>
|
||||||
|
<Label htmlFor="notice" className="!m-0 flex items-center">
|
||||||
|
<Trans
|
||||||
|
i18nKey="when_booked_with_less_than_notice"
|
||||||
|
defaults="When booked with less than <time></time> notice"
|
||||||
|
components={{
|
||||||
|
time: (
|
||||||
|
<div className="mx-2 flex">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
onChange={(evt) => {
|
||||||
|
const val = Number(evt.target?.value);
|
||||||
|
setRequiresConfirmationSetup({
|
||||||
|
unit:
|
||||||
|
requiresConfirmationSetup?.unit ??
|
||||||
|
defaultRequiresConfirmationSetup.unit,
|
||||||
|
time: val,
|
||||||
|
});
|
||||||
|
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
|
||||||
|
}}
|
||||||
|
className="!m-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||||
|
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
onChange={(evt) => {
|
||||||
|
const val = evt.target.value as UnitTypeLongPlural;
|
||||||
|
setRequiresConfirmationSetup({
|
||||||
|
time:
|
||||||
|
requiresConfirmationSetup?.time ??
|
||||||
|
defaultRequiresConfirmationSetup.time,
|
||||||
|
unit: val,
|
||||||
|
});
|
||||||
|
formMethods.setValue("metadata.requiresConfirmationThreshold.unit", val);
|
||||||
|
}}
|
||||||
|
className="ml-2 block h-9 rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
||||||
|
defaultValue={
|
||||||
|
metadata?.requiresConfirmationThreshold?.unit ||
|
||||||
|
defaultRequiresConfirmationSetup.unit
|
||||||
|
}>
|
||||||
|
<option value="minutes">{t("minute_timeUnit")}</option>
|
||||||
|
<option value="hours">{t("hour_timeUnit")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</SettingsToggle>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Icon, SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
|
import { FiClock, FiUser } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
function SkeletonLoader() {
|
function SkeletonLoader() {
|
||||||
return (
|
return (
|
||||||
@@ -10,7 +11,7 @@ function SkeletonLoader() {
|
|||||||
<SkeletonText className="h-4 w-24" />
|
<SkeletonText className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
<SkeletonItem />
|
<SkeletonItem />
|
||||||
@@ -31,11 +32,11 @@ function SkeletonItem() {
|
|||||||
<div className="">
|
<div className="">
|
||||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||||
<li className="flex items-center whitespace-nowrap">
|
<li className="flex items-center whitespace-nowrap">
|
||||||
<Icon.FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
<FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||||
<SkeletonText className="h-4 w-12" />
|
<SkeletonText className="h-4 w-12" />
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center whitespace-nowrap">
|
<li className="flex items-center whitespace-nowrap">
|
||||||
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
<FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||||
<SkeletonText className="h-4 w-12" />
|
<SkeletonText className="h-4 w-12" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import { CalendarSwitch } from "./CalendarSwitch";
|
import { CalendarSwitch } from "./CalendarSwitch";
|
||||||
|
|
||||||
interface IConnectedCalendarItem {
|
interface IConnectedCalendarItem {
|
||||||
@@ -21,7 +19,7 @@ interface IConnectedCalendarItem {
|
|||||||
|
|
||||||
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||||
const { name, logo, externalId, calendars, integrationType } = prop;
|
const { name, logo, externalId, calendars, integrationType } = prop;
|
||||||
const { t } = useLocale();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-row items-center p-4">
|
<div className="flex flex-row items-center p-4">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps)
|
|||||||
<>
|
<>
|
||||||
<div className="mt-6 flex flex-row">
|
<div className="mt-6 flex flex-row">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<label htmlFor="createEventsOn" className="flex text-sm font-medium text-neutral-700">
|
<label htmlFor="createEventsOn" className="flex text-sm font-medium text-gray-700">
|
||||||
{t("create_events_on")}
|
{t("create_events_on")}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { FormEvent, useRef, useState } from "react";
|
import { FormEvent, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { User } from "@calcom/prisma/client";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
|
import turndown from "@calcom/lib/turndownService";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
|
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||||
|
import { Avatar } from "@calcom/ui";
|
||||||
|
|
||||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||||
|
|
||||||
interface IUserProfile {
|
const md = new MarkdownIt("default", { html: true, breaks: true });
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormData = {
|
type FormData = {
|
||||||
bio: string;
|
bio: string;
|
||||||
};
|
};
|
||||||
|
interface IUserProfileProps {
|
||||||
|
user: IOnboardingPageProps["user"];
|
||||||
|
}
|
||||||
|
|
||||||
const UserProfile = (props: IUserProfile) => {
|
const UserProfile = (props: IUserProfileProps) => {
|
||||||
const { user } = props;
|
const { user } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||||
const bioRef = useRef<HTMLTextAreaElement>(null);
|
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||||
const {
|
defaultValues: { bio: user?.bio || "" },
|
||||||
register,
|
});
|
||||||
setValue,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<FormData>({ defaultValues: { bio: user?.bio || "" } });
|
|
||||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||||
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (_data, context) => {
|
onSuccess: async (_data, context) => {
|
||||||
@@ -64,6 +66,8 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
const onSubmit = handleSubmit((data: { bio: string }) => {
|
const onSubmit = handleSubmit((data: { bio: string }) => {
|
||||||
const { bio } = data;
|
const { bio } = data;
|
||||||
|
|
||||||
|
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||||
|
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
bio,
|
bio,
|
||||||
completedOnboarding: true,
|
completedOnboarding: true,
|
||||||
@@ -100,14 +104,21 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||||
{user && <AvatarSSR user={user} alt="Profile picture" className="h-16 w-16" />}
|
{user && (
|
||||||
|
<Avatar
|
||||||
|
alt={user.username || "user avatar"}
|
||||||
|
gravatarFallbackMd5={user.emailMd5}
|
||||||
|
size="lg"
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={avatarRef}
|
ref={avatarRef}
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="avatar"
|
name="avatar"
|
||||||
id="avatar"
|
id="avatar"
|
||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800"
|
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-gray-800 focus:outline-none focus:ring-gray-800"
|
||||||
defaultValue={imageSrc}
|
defaultValue={imageSrc}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center px-4">
|
<div className="flex items-center px-4">
|
||||||
@@ -132,25 +143,12 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<fieldset className="mt-8">
|
<fieldset className="mt-8">
|
||||||
<label htmlFor="bio" className="mb-2 block text-sm font-medium text-gray-700">
|
<Label className="mb-2 block text-sm font-medium text-gray-700">{t("about")}</Label>
|
||||||
{t("about")}
|
<Editor
|
||||||
</label>
|
getText={() => md.render(getValues("bio") || user?.bio || "")}
|
||||||
<TextArea
|
setText={(value: string) => setValue("bio", turndown(value))}
|
||||||
{...register("bio", { required: true })}
|
excludedToolbarItems={["blockType"]}
|
||||||
ref={bioRef}
|
|
||||||
name="bio"
|
|
||||||
id="bio"
|
|
||||||
className="mt-1 block h-[60px] w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
|
|
||||||
defaultValue={user?.bio || undefined}
|
|
||||||
onChange={(event) => {
|
|
||||||
setValue("bio", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{errors.bio && (
|
|
||||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
|
||||||
{t("required")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 font-sans text-sm font-normal text-gray-600 dark:text-white">
|
<p className="mt-2 font-sans text-sm font-normal text-gray-600 dark:text-white">
|
||||||
{t("few_sentences_about_yourself")}
|
{t("few_sentences_about_yourself")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { User } from "@calcom/prisma/client";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, TimezoneSelect } from "@calcom/ui";
|
import { Button, TimezoneSelect } from "@calcom/ui";
|
||||||
|
|
||||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||||
|
|
||||||
|
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||||
|
|
||||||
interface IUserSettingsProps {
|
interface IUserSettingsProps {
|
||||||
user: User;
|
user: IOnboardingPageProps["user"];
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserSettings = (props: IUserSettingsProps) => {
|
const UserSettings = (props: IUserSettingsProps) => {
|
||||||
const { user, nextStep } = props;
|
const { user, nextStep } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState(user.timeZone ?? dayjs.tz.guess());
|
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
||||||
|
const telemetry = useTelemetry();
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
control,
|
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: user?.name || "",
|
name: user?.name || "",
|
||||||
username: user?.username || "",
|
|
||||||
},
|
},
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
telemetry.event(telemetryEventTypes.onboardingStarted);
|
||||||
|
}, [telemetry]);
|
||||||
|
|
||||||
const defaultOptions = { required: true, maxLength: 255 };
|
const defaultOptions = { required: true, maxLength: 255 };
|
||||||
|
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
@@ -41,39 +47,19 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: onSuccess,
|
onSuccess: onSuccess,
|
||||||
});
|
});
|
||||||
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
|
|
||||||
const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false;
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
const onSubmit = handleSubmit((data) => {
|
||||||
if (paymentRequired) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
timeZone: selectedTimeZone,
|
timeZone: selectedTimeZone,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const [currentUsername, setCurrentUsername] = useState(user.username || "");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Username textfield */}
|
{/* Username textfield */}
|
||||||
<Controller
|
<UsernameAvailabilityField user={user} />
|
||||||
control={control}
|
|
||||||
name="username"
|
|
||||||
render={({ field: { value, ref, onChange } }) => (
|
|
||||||
<UsernameAvailability
|
|
||||||
readonly={true}
|
|
||||||
currentUsername={currentUsername}
|
|
||||||
setCurrentUsername={setCurrentUsername}
|
|
||||||
inputUsernameValue={value}
|
|
||||||
usernameRef={ref}
|
|
||||||
setInputUsernameValue={onChange}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Full name textfield */}
|
{/* Full name textfield */}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
|
|
||||||
import { StepCard, Steps } from "../../..";
|
import { StepCard, Steps } from "@calcom/ui";
|
||||||
|
|
||||||
export default function WizardLayout({
|
export default function WizardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
We don't have many layouts currently however, if they are relevant to a features - please put them in their relvant component folder
|
||||||
|
in `web/components` if you believe they will be reused in other apps please place them in `packages/features/[feature]`
|
||||||
|
|
||||||
|
Thanks Sean 🔥
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
|
|
||||||
import React, { FC } from "react";
|
|
||||||
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
|
||||||
|
|
||||||
import { Button } from "@calcom/ui";
|
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import Select from "@components/ui/form/Select";
|
|
||||||
|
|
||||||
interface OptionTypeBase {
|
|
||||||
label: string;
|
|
||||||
value: EventTypeCustomInputType;
|
|
||||||
}
|
|
||||||
|
|
||||||
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="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
|
||||||
onChange={(option) => 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 rounded-sm border-gray-300 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 rounded-sm border-gray-300 text-sm"
|
|
||||||
defaultValue={selectedCustomInput?.placeholder}
|
|
||||||
{...register("placeholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex h-5 items-center">
|
|
||||||
<input
|
|
||||||
id="required"
|
|
||||||
type="checkbox"
|
|
||||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 ltr:mr-2 rtl:ml-2"
|
|
||||||
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 flex space-x-2 sm:mt-4">
|
|
||||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{t("save")}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomInputTypeForm;
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Attendee, Booking, User } from "@prisma/client";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { JsonLd } from "react-schemaorg";
|
||||||
|
import type { EventReservation, Person, ReservationStatusType } from "schema-dts";
|
||||||
|
|
||||||
|
type EventSchemaUser = Pick<User, "name" | "email">;
|
||||||
|
type EventSchemaAttendee = Pick<Attendee, "name" | "email">;
|
||||||
|
|
||||||
|
interface EventReservationSchemaInterface {
|
||||||
|
reservationId: Booking["uid"];
|
||||||
|
eventName: Booking["title"];
|
||||||
|
startTime: Booking["startTime"];
|
||||||
|
endTime: Booking["endTime"];
|
||||||
|
organizer: EventSchemaUser | null;
|
||||||
|
attendees: EventSchemaAttendee[];
|
||||||
|
location: Booking["location"];
|
||||||
|
description: Booking["description"];
|
||||||
|
status: Booking["status"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventReservationSchema: FC<EventReservationSchemaInterface> = ({
|
||||||
|
reservationId,
|
||||||
|
eventName,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
organizer,
|
||||||
|
attendees,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
}) => {
|
||||||
|
const reservationStatus = useMemo<ReservationStatusType>(() => {
|
||||||
|
switch (status) {
|
||||||
|
case "ACCEPTED":
|
||||||
|
return "ReservationConfirmed";
|
||||||
|
case "REJECTED":
|
||||||
|
case "CANCELLED":
|
||||||
|
return "ReservationCancelled";
|
||||||
|
case "PENDING":
|
||||||
|
return "ReservationPending";
|
||||||
|
default:
|
||||||
|
return "ReservationHold";
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JsonLd<EventReservation>
|
||||||
|
item={{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "EventReservation",
|
||||||
|
reservationId,
|
||||||
|
reservationStatus,
|
||||||
|
reservationFor: {
|
||||||
|
"@type": "Event",
|
||||||
|
name: eventName,
|
||||||
|
startDate: startTime.toString(),
|
||||||
|
endDate: endTime.toString(),
|
||||||
|
organizer: organizer
|
||||||
|
? ({ "@type": "Person", name: organizer.name, email: organizer.email } as Person)
|
||||||
|
: undefined,
|
||||||
|
attendee: attendees?.map(
|
||||||
|
(person) => ({ "@type": "Person", name: person.name, email: person.email } as Person)
|
||||||
|
),
|
||||||
|
location: location || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventReservationSchema;
|
||||||
@@ -82,7 +82,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
|
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
|
||||||
{t("disable")}
|
{t("disable")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
<Button color="secondary" onClick={onCancel}>
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
|||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ltr:ml-2 rtl:mr-2"
|
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||||
onClick={handleSetup}
|
onClick={handleSetup}
|
||||||
disabled={password.length === 0 || isSubmitting}>
|
disabled={password.length === 0 || isSubmitting}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
@@ -186,13 +186,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
|||||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ltr:ml-2 rtl:mr-2"
|
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
|
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
|
||||||
{t("enable")}
|
{t("enable")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Badge, Button } from "@calcom/ui";
|
import { Badge, Button } from "@calcom/ui";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
||||||
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ShieldCheckIcon } from "@calcom/ui";
|
import { ShieldCheckIcon } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Badge, Icon, showToast, Switch } from "@calcom/ui";
|
import { Badge, showToast, Switch } from "@calcom/ui";
|
||||||
|
import { FiArrowLeft } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
export function CalendarSwitch(props: {
|
export function CalendarSwitch(props: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -62,7 +63,7 @@ export function CalendarSwitch(props: {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex space-x-2 py-1">
|
<div className="flex space-x-2 py-1 rtl:space-x-reverse">
|
||||||
<Switch
|
<Switch
|
||||||
key={props.externalId}
|
key={props.externalId}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
@@ -74,7 +75,7 @@ export function CalendarSwitch(props: {
|
|||||||
/>
|
/>
|
||||||
{props.defaultSelected && (
|
{props.defaultSelected && (
|
||||||
<Badge variant="gray">
|
<Badge variant="gray">
|
||||||
<Icon.FiArrowLeft className="mr-1" /> {t("adding_events_to")}
|
<FiArrowLeft className="mr-1" /> {t("adding_events_to")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,11 +72,7 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||||
title={t("disable_2fa")}
|
|
||||||
description={t("disable_2fa_recommendation")}
|
|
||||||
type="creation"
|
|
||||||
useOwnActionButtons>
|
|
||||||
<Form form={form} handleSubmit={handleDisable}>
|
<Form form={form} handleSubmit={handleDisable}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
@@ -94,7 +90,7 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
|
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
|
||||||
{t("disable")}
|
{t("disable")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
<Button color="secondary" onClick={onCancel}>
|
||||||
|
|||||||
@@ -130,11 +130,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||||
title={t("enable_2fa")}
|
|
||||||
description={setupDescriptions[step]}
|
|
||||||
type="creation"
|
|
||||||
useOwnActionButtons>
|
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<form onSubmit={handleSetup}>
|
<form onSubmit={handleSetup}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -174,7 +170,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ltr:ml-2 rtl:mr-2"
|
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||||
onClick={handleSetup}
|
onClick={handleSetup}
|
||||||
disabled={password.length === 0 || isSubmitting}>
|
disabled={password.length === 0 || isSubmitting}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
@@ -183,13 +179,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ltr:ml-2 rtl:mr-2"
|
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
|
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
|
||||||
{t("enable")}
|
{t("enable")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import { CheckIcon } from "@heroicons/react/solid";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import React from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { isPasswordValid } from "@calcom/lib/auth";
|
import { isPasswordValid } from "@calcom/lib/auth";
|
||||||
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import { EmailField, EmptyScreen, Label, PasswordField, TextField } from "@calcom/ui";
|
||||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
import { FiUserCheck } from "@calcom/ui/components/icon";
|
||||||
import { EmailField, Label, PasswordField, TextField, WizardForm } from "@calcom/ui";
|
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
export const AdminUserContainer = (props: React.ComponentProps<typeof AdminUser> & { userCount: number }) => {
|
||||||
|
|
||||||
const StepDone = () => {
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
if (props.userCount > 0)
|
||||||
return (
|
return (
|
||||||
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
<form
|
||||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
|
id="wizard-step-1"
|
||||||
<CheckIcon className="inline-block h-10 w-10 text-white dark:bg-white dark:text-gray-600" />
|
name="wizard-step-1"
|
||||||
</div>
|
className="space-y-4"
|
||||||
<div className="max-w-[420px] text-center">
|
onSubmit={(e) => {
|
||||||
<h2 className="mt-6 mb-1 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
|
e.preventDefault();
|
||||||
</div>
|
props.onSuccess();
|
||||||
</div>
|
}}>
|
||||||
);
|
<EmptyScreen
|
||||||
|
Icon={FiUserCheck}
|
||||||
|
headline={t("admin_user_created")}
|
||||||
|
description={t("admin_user_created_description")}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
return <AdminUser {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
export const AdminUser = (props: { onSubmit: () => void; onError: () => void; onSuccess: () => void }) => {
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -65,11 +67,11 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
props.setIsLoading(false);
|
props.onError();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
||||||
props.setIsLoading(true);
|
props.onSubmit();
|
||||||
const response = await fetch("/api/auth/setup", {
|
const response = await fetch("/api/auth/setup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -83,13 +85,19 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
router.replace(`/auth/login?email=${data.email_address.toLowerCase()}`);
|
await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: "/",
|
||||||
|
email: data.email_address.toLowerCase(),
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
props.onSuccess();
|
||||||
} else {
|
} else {
|
||||||
router.replace("/auth/setup");
|
props.onError();
|
||||||
}
|
}
|
||||||
}, onError);
|
}, onError);
|
||||||
|
|
||||||
const longWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL.length > 30;
|
const longWebsiteUrl = WEBSITE_URL.length > 30;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...formMethods}>
|
<FormProvider {...formMethods}>
|
||||||
@@ -111,7 +119,7 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
<TextField
|
<TextField
|
||||||
addOnLeading={
|
addOnLeading={
|
||||||
!longWebsiteUrl && (
|
!longWebsiteUrl && (
|
||||||
<span className="items-centerpx-3 inline-flex rounded-none text-sm text-gray-500">
|
<span className="inline-flex items-center rounded-none px-3 text-sm text-gray-500">
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -200,38 +208,3 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
title: t("administrator_user"),
|
|
||||||
description: t("lets_create_first_administrator_user"),
|
|
||||||
content: props.userCount !== 0 ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
|
|
||||||
enabled: props.userCount === 0, // to check if the wizard should show buttons to navigate through more steps
|
|
||||||
isLoading: isLoadingStep1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<main className="flex h-screen items-center bg-gray-100 print:h-full">
|
|
||||||
<WizardForm href="/auth/setup" steps={steps} containerClassname="max-w-sm" />
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
|
||||||
const ssr = await ssrInit(context);
|
|
||||||
const userCount = await prisma.user.count();
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
trpcState: ssr.dehydrate(),
|
|
||||||
userCount,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
|
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
|
||||||
|
|
||||||
|
const ChooseLicense = (
|
||||||
|
props: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
} & Omit<JSX.IntrinsicElements["form"], "onSubmit" | "onChange">
|
||||||
|
) => {
|
||||||
|
const { value: initialValue = "FREE", onChange, onSubmit, ...rest } = props;
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
{...rest}
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(value);
|
||||||
|
}}>
|
||||||
|
<RadioGroup.Root
|
||||||
|
defaultValue={initialValue}
|
||||||
|
value={value}
|
||||||
|
aria-label={t("choose_a_license")}
|
||||||
|
className="grid grid-rows-2 gap-4 md:grid-cols-2 md:grid-rows-1"
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onChange(value);
|
||||||
|
setValue(value);
|
||||||
|
}}>
|
||||||
|
<RadioGroup.Item value="FREE">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
|
||||||
|
value === "FREE" && "ring-2 ring-black"
|
||||||
|
)}>
|
||||||
|
<h2 className="font-cal text-xl text-black">{t("agplv3_license")}</h2>
|
||||||
|
<p className="font-medium text-green-800">{t("free_license_fee")}</p>
|
||||||
|
<p className="text-gray-500">{t("forever_open_and_free")}</p>
|
||||||
|
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
|
||||||
|
<li>{t("required_to_keep_your_code_open_source")}</li>
|
||||||
|
<li>{t("cannot_repackage_and_resell")}</li>
|
||||||
|
<li>{t("no_enterprise_features")}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
<RadioGroup.Item value="EE">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
|
||||||
|
value === "EE" && "ring-2 ring-black"
|
||||||
|
)}>
|
||||||
|
<h2 className="font-cal text-xl text-black">{t("ee_enterprise_license")}</h2>
|
||||||
|
<p className="font-medium text-green-800">
|
||||||
|
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500">{t("enterprise_license_includes")}</p>
|
||||||
|
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
|
||||||
|
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||||
|
<li>{t("repackage_rebrand_resell")}</li>
|
||||||
|
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChooseLicense;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { noop } from "lodash";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
|
import { Button, TextField } from "@calcom/ui";
|
||||||
|
import { FiCheck, FiExternalLink, FiLoader } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
type EnterpriseLicenseFormValues = {
|
||||||
|
licenseKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSchemaLicenseKey = (args: { callback: (valid: boolean) => void; onSuccessValidate: () => void }) =>
|
||||||
|
z.object({
|
||||||
|
licenseKey: z
|
||||||
|
.string()
|
||||||
|
.uuid({
|
||||||
|
message: "License key must follow UUID format: 8-4-4-4-12",
|
||||||
|
})
|
||||||
|
.superRefine(async (data, ctx) => {
|
||||||
|
const parse = z.string().uuid().safeParse(data);
|
||||||
|
if (parse.success) {
|
||||||
|
args.callback(true);
|
||||||
|
const response = await fetch(`${CONSOLE_URL}/api/license?key=${data}`);
|
||||||
|
args.callback(false);
|
||||||
|
const json = await response.json();
|
||||||
|
if (!json.valid) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `License key ${json.message.toLowerCase()}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
args.onSuccessValidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EnterpriseLicense = (
|
||||||
|
props: {
|
||||||
|
licenseKey?: string;
|
||||||
|
initialValue?: Partial<EnterpriseLicenseFormValues>;
|
||||||
|
onSuccessValidate: () => void;
|
||||||
|
onSubmit: (value: EnterpriseLicenseFormValues) => void;
|
||||||
|
onSuccess?: (
|
||||||
|
data: RouterOutputs["viewer"]["deploymentSetup"]["update"],
|
||||||
|
variables: RouterInputs["viewer"]["deploymentSetup"]["update"]
|
||||||
|
) => void;
|
||||||
|
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">
|
||||||
|
) => {
|
||||||
|
const { onSubmit, onSuccess = noop, onSuccessValidate = noop, ...rest } = props;
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [checkLicenseLoading, setCheckLicenseLoading] = useState(false);
|
||||||
|
const mutation = trpc.viewer.deploymentSetup.update.useMutation({
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaLicenseKey = useCallback(
|
||||||
|
() =>
|
||||||
|
makeSchemaLicenseKey({
|
||||||
|
callback: setCheckLicenseLoading,
|
||||||
|
onSuccessValidate,
|
||||||
|
}),
|
||||||
|
[setCheckLicenseLoading, onSuccessValidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formMethods = useForm<EnterpriseLicenseFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
licenseKey: props.licenseKey || "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(schemaLicenseKey()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = formMethods.handleSubmit((values) => {
|
||||||
|
onSubmit(values);
|
||||||
|
setCheckLicenseLoading(false);
|
||||||
|
mutation.mutate(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isDirty, errors } = useFormState(formMethods);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...formMethods}>
|
||||||
|
<form {...rest} className="space-y-4 rounded-md bg-white px-8 py-10" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="w-full justify-center text-lg"
|
||||||
|
EndIcon={FiExternalLink}
|
||||||
|
href="https://console.cal.com"
|
||||||
|
target="_blank">
|
||||||
|
{t("purchase_license")}
|
||||||
|
</Button>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<hr className="my-8 w-full border-[1.5px] border-gray-200" />
|
||||||
|
<span className="absolute mt-[22px] bg-white px-3.5 text-sm">OR</span>
|
||||||
|
</div>
|
||||||
|
{t("already_have_key")}
|
||||||
|
<Controller
|
||||||
|
name="licenseKey"
|
||||||
|
control={formMethods.control}
|
||||||
|
render={({ field: { onBlur, onChange, value } }) => (
|
||||||
|
<TextField
|
||||||
|
{...formMethods.register("licenseKey")}
|
||||||
|
className={classNames(
|
||||||
|
"mb-0 group-hover:border-gray-400",
|
||||||
|
(checkLicenseLoading || (errors.licenseKey === undefined && isDirty)) && "border-r-0"
|
||||||
|
)}
|
||||||
|
placeholder="xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx"
|
||||||
|
labelSrOnly={true}
|
||||||
|
value={value}
|
||||||
|
addOnFilled={false}
|
||||||
|
addOnClassname={classNames(
|
||||||
|
"hover:border-gray-300",
|
||||||
|
errors.licenseKey === undefined && isDirty && "group-hover:border-gray-400"
|
||||||
|
)}
|
||||||
|
addOnSuffix={
|
||||||
|
checkLicenseLoading ? (
|
||||||
|
<FiLoader className="h-5 w-5 animate-spin" />
|
||||||
|
) : errors.licenseKey === undefined && isDirty ? (
|
||||||
|
<FiCheck className="h-5 w-5 text-green-700" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
color={errors.licenseKey ? "warn" : ""}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
formMethods.setValue("licenseKey", e.target.value);
|
||||||
|
await formMethods.trigger("licenseKey");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnterpriseLicense;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { FiCheck } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
const StepDone = (props: {
|
||||||
|
currentStep: number;
|
||||||
|
nextStepPath: string;
|
||||||
|
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
id={`wizard-step-${props.currentStep}`}
|
||||||
|
name={`wizard-step-${props.currentStep}`}
|
||||||
|
className="flex justify-center space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
props.setIsLoading(true);
|
||||||
|
e.preventDefault();
|
||||||
|
router.replace(props.nextStepPath);
|
||||||
|
}}>
|
||||||
|
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
||||||
|
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
|
||||||
|
<FiCheck className="inline-block h-10 w-10 text-white dark:bg-white dark:text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[420px] text-center">
|
||||||
|
<h2 className="mt-6 mb-1 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepDone;
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import React, { useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { objectKeys } from "@calcom/lib/objectKeys";
|
|
||||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import { Alert, Button, Icon, showToast, TextField } from "@calcom/ui";
|
|
||||||
|
|
||||||
import ImageUploader from "@components/ImageUploader";
|
|
||||||
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.viewer.teams.update.useMutation({
|
|
||||||
onError: (err) => {
|
|
||||||
setHasErrors(true);
|
|
||||||
setErrorMessage(err.message);
|
|
||||||
},
|
|
||||||
async onSuccess() {
|
|
||||||
await utils.viewer.teams.get.invalidate();
|
|
||||||
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
|
|
||||||
objectKeys(variables).forEach((key) => {
|
|
||||||
if (variables[key as keyof typeof variables] === 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={Icon.FiLink}
|
|
||||||
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 rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/team/
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
ref={teamUrlRef}
|
|
||||||
defaultValue={team?.slug as string}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingInputContainer
|
|
||||||
Icon={Icon.FiHash}
|
|
||||||
label="Team Name"
|
|
||||||
htmlFor="name"
|
|
||||||
Input={
|
|
||||||
<input
|
|
||||||
ref={nameRef}
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
id="name"
|
|
||||||
placeholder={t("your_team_name")}
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
|
|
||||||
defaultValue={team?.name as string}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<hr />
|
|
||||||
<div>
|
|
||||||
<SettingInputContainer
|
|
||||||
Icon={Icon.FiInfo}
|
|
||||||
label={t("about")}
|
|
||||||
htmlFor="about"
|
|
||||||
Input={
|
|
||||||
<>
|
|
||||||
<textarea
|
|
||||||
ref={descriptionRef}
|
|
||||||
id="about"
|
|
||||||
name="about"
|
|
||||||
rows={3}
|
|
||||||
defaultValue={team?.bio as string}
|
|
||||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<SettingInputContainer
|
|
||||||
Icon={Icon.FiImage}
|
|
||||||
label="Logo"
|
|
||||||
htmlFor="avatar"
|
|
||||||
Input={
|
|
||||||
<>
|
|
||||||
<div className="mt-1 flex">
|
|
||||||
<input
|
|
||||||
ref={logoRef}
|
|
||||||
type="hidden"
|
|
||||||
name="avatar"
|
|
||||||
id="avatar"
|
|
||||||
placeholder="URL"
|
|
||||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 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="ml-1 py-1 text-xs">
|
|
||||||
{t("remove_logo")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<hr className="mt-6" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex items-start">
|
|
||||||
<div className="flex h-5 items-center">
|
|
||||||
<input
|
|
||||||
id="hide-branding"
|
|
||||||
name="hide-branding"
|
|
||||||
type="checkbox"
|
|
||||||
ref={hideBrandingRef}
|
|
||||||
defaultChecked={team?.hideBranding}
|
|
||||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user