Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
43fb410a28 | ||
|
|
b8bbfe8a3f | ||
|
|
1f74fc394a | ||
|
|
9a12b5a0ac | ||
|
|
2ce2c1074b | ||
|
|
0ef80da260 | ||
|
|
1e50488cfe | ||
|
|
ae9f66c9ef | ||
|
|
798a4b0090 | ||
|
|
dcd9ff72b7 | ||
|
|
0b5a0186bc | ||
|
|
d9846dca4c | ||
|
|
2fd606c38e | ||
|
|
c066a0cba9 | ||
|
|
568cc22c60 | ||
|
|
b3243c53dc | ||
|
|
80ffcf8b58 | ||
|
+1 |
77bf14a797 | ||
|
|
b188368e8f | ||
|
|
e35631aee3 | ||
|
|
733a06a672 | ||
|
|
2de3e7fc59 | ||
|
|
3137a188a0 | ||
|
|
a2a4ff934f | ||
|
|
eda1d5f393 | ||
|
|
93a14b190e | ||
|
|
2a6dc340df | ||
|
|
9cff1e3439 | ||
|
|
bb9cbd6582 | ||
|
|
81721e7bb9 | ||
|
|
454311dd52 | ||
|
|
7df04c1d50 | ||
|
|
d9a8ab3864 | ||
|
|
e3a7fd006b | ||
|
|
38d8852819 | ||
|
|
c0f34a39d1 | ||
|
|
e09dd04e7f | ||
|
|
1c2c6a62e6 | ||
|
|
33271a88b1 | ||
|
|
4086a666ae | ||
|
|
b31b8cc6df | ||
|
|
6dce57440e | ||
|
|
22a030afab | ||
|
|
e04d81cf11 | ||
|
|
bd6888b758 | ||
|
|
ccc2bdd25e | ||
|
|
a36b46bd81 | ||
|
|
a94d51c4bd | ||
|
|
3854b9c58e | ||
|
|
ba6963573e | ||
|
|
596f723cff | ||
|
|
c408d71a53 | ||
|
|
d64400d66b | ||
|
|
16eede9bb0 | ||
|
|
ba5d448811 | ||
|
|
1070cc2892 | ||
|
|
df91425643 | ||
|
|
c98bafcd52 | ||
|
|
9441a79def | ||
|
|
994df45a19 | ||
|
|
c4662d79e1 | ||
|
|
50cd1fbd83 | ||
|
|
196549c124 | ||
|
|
91274d8ee0 | ||
|
|
fa026ee393 | ||
|
|
5793901b03 | ||
|
|
030f1c6990 | ||
|
|
b201067f86 | ||
|
|
cb96b12108 | ||
|
|
e7f557f7f9 | ||
|
|
e2921c3126 | ||
|
|
d8102615b1 | ||
|
|
1010e2894a | ||
|
|
c728174c14 | ||
|
|
3889b49fa1 | ||
|
|
461389fb0b | ||
|
|
a8186e3039 | ||
|
|
5e2db12396 | ||
|
|
6bc9d2fd94 | ||
|
|
37f9bbf3fa | ||
|
|
fb5b3bbcc6 | ||
|
|
d43ee154d0 | ||
|
|
6415d42a1d | ||
|
|
ee620c7fff | ||
|
|
112efbbda2 | ||
|
|
fac0b0fa32 | ||
|
|
12d47ab949 | ||
|
|
4fef7ba5e2 | ||
|
|
6af0428a18 | ||
|
|
d66f3d1dc9 | ||
|
|
a5249b6cb5 | ||
|
|
6771c11360 | ||
|
|
002375ac72 | ||
|
|
0402e46328 | ||
|
|
02e3738a17 | ||
|
|
f822177788 | ||
|
|
1edc9c50d5 | ||
|
|
7beb17c007 | ||
|
|
ad7af451ff | ||
|
|
f513ce8687 | ||
|
|
d83883e7e4 | ||
|
|
9e001c53e8 | ||
|
|
3937b0c4c6 | ||
|
|
cbe358bb6b | ||
|
|
2b91102f3f | ||
|
|
4e7804c46d | ||
|
|
d7226fc3d4 | ||
|
|
0cb278d109 | ||
|
|
a1430101d1 | ||
|
|
9092addd9c | ||
|
|
7f14e00119 | ||
|
|
abc5ff2d97 | ||
|
|
fcde1f7a9d | ||
|
|
30eea089ba | ||
|
|
3c8cb3a9ea | ||
|
|
54f4e665a3 | ||
|
|
ef3e7fae20 | ||
|
|
bb7815464c | ||
|
|
acb6f292ec | ||
|
|
0b2250caf9 | ||
|
|
db7c3fb52f | ||
|
|
6d67808627 | ||
|
|
557b57a261 | ||
|
|
31875f7535 | ||
|
|
29c4efe4a8 | ||
|
|
7bda15aaa3 | ||
|
|
4176086ce0 | ||
|
|
04e62a10e6 | ||
|
|
0ff38dece2 | ||
|
|
8416b76bf6 | ||
|
|
c3a427346d | ||
|
|
a73b3fcaed |
No files matched your search
@@ -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"
|
||||||
|
}
|
||||||
+10
-1
@@ -11,6 +11,10 @@
|
|||||||
# - ZOOM
|
# - ZOOM
|
||||||
# - GIPHY
|
# - GIPHY
|
||||||
# - VITAL
|
# - VITAL
|
||||||
|
# - ZAPIER
|
||||||
|
# - LARK
|
||||||
|
# - WEB3
|
||||||
|
# - SALESFORCE
|
||||||
|
|
||||||
# - APP STORE **********************************************************************************************
|
# - APP STORE **********************************************************************************************
|
||||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||||
@@ -88,7 +92,7 @@ VITAL_REGION="us"
|
|||||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
|
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
|
||||||
ZAPIER_INVITE_LINK=""
|
ZAPIER_INVITE_LINK=""
|
||||||
|
|
||||||
# - LARK
|
# - LARK
|
||||||
# Needed to enable Lark Calendar integration and Login with Lark
|
# Needed to enable Lark Calendar integration and Login with Lark
|
||||||
# @see <https://open.larksuite.com/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/g>
|
# @see <https://open.larksuite.com/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/g>
|
||||||
LARK_OPEN_APP_ID=""
|
LARK_OPEN_APP_ID=""
|
||||||
@@ -100,4 +104,9 @@ LARK_OPEN_VERIFICATION_TOKEN=""
|
|||||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/rainbow/README.md
|
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/rainbow/README.md
|
||||||
ALCHEMY_API_KEY=""
|
ALCHEMY_API_KEY=""
|
||||||
INFURA_API_KEY=""
|
INFURA_API_KEY=""
|
||||||
|
|
||||||
|
# - SALESFORCE
|
||||||
|
# Used for the Salesforce (Sales Cloud) app
|
||||||
|
SALESFORCE_CONSUMER_KEY=""
|
||||||
|
SALESFORCE_CONSUMER_SECRET=""
|
||||||
# *********************************************************************************************************
|
# *********************************************************************************************************
|
||||||
+27
-10
@@ -7,14 +7,13 @@
|
|||||||
# - E-MAIL SETTINGS
|
# - E-MAIL SETTINGS
|
||||||
|
|
||||||
# - LICENSE *************************************************************************************************
|
# - LICENSE *************************************************************************************************
|
||||||
# Set this value to 'agree' to accept our license:
|
# https://github.com/calendso/calendso/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, fill your license key in here.
|
||||||
# @see https://console.cal.com
|
# @see https://console.cal.com
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
@@ -32,11 +31,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 +82,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
|
||||||
@@ -96,11 +103,12 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
|||||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_PRO_PLAN_PRODUCT_ID=
|
STRIPE_PRIVATE_KEY=
|
||||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
|
STRIPE_CLIENT_ID=
|
||||||
STRIPE_FREE_PLAN_PRODUCT_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_
|
||||||
@@ -138,5 +146,14 @@ CLOSECOM_API_KEY=
|
|||||||
# Sendgrid internal sync service
|
# Sendgrid internal sync service
|
||||||
SENDGRID_SYNC_API_KEY=
|
SENDGRID_SYNC_API_KEY=
|
||||||
|
|
||||||
# Sendgrid internal email sender
|
# Sentry
|
||||||
SENDGRID_API_KEY=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
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=
|
||||||
+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
|
|
||||||
@@ -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,13 +1,12 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -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: ""
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
# Pattern matched against refs/tags
|
# Pattern matched against refs/tags
|
||||||
tags:
|
tags:
|
||||||
@@ -8,4 +9,9 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: curl -X POST -d {} ${{ secrets.VERCEL_VERSIONED_DEPLOY_HOOK }}
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: main # Always checkout main even for tagged releases
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
- 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: [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_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
|
||||||
@@ -21,7 +22,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
|
||||||
|
- run: echo 'NODE_OPTIONS="--max_old_space_size=6144"' >> $GITHUB_ENV
|
||||||
- name: Use Node 16.x
|
- name: Use Node 16.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -80,3 +80,9 @@ apps/storybook/build-storybook.log
|
|||||||
# Snaplet
|
# Snaplet
|
||||||
.snaplet/snapshots
|
.snaplet/snapshots
|
||||||
.snaplet/structure.d.ts
|
.snaplet/structure.d.ts
|
||||||
|
|
||||||
|
# Submodules
|
||||||
|
.gitmodules
|
||||||
|
apps/api
|
||||||
|
apps/website
|
||||||
|
apps/console
|
||||||
-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
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"projectId": "cl4u26bwz7962859ply7ibuo43t",
|
"projectId": "cl4u26bwz7962859ply7ibuo43t",
|
||||||
"databaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
"targetDatabaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "0.3.0"
|
"version": "0.22.3"
|
||||||
}
|
}
|
||||||
Vendored
+2
-1
@@ -6,5 +6,6 @@
|
|||||||
},
|
},
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"spellright.language": ["en"],
|
"spellright.language": ["en"],
|
||||||
"spellright.documentTypes": ["markdown", "typescript"]
|
"spellright.documentTypes": ["markdown", "typescript"],
|
||||||
|
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<!-- PROJECT LOGO -->
|
<!-- PROJECT LOGO -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/calcom/cal.com">
|
<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">
|
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
||||||
@@ -37,7 +36,7 @@
|
|||||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
||||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||||
<a href="https://github.com/orgs/calcom/projects/9"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||||
@@ -292,7 +291,11 @@ You can deploy Cal on [Railway](https://railway.app/) using the button above. Th
|
|||||||
|
|
||||||
Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
|
Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY,NEXT_PUBLIC_LICENSE_CONSENT&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
||||||
|
|
||||||
|
### Render
|
||||||
|
|
||||||
|
[](https://render.com/deploy?repo=https://github.com/calcom/docker)
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
<!-- ROADMAP -->
|
||||||
|
|
||||||
@@ -302,6 +305,12 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
|||||||
|
|
||||||
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||||
|
|
||||||
|
<!-- RORADMAP -->
|
||||||
|
|
||||||
|
## Repo Activity
|
||||||
|
|
||||||
|
<img width="100%" src="https://repobeats.axiom.co/api/embed/6bfca2f20f39738048b6e70ca205efde46352c3d.svg" />
|
||||||
|
|
||||||
<!-- CONTRIBUTING -->
|
<!-- CONTRIBUTING -->
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
@@ -310,7 +319,18 @@ Please see our [contributing guide](/CONTRIBUTING.md).
|
|||||||
|
|
||||||
### Good First Issues
|
### Good First Issues
|
||||||
|
|
||||||
We have a list of [help wanted](https://github.com/orgs/calcom/projects/1/views/25) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||||
|
|
||||||
|
|
||||||
|
<!-- TRANSLATIONS -->
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
Don't code but still want to contribute? Join our [slack](https://cal.com/slack) and join the [#i18n channel](https://calendso.slack.com/archives/C02BY67GMMW) and let us know what language you want to translate.
|
||||||
|
|
||||||
|
                       ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
|
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
|
||||||
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
|
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
|
||||||
|
|
||||||
### Obtaining Slack Client ID and Secret and Signing Secret
|
|
||||||
|
|
||||||
To test this you will need to create a Slack app for yourself on [their apps website](https://api.slack.com/apps).
|
|
||||||
|
|
||||||
Copy and paste the app manifest below into the setting on your slack app. Be sure to replace `YOUR_DOMAIN` with your own domain or your proxy host if you're testing locally.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>App Manifest</summary>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
display_information:
|
|
||||||
name: Cal.com Slack
|
|
||||||
features:
|
|
||||||
bot_user:
|
|
||||||
display_name: Cal.com Slack
|
|
||||||
always_online: false
|
|
||||||
slash_commands:
|
|
||||||
- command: /create-event
|
|
||||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
|
||||||
description: Create an event within Cal!
|
|
||||||
should_escape: false
|
|
||||||
- command: /today
|
|
||||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
|
||||||
description: View all your bookings for today
|
|
||||||
should_escape: false
|
|
||||||
oauth_config:
|
|
||||||
redirect_urls:
|
|
||||||
- https://YOUR_DOMAIN/api/integrations/slackmessaging/callback
|
|
||||||
scopes:
|
|
||||||
bot:
|
|
||||||
- chat:write
|
|
||||||
- commands
|
|
||||||
- chat:write.public
|
|
||||||
settings:
|
|
||||||
interactivity:
|
|
||||||
is_enabled: true
|
|
||||||
request_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
|
||||||
message_menu_options_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
|
||||||
org_deploy_enabled: false
|
|
||||||
socket_mode_enabled: false
|
|
||||||
token_rotation_enabled: false
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
Add the integration as normal - slack app - add. Follow the oauth flow to add it to a server.
|
|
||||||
|
|
||||||
Next make sure you have your app running `yarn dx`. Then in the slack chat type one of these commands: `/create-event` or `/today`
|
|
||||||
|
|
||||||
> NOTE: Next you will need to setup a proxy server like [ngrok](https://ngrok.com/) to allow your local host machine to be hosted on a public https server.
|
|
||||||
|
|
||||||
### Obtaining Zoom Client ID and Secret
|
### Obtaining Zoom Client ID and Secret
|
||||||
|
|
||||||
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
||||||
@@ -437,22 +406,11 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
|||||||
4. Fill in any information you want in the "App info" tab
|
4. Fill in any information you want in the "App info" tab
|
||||||
5. Go to tab "Auth"
|
5. Go to tab "Auth"
|
||||||
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspotothercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||||
9. Click the "Save" button at the bottom footer.
|
9. Click the "Save" button at the bottom footer.
|
||||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||||
|
|
||||||
### Obtaining Vital API Keys
|
|
||||||
|
|
||||||
1. Open [Vital](https://tryvital.io/) and click Get API Keys.
|
|
||||||
1. Create a team with the team name you desire
|
|
||||||
1. Head to the configuration section on the sidebar of the dashboard
|
|
||||||
1. Click on API keys and you'll find your sandbox `api_key`.
|
|
||||||
1. Copy your `api_key` to `VITAL_API_KEY` in the .env.appStore file.
|
|
||||||
1. Open [Vital Webhooks](https://app.tryvital.io/team/{team_id}/webhooks) and add `<CALCOM BASE URL>/api/integrations/vital/webhook` as webhook for connected applications.
|
|
||||||
1. Select all events for the webhook you interested, e.g. `sleep_created`
|
|
||||||
1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file.
|
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
### Setting up SendGrid for Email reminders
|
### Setting up SendGrid for Email reminders
|
||||||
@@ -462,6 +420,7 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
|||||||
3. Copy API key to your .env file into the SENDGRID_API_KEY field
|
3. Copy API key to your .env file into the SENDGRID_API_KEY field
|
||||||
4. Go to Settings -> Sender Authentication and verify a single sender
|
4. Go to Settings -> Sender Authentication and verify a single sender
|
||||||
5. Copy the verified E-Mail to your .env file into the SENDGRID_EMAIL field
|
5. Copy the verified E-Mail to your .env file into the SENDGRID_EMAIL field
|
||||||
|
6. Add your custom sender name to the .env file into the NEXT_PUBLIC_SENDGRID_SENDER_NAME field (fallback is Cal.com)
|
||||||
|
|
||||||
### Setting up Twilio for SMS reminders
|
### Setting up Twilio for SMS reminders
|
||||||
|
|
||||||
@@ -469,14 +428,18 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
|||||||
2. Click ‘Get a Twilio phone number’
|
2. Click ‘Get a Twilio phone number’
|
||||||
3. Copy Account SID to your .env file into the TWILIO_SID field
|
3. Copy Account SID to your .env file into the TWILIO_SID field
|
||||||
4. Copy Auth Token to your .env file into the TWILIO_TOKEN field
|
4. Copy Auth Token to your .env file into the TWILIO_TOKEN field
|
||||||
5. Create a messaging service (Develop -> Messaging -> Services)
|
5. Copy your Twilio phone number to your .env file into the TWILIO_PHONE_NUMBER field
|
||||||
6. Choose any name for the messaging service
|
6. Add your own sender id to the .env file into the NEXT_PUBLIC_SENDER_ID field (fallback is Cal)
|
||||||
7. Click 'Add Senders'
|
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||||
8. Choose phone number as sender type
|
8. Choose any name for the messaging service
|
||||||
9. Add the listed phone number
|
9. Click 'Add Senders'
|
||||||
10. Leave all other fields as they are
|
10. Choose phone number as sender type
|
||||||
11. Complete setup and click ‘View my new Messaging Service’
|
11. Add the listed phone number
|
||||||
12. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
12. Leave all other fields as they are
|
||||||
|
13. Complete setup and click ‘View my new Messaging Service’
|
||||||
|
14. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
||||||
|
15. Create a verify service
|
||||||
|
16. Copy Verify Service SID to your .env file into the TWILIO_VERIFY_SID field
|
||||||
|
|
||||||
<!-- LICENSE -->
|
<!-- LICENSE -->
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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": ""
|
||||||
|
|||||||
-1
Submodule apps/api deleted from 0f5017010f.
-1
Submodule apps/console deleted from 2219900e06.
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { classNames } from "@calcom/lib";
|
|||||||
interface ExampleProps {
|
interface ExampleProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
isFullWidth?: boolean;
|
||||||
}
|
}
|
||||||
export const Example = ({ children, title }: ExampleProps) => {
|
export const Example = ({ children, title, isFullWidth = false }: ExampleProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="examples-item">
|
<div className={classNames("examples-item", isFullWidth && "w-full")}>
|
||||||
<span className="examples-item-title">{title}</span>
|
<span className={classNames("examples-item-title", isFullWidth && "mb-0 mt-2")}>{title}</span>
|
||||||
<div className="examples-item-content">{children}</div>
|
<div className="examples-item-content">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function VariantsTable({
|
|||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isDark &&
|
isDark &&
|
||||||
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#22252A]"
|
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
||||||
)}>
|
)}>
|
||||||
<div className="z-1 relative mx-auto w-full max-w-[1200px] overflow-auto pr-8 pt-6">
|
<div className="z-1 relative mx-auto w-full max-w-[1200px] overflow-auto pr-8 pt-6">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -49,6 +50,6 @@
|
|||||||
"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.7.4",
|
||||||
"vite": "^3.1.0"
|
"vite": "^2.9.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -68,3 +68,5 @@ public/embed
|
|||||||
|
|
||||||
# Copied app-store images
|
# Copied app-store images
|
||||||
public/app-store
|
public/app-store
|
||||||
|
# Sentry
|
||||||
|
.sentryclirc
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
14.17
|
v16
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
declare module "@wojtekmaj/react-daterange-picker/dist/entry.nostyle" {
|
|
||||||
import { CalendarProps } from "react-calendar";
|
|
||||||
export type DateRangePickerCalendarProps = Omit<
|
|
||||||
CalendarProps,
|
|
||||||
"calendarClassName" | "onChange" | "value"
|
|
||||||
> & {
|
|
||||||
calendarClassName?: string;
|
|
||||||
onChange: (value: [Date, Date]) => void;
|
|
||||||
value: [Date, Date];
|
|
||||||
clearIcon: JSX.Element | null;
|
|
||||||
calendarIcon: JSX.Element | null;
|
|
||||||
rangeDivider: JSX.Element | null;
|
|
||||||
};
|
|
||||||
export default function DateRangePicker(props: DateRangePickerCalendarProps): JSX.Element;
|
|
||||||
}
|
|
||||||
@@ -598,60 +598,6 @@ paths:
|
|||||||
summary: Gets and stores the OAuth token
|
summary: Gets and stores the OAuth token
|
||||||
tags:
|
tags:
|
||||||
- Integrations
|
- Integrations
|
||||||
/api/user/profile:
|
|
||||||
patch:
|
|
||||||
description: Updates a user's profile.
|
|
||||||
summary: Updates a user's profile
|
|
||||||
tags:
|
|
||||||
- User
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description:
|
|
||||||
type: string
|
|
||||||
avatar:
|
|
||||||
type: string
|
|
||||||
timeZone:
|
|
||||||
type: string
|
|
||||||
weekStart:
|
|
||||||
type: string
|
|
||||||
hideBranding:
|
|
||||||
type: string
|
|
||||||
theme:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
"401":
|
|
||||||
description: Unauthorized
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
"500":
|
|
||||||
description: Internal Server Error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
/api/me:
|
/api/me:
|
||||||
get:
|
get:
|
||||||
description: Gets current user's profile.
|
description: Gets current user's profile.
|
||||||
@@ -686,71 +632,6 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
/api/user/membership:
|
|
||||||
get:
|
|
||||||
description: Get a list of the teams the user has joined.
|
|
||||||
summary: Get the teams a user is joined to
|
|
||||||
tags:
|
|
||||||
- User
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
"401":
|
|
||||||
description: Unauthorized
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
"500":
|
|
||||||
description: Internal Server Error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
patch:
|
|
||||||
description: Accept team invitation
|
|
||||||
summary: Accept team invitation.
|
|
||||||
tags:
|
|
||||||
- User
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
teamId:
|
|
||||||
type: string
|
|
||||||
delete:
|
|
||||||
description: Leave team or decline membership invite of current user
|
|
||||||
summary: Leave team or decline team invite.
|
|
||||||
tags:
|
|
||||||
- User
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
teamId:
|
|
||||||
type: string
|
|
||||||
"/api/{team}":
|
"/api/{team}":
|
||||||
delete:
|
delete:
|
||||||
description: Deletes a team
|
description: Deletes a team
|
||||||
|
|||||||
@@ -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/Icon";
|
import { Icon } from "@calcom/ui";
|
||||||
|
|
||||||
export default function AddToHomescreen() {
|
export default function AddToHomescreen() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
|||||||
|
|
||||||
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: true }]);
|
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryCell
|
<QueryCell
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import { App as AppType } from "@calcom/types/App";
|
|
||||||
import { Button, SkeletonButton } from "@calcom/ui";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import Shell from "@calcom/ui/Shell";
|
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
|
||||||
|
|
||||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
|
||||||
|
|
||||||
const Component = ({
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
logo,
|
|
||||||
body,
|
|
||||||
categories,
|
|
||||||
author,
|
|
||||||
price = 0,
|
|
||||||
commission,
|
|
||||||
isGlobal = false,
|
|
||||||
feeType,
|
|
||||||
docs,
|
|
||||||
website,
|
|
||||||
email,
|
|
||||||
tos,
|
|
||||||
privacy,
|
|
||||||
isProOnly,
|
|
||||||
}: Parameters<typeof App>[0]) => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
|
||||||
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
const handleOpenChange = () => {
|
|
||||||
utils.invalidateQueries(["viewer.integrations"]);
|
|
||||||
router.replace("/apps/installed");
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutation = useAddAppMutation(null, {
|
|
||||||
onSuccess: () => {
|
|
||||||
showToast("App successfully installed", "success");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (error instanceof Error) showToast(error.message || "App could not be installed", "error");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
useGrouping: false,
|
|
||||||
}).format(price);
|
|
||||||
|
|
||||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
|
||||||
const appCredentials = trpc.useQuery(["viewer.appCredentialsByType", { appType: type }], {
|
|
||||||
onSuccess(data) {
|
|
||||||
setExistingCredentials(data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="-mx-4 md:-mx-8">
|
|
||||||
<div className="px-8">
|
|
||||||
<Link href="/apps">
|
|
||||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
|
||||||
<Icon.FiChevronLeft className="h-5 w-5" /> {t("browse_apps")}
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
|
||||||
<div className="flex">
|
|
||||||
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
|
|
||||||
<header className="px-4 py-2">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-sm text-gray-500">
|
|
||||||
<span className="capitalize">{categories[0]}</span> • {t("published_by", { author })}
|
|
||||||
</h2>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
|
||||||
{!appCredentials.isLoading ? (
|
|
||||||
isGlobal ||
|
|
||||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
|
|
||||||
{existingCredentials.length > 0
|
|
||||||
? t("active_install", { count: existingCredentials.length })
|
|
||||||
: t("globally_install")}
|
|
||||||
</Button>
|
|
||||||
{!isGlobal && (
|
|
||||||
<InstallAppButton
|
|
||||||
type={type}
|
|
||||||
isProOnly={isProOnly}
|
|
||||||
render={({ useDefaultComponent, ...props }) => {
|
|
||||||
if (useDefaultComponent) {
|
|
||||||
props = {
|
|
||||||
onClick: () => {
|
|
||||||
mutation.mutate({ type });
|
|
||||||
},
|
|
||||||
loading: mutation.isLoading,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button StartIcon={Icon.FiPlus} {...props} data-testid="install-app-button">
|
|
||||||
{t("add_another")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : existingCredentials.length > 0 ? (
|
|
||||||
<DisconnectIntegration
|
|
||||||
id={existingCredentials[0]}
|
|
||||||
render={(btnProps) => (
|
|
||||||
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
|
|
||||||
{t("disconnect")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<InstallAppButton
|
|
||||||
type={type}
|
|
||||||
isProOnly={isProOnly}
|
|
||||||
render={({ useDefaultComponent, ...props }) => {
|
|
||||||
if (useDefaultComponent) {
|
|
||||||
props = {
|
|
||||||
onClick: () => {
|
|
||||||
mutation.mutate({ type });
|
|
||||||
},
|
|
||||||
loading: mutation.isLoading,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button data-testid="install-app-button" {...props}>
|
|
||||||
{t("install_app")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<SkeletonButton width="24" height="10" />
|
|
||||||
)}
|
|
||||||
{price !== 0 && (
|
|
||||||
<small className="block text-right">
|
|
||||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
|
||||||
{feeType === "monthly" && "/" + t("month")}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="justify-between px-8 py-10 md:flex">
|
|
||||||
<div className="prose-sm prose mb-6">{body}</div>
|
|
||||||
<div className="md:max-w-80 flex-1 md:ml-8">
|
|
||||||
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
|
||||||
<div className="space-x-2">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<Link href={"/apps/categories/" + category} key={category}>
|
|
||||||
<a>
|
|
||||||
<Badge variant="success">{category}</Badge>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<h4 className="mt-8 font-medium text-gray-900 ">{t("pricing")}</h4>
|
|
||||||
<small>
|
|
||||||
{price === 0 ? (
|
|
||||||
"Free"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
useGrouping: false,
|
|
||||||
}).format(price)}
|
|
||||||
{feeType === "monthly" && "/" + t("month")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</small>
|
|
||||||
<h4 className="mt-8 mb-2 font-medium text-gray-900 ">{t("learn_more")}</h4>
|
|
||||||
<ul className="prose -ml-1 -mr-1 text-xs leading-5">
|
|
||||||
{docs && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-500 no-underline hover:underline"
|
|
||||||
href={docs}>
|
|
||||||
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4" />
|
|
||||||
{t("documentation")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{website && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-500 no-underline hover:underline"
|
|
||||||
href={website}>
|
|
||||||
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4" />
|
|
||||||
{website.replace("https://", "")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{email && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-500 no-underline hover:underline"
|
|
||||||
href={"mailto:" + email}>
|
|
||||||
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4" />
|
|
||||||
|
|
||||||
{email}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{tos && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-500 no-underline hover:underline"
|
|
||||||
href={tos}>
|
|
||||||
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4" />
|
|
||||||
{t("terms_of_service")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{privacy && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-500 no-underline hover:underline"
|
|
||||||
href={privacy}>
|
|
||||||
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4" />
|
|
||||||
{t("privacy_policy")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
<hr className="my-6" />
|
|
||||||
<small className="leading-1 block text-gray-500">
|
|
||||||
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
|
|
||||||
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
|
|
||||||
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
|
|
||||||
</small>
|
|
||||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
|
||||||
<Icon.FiFlag className="inline h-3 w-3" /> Report App
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App(props: {
|
|
||||||
name: string;
|
|
||||||
type: AppType["type"];
|
|
||||||
isGlobal?: AppType["isGlobal"];
|
|
||||||
logo: string;
|
|
||||||
body: React.ReactNode;
|
|
||||||
categories: string[];
|
|
||||||
author: string;
|
|
||||||
pro?: boolean;
|
|
||||||
price?: number;
|
|
||||||
commission?: number;
|
|
||||||
feeType?: AppType["feeType"];
|
|
||||||
docs?: string;
|
|
||||||
website?: string;
|
|
||||||
email: string; // required
|
|
||||||
tos?: string;
|
|
||||||
privacy?: string;
|
|
||||||
licenseRequired: AppType["licenseRequired"];
|
|
||||||
isProOnly: AppType["isProOnly"];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Shell large isPublic>
|
|
||||||
{props.licenseRequired ? (
|
|
||||||
<LicenseRequired>
|
|
||||||
<Component {...props} />
|
|
||||||
</LicenseRequired>
|
|
||||||
) : (
|
|
||||||
<Component {...props} />
|
|
||||||
)}
|
|
||||||
</Shell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Badge, ListItemText } from "@calcom/ui";
|
||||||
|
|
||||||
|
interface AppListCardProps {
|
||||||
|
logo?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppListCard(props: AppListCardProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { logo, title, description, actions, isDefault } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center 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>
|
||||||
|
{isDefault ? <Badge variant="green">{t("default")}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<ListItemText component="p">{description}</ListItemText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
</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}</>;
|
|
||||||
};
|
|
||||||
@@ -23,7 +23,7 @@ const DestinationCalendarSelector = ({
|
|||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
}: Props): JSX.Element | null => {
|
}: Props): JSX.Element | null => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||||
|
|
||||||
// Extra styles to show prefixed text in react-select
|
// Extra styles to show prefixed text in react-select
|
||||||
|
|||||||
+256
-226
@@ -4,14 +4,22 @@ 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 { Icon } from "@calcom/ui/Icon";
|
import {
|
||||||
import { Button } from "@calcom/ui/components";
|
Button,
|
||||||
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
Dialog,
|
||||||
import { HorizontalTabs, showToast, Switch } from "@calcom/ui/v2";
|
DialogClose,
|
||||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/v2/core/Dialog";
|
DialogContent,
|
||||||
|
HorizontalTabs,
|
||||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
Icon,
|
||||||
|
InputLeading,
|
||||||
|
Label,
|
||||||
|
showToast,
|
||||||
|
Switch,
|
||||||
|
TextArea,
|
||||||
|
TextField,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
|
||||||
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";
|
||||||
@@ -36,6 +44,7 @@ type PreviewState = {
|
|||||||
palette: {
|
palette: {
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
};
|
};
|
||||||
|
hideEventTypeDetails: boolean;
|
||||||
};
|
};
|
||||||
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
||||||
|
|
||||||
@@ -117,10 +126,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({
|
||||||
@@ -133,6 +144,7 @@ const getEmbedUIInstructionString = ({
|
|||||||
brandColor,
|
brandColor,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hideEventTypeDetails: hideEventTypeDetails,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -245,18 +257,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]) {
|
||||||
@@ -489,7 +504,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"
|
||||||
@@ -539,7 +556,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>}
|
||||||
@@ -580,7 +597,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}`}
|
||||||
@@ -610,19 +627,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={() => {
|
||||||
@@ -633,7 +650,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>
|
||||||
))}
|
))}
|
||||||
@@ -679,6 +696,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
theme: Theme.auto,
|
theme: Theme.auto,
|
||||||
floatingPopup: {},
|
floatingPopup: {},
|
||||||
elementClick: {},
|
elementClick: {},
|
||||||
|
hideEventTypeDetails: false,
|
||||||
palette: {
|
palette: {
|
||||||
brandColor: "#000000",
|
brandColor: "#000000",
|
||||||
},
|
},
|
||||||
@@ -743,6 +761,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,
|
||||||
@@ -788,7 +807,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">
|
||||||
@@ -801,245 +820,258 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
{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={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
<div className="flex flex-col overflow-y-auto">
|
||||||
<Collapsible
|
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||||
open={isEmbedCustomizationOpen}
|
<Collapsible
|
||||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
open={isEmbedCustomizationOpen}
|
||||||
<CollapsibleTrigger
|
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||||
type="button"
|
<CollapsibleTrigger
|
||||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
type="button"
|
||||||
<div>
|
className="flex w-full items-center text-base font-medium text-gray-900">
|
||||||
{embedType === "inline"
|
<div>
|
||||||
? "Inline Embed Customization"
|
{embedType === "inline"
|
||||||
: embedType === "floating-popup"
|
? "Inline Embed Customization"
|
||||||
? "Floating Popup Customization"
|
: embedType === "floating-popup"
|
||||||
: "Element Click Customization"}
|
? "Floating Popup Customization"
|
||||||
</div>
|
: "Element Click Customization"}
|
||||||
<Icon.FiChevronRight
|
|
||||||
className={`${
|
|
||||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
|
||||||
} ml-auto h-5 w-5 text-neutral-500`}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="text-sm">
|
|
||||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
|
||||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
|
||||||
<div className="text-sm">Embed Window Sizing</div>
|
|
||||||
<div className="justify-left flex items-center">
|
|
||||||
<TextField
|
|
||||||
name="width"
|
|
||||||
labelProps={{ className: "hidden" }}
|
|
||||||
required
|
|
||||||
value={previewState.inline.width}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPreviewState((previewState) => {
|
|
||||||
const width = e.target.value || "100%";
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previewState,
|
|
||||||
inline: {
|
|
||||||
...previewState.inline,
|
|
||||||
width,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
addOnLeading={<InputLeading>W</InputLeading>}
|
|
||||||
/>
|
|
||||||
<span className="p-2">×</span>
|
|
||||||
<TextField
|
|
||||||
labelProps={{ className: "hidden" }}
|
|
||||||
name="height"
|
|
||||||
value={previewState.inline.height}
|
|
||||||
required
|
|
||||||
onChange={(e) => {
|
|
||||||
const height = e.target.value || "100%";
|
|
||||||
|
|
||||||
setPreviewState((previewState) => {
|
|
||||||
return {
|
|
||||||
...previewState,
|
|
||||||
inline: {
|
|
||||||
...previewState.inline,
|
|
||||||
height,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
addOnLeading={<InputLeading>H</InputLeading>}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Icon.FiChevronRight
|
||||||
<div
|
className={`${
|
||||||
className={classNames(
|
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||||
"mt-4 items-center justify-between",
|
} ml-auto h-5 w-5 text-gray-500`}
|
||||||
embedType === "floating-popup" ? "" : "hidden"
|
|
||||||
)}>
|
|
||||||
<div className="mb-2 text-sm">Button Text</div>
|
|
||||||
{/* Default Values should come from preview iframe */}
|
|
||||||
<TextField
|
|
||||||
name="buttonText"
|
|
||||||
labelProps={{ className: "hidden" }}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPreviewState((previewState) => {
|
|
||||||
return {
|
|
||||||
...previewState,
|
|
||||||
floatingPopup: {
|
|
||||||
...previewState.floatingPopup,
|
|
||||||
buttonText: e.target.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
defaultValue="Book my Cal"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</CollapsibleTrigger>
|
||||||
<div
|
<CollapsibleContent className="text-sm">
|
||||||
className={classNames(
|
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||||
"mt-4 flex items-center justify-start",
|
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
<div className="text-sm">Embed Window Sizing</div>
|
||||||
)}>
|
<div className="justify-left flex items-center">
|
||||||
<Switch
|
<TextField
|
||||||
defaultChecked={true}
|
labelProps={{ className: "hidden" }}
|
||||||
onCheckedChange={(checked) => {
|
required
|
||||||
setPreviewState((previewState) => {
|
value={previewState.inline.width}
|
||||||
return {
|
onChange={(e) => {
|
||||||
...previewState,
|
setPreviewState((previewState) => {
|
||||||
floatingPopup: {
|
const width = e.target.value || "100%";
|
||||||
...previewState.floatingPopup,
|
|
||||||
hideButtonIcon: !checked,
|
return {
|
||||||
},
|
...previewState,
|
||||||
};
|
inline: {
|
||||||
});
|
...previewState.inline,
|
||||||
}}
|
width,
|
||||||
/>
|
},
|
||||||
<div className="text-sm">Display Calendar Icon Button</div>
|
};
|
||||||
</div>
|
});
|
||||||
<div
|
}}
|
||||||
className={classNames(
|
addOnLeading={<InputLeading>W</InputLeading>}
|
||||||
"mt-4 items-center justify-between",
|
/>
|
||||||
embedType === "floating-popup" ? "" : "hidden"
|
<span className="p-2">×</span>
|
||||||
)}>
|
<TextField
|
||||||
<div className="mb-2">Position of Button</div>
|
labelProps={{ className: "hidden" }}
|
||||||
<Select
|
value={previewState.inline.height}
|
||||||
onChange={(position) => {
|
required
|
||||||
setPreviewState((previewState) => {
|
onChange={(e) => {
|
||||||
return {
|
const height = e.target.value || "100%";
|
||||||
...previewState,
|
|
||||||
floatingPopup: {
|
setPreviewState((previewState) => {
|
||||||
...previewState.floatingPopup,
|
return {
|
||||||
buttonPosition: position?.value,
|
...previewState,
|
||||||
},
|
inline: {
|
||||||
};
|
...previewState.inline,
|
||||||
});
|
height,
|
||||||
}}
|
},
|
||||||
defaultValue={FloatingPopupPositionOptions[0]}
|
};
|
||||||
options={FloatingPopupPositionOptions}
|
});
|
||||||
/>
|
}}
|
||||||
</div>
|
addOnLeading={<InputLeading>H</InputLeading>}
|
||||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
/>
|
||||||
<div>Button Color</div>
|
</div>
|
||||||
<div className="w-full">
|
</div>
|
||||||
<ColorPicker
|
<div
|
||||||
defaultValue="#000000"
|
className={classNames(
|
||||||
onChange={(color) => {
|
"mt-4 items-center justify-between",
|
||||||
|
embedType === "floating-popup" ? "" : "hidden"
|
||||||
|
)}>
|
||||||
|
<div className="mb-2 text-sm">Button Text</div>
|
||||||
|
{/* Default Values should come from preview iframe */}
|
||||||
|
<TextField
|
||||||
|
labelProps={{ className: "hidden" }}
|
||||||
|
onChange={(e) => {
|
||||||
setPreviewState((previewState) => {
|
setPreviewState((previewState) => {
|
||||||
return {
|
return {
|
||||||
...previewState,
|
...previewState,
|
||||||
floatingPopup: {
|
floatingPopup: {
|
||||||
...previewState.floatingPopup,
|
...previewState.floatingPopup,
|
||||||
buttonColor: color,
|
buttonText: e.target.value,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
defaultValue="Book my Cal"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
className={classNames(
|
||||||
<div>Text Color</div>
|
"mt-4 flex items-center justify-start",
|
||||||
<div className="w-full">
|
embedType === "floating-popup" ? "space-x-2 rtl:space-x-reverse" : "hidden"
|
||||||
<ColorPicker
|
)}>
|
||||||
defaultValue="#000000"
|
<Switch
|
||||||
onChange={(color) => {
|
defaultChecked={true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
setPreviewState((previewState) => {
|
setPreviewState((previewState) => {
|
||||||
return {
|
return {
|
||||||
...previewState,
|
...previewState,
|
||||||
floatingPopup: {
|
floatingPopup: {
|
||||||
...previewState.floatingPopup,
|
...previewState.floatingPopup,
|
||||||
buttonTextColor: color,
|
hideButtonIcon: !checked,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="text-sm">Display Calendar Icon Button</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
</CollapsibleContent>
|
className={classNames(
|
||||||
</Collapsible>
|
"mt-4 items-center justify-between",
|
||||||
</div>
|
embedType === "floating-popup" ? "" : "hidden"
|
||||||
<hr className="mt-4" />
|
)}>
|
||||||
<div className="mt-4 font-medium">
|
<div className="mb-2">Position of Button</div>
|
||||||
<Collapsible
|
|
||||||
open={isBookingCustomizationOpen}
|
|
||||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
|
||||||
<CollapsibleTrigger className="flex w-full" type="button">
|
|
||||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
|
||||||
<Icon.FiChevronRight
|
|
||||||
className={`${
|
|
||||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
|
||||||
} ml-auto h-5 w-5 text-neutral-500`}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="mt-6 text-sm">
|
|
||||||
<Label className="">
|
|
||||||
<div className="mb-2">Theme</div>
|
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
onChange={(position) => {
|
||||||
defaultValue={ThemeOptions[0]}
|
|
||||||
components={{
|
|
||||||
Control: ThemeSelectControl,
|
|
||||||
}}
|
|
||||||
onChange={(option) => {
|
|
||||||
if (!option) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewState((previewState) => {
|
setPreviewState((previewState) => {
|
||||||
return {
|
return {
|
||||||
...previewState,
|
...previewState,
|
||||||
theme: option.value,
|
floatingPopup: {
|
||||||
|
...previewState.floatingPopup,
|
||||||
|
buttonPosition: position?.value,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
options={ThemeOptions}
|
defaultValue={FloatingPopupPositionOptions[0]}
|
||||||
|
options={FloatingPopupPositionOptions}
|
||||||
/>
|
/>
|
||||||
</Label>
|
</div>
|
||||||
{[
|
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||||
{ name: "brandColor", title: "Brand Color" },
|
<div>Button Color</div>
|
||||||
// { name: "lightColor", title: "Light Color" },
|
<div className="w-full">
|
||||||
// { name: "lighterColor", title: "Lighter Color" },
|
<ColorPicker
|
||||||
// { name: "lightestColor", title: "Lightest Color" },
|
defaultValue="#000000"
|
||||||
// { name: "highlightColor", title: "Highlight Color" },
|
onChange={(color) => {
|
||||||
// { name: "medianColor", title: "Median Color" },
|
setPreviewState((previewState) => {
|
||||||
].map((palette) => (
|
return {
|
||||||
<Label key={palette.name} className="pb-4">
|
...previewState,
|
||||||
<div className="mb-2 pt-2">{palette.title}</div>
|
floatingPopup: {
|
||||||
<div className="w-full">
|
...previewState.floatingPopup,
|
||||||
<ColorPicker
|
buttonColor: color,
|
||||||
defaultValue="#000000"
|
},
|
||||||
onChange={(color) => {
|
};
|
||||||
addToPalette({
|
});
|
||||||
[palette.name as keyof typeof previewState["palette"]]: color,
|
}}
|
||||||
});
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||||
|
<div>Text Color</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<ColorPicker
|
||||||
|
defaultValue="#000000"
|
||||||
|
onChange={(color) => {
|
||||||
|
setPreviewState((previewState) => {
|
||||||
|
return {
|
||||||
|
...previewState,
|
||||||
|
floatingPopup: {
|
||||||
|
...previewState.floatingPopup,
|
||||||
|
buttonTextColor: color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
<hr className="mt-4" />
|
||||||
|
<div className="mt-4 font-medium">
|
||||||
|
<Collapsible
|
||||||
|
open={isBookingCustomizationOpen}
|
||||||
|
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||||
|
<CollapsibleTrigger className="flex w-full" type="button">
|
||||||
|
<div className="text-base font-medium text-gray-900">Cal Booking Customization</div>
|
||||||
|
<Icon.FiChevronRight
|
||||||
|
className={`${
|
||||||
|
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||||
|
} ml-auto h-5 w-5 text-gray-500`}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<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="">
|
||||||
|
<div className="mb-2">Theme</div>
|
||||||
|
<Select
|
||||||
|
className="w-full"
|
||||||
|
defaultValue={ThemeOptions[0]}
|
||||||
|
components={{
|
||||||
|
Control: ThemeSelectControl,
|
||||||
|
}}
|
||||||
|
onChange={(option) => {
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewState((previewState) => {
|
||||||
|
return {
|
||||||
|
...previewState,
|
||||||
|
theme: option.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
options={ThemeOptions}
|
||||||
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
))}
|
{[
|
||||||
</div>
|
{ name: "brandColor", title: "Brand Color" },
|
||||||
</CollapsibleContent>
|
// { name: "lightColor", title: "Light Color" },
|
||||||
</Collapsible>
|
// { name: "lighterColor", title: "Lighter Color" },
|
||||||
|
// { name: "lightestColor", title: "Lightest Color" },
|
||||||
|
// { name: "highlightColor", title: "Highlight Color" },
|
||||||
|
// { name: "medianColor", title: "Median Color" },
|
||||||
|
].map((palette) => (
|
||||||
|
<Label key={palette.name} className="pb-4">
|
||||||
|
<div className="mb-2 pt-2">{palette.title}</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<ColorPicker
|
||||||
|
defaultValue="#000000"
|
||||||
|
onChange={(color) => {
|
||||||
|
addToPalette({
|
||||||
|
[palette.name as keyof typeof previewState["palette"]]: color,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-2/3 flex-col p-8">
|
<div className="flex w-2/3 flex-col p-8">
|
||||||
@@ -1084,9 +1116,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>
|
||||||
);
|
);
|
||||||
@@ -1133,7 +1163,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,10 +1,11 @@
|
|||||||
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() {
|
||||||
return trpc.useQuery(["viewer.public.i18n"], {
|
return trpc.viewer.public.i18n.useQuery(undefined, {
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
/**
|
/**
|
||||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||||
@@ -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,8 +1,7 @@
|
|||||||
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 Button from "@calcom/ui/Button";
|
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
|
||||||
|
|
||||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||||
@@ -143,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"
|
||||||
@@ -157,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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* @deprecated Use custom Skeletons instead
|
* @deprecated Use custom Skeletons instead
|
||||||
**/
|
**/
|
||||||
export { default } from "@calcom/ui/Loader";
|
export { Loader as default } from "@calcom/ui";
|
||||||
@@ -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,8 +1,7 @@
|
|||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
|
|
||||||
import ErrorBoundary from "@calcom/ui/ErrorBoundary";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { ErrorBoundary, Icon } from "@calcom/ui";
|
||||||
import Shell from "@calcom/ui/Shell";
|
|
||||||
|
|
||||||
import NavTabs from "./NavTabs";
|
import NavTabs from "./NavTabs";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import React from "react";
|
|
||||||
import { OptionProps } from "react-select";
|
import { OptionProps } from "react-select";
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
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 } from "@calcom/ui";
|
import { Button, Icon, Select } from "@calcom/ui";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import { Select } from "@calcom/ui/v2";
|
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
@@ -42,7 +39,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
|||||||
|
|
||||||
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: true }]);
|
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryCell
|
<QueryCell
|
||||||
@@ -64,12 +61,10 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
|||||||
<Select
|
<Select
|
||||||
name="additionalCalendar"
|
name="additionalCalendar"
|
||||||
placeholder={
|
placeholder={
|
||||||
<div className="flex justify-start text-black">
|
<Button StartIcon={Icon.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}
|
||||||
@@ -83,11 +78,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,18 +4,15 @@ 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 { Icon } from "@calcom/ui/Icon";
|
import { Button, Icon, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
|
||||||
import { Button } from "@calcom/ui/components";
|
|
||||||
import { showToast, SkeletonText } from "@calcom/ui/v2";
|
|
||||||
import { SkeletonButton, Shell } from "@calcom/ui/v2";
|
|
||||||
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
|
||||||
|
|
||||||
import HeadSeo from "@components/seo/head-seo";
|
|
||||||
|
|
||||||
const Component = ({
|
const Component = ({
|
||||||
name,
|
name,
|
||||||
@@ -37,13 +34,15 @@ 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;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const mutation = useAddAppMutation(null, {
|
const mutation = useAddAppMutation(null, {
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
|
if (data?.setupPending) return;
|
||||||
showToast(t("app_successfully_installed"), "success");
|
showToast(t("app_successfully_installed"), "success");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -58,13 +57,18 @@ const Component = ({
|
|||||||
}).format(price);
|
}).format(price);
|
||||||
|
|
||||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||||
const appCredentials = trpc.useQuery(["viewer.appCredentialsByType", { appType: type }], {
|
const appCredentials = trpc.viewer.appCredentialsByType.useQuery(
|
||||||
onSuccess(data) {
|
{ appType: type },
|
||||||
setExistingCredentials(data);
|
{
|
||||||
},
|
onSuccess(data) {
|
||||||
});
|
setExistingCredentials(data);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
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">
|
||||||
@@ -96,11 +100,18 @@ 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 ? (
|
||||||
@@ -119,6 +130,7 @@ const Component = ({
|
|||||||
render={({ useDefaultComponent, ...props }) => {
|
render={({ useDefaultComponent, ...props }) => {
|
||||||
if (useDefaultComponent) {
|
if (useDefaultComponent) {
|
||||||
props = {
|
props = {
|
||||||
|
...props,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
mutation.mutate({ type, variant, slug });
|
mutation.mutate({ type, variant, slug });
|
||||||
},
|
},
|
||||||
@@ -143,6 +155,7 @@ const Component = ({
|
|||||||
</div>
|
</div>
|
||||||
) : existingCredentials.length > 0 ? (
|
) : existingCredentials.length > 0 ? (
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
|
buttonProps={{ color: "secondary" }}
|
||||||
label={t("disconnect")}
|
label={t("disconnect")}
|
||||||
credentialId={existingCredentials[0]}
|
credentialId={existingCredentials[0]}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
@@ -156,6 +169,7 @@ const Component = ({
|
|||||||
render={({ useDefaultComponent, ...props }) => {
|
render={({ useDefaultComponent, ...props }) => {
|
||||||
if (useDefaultComponent) {
|
if (useDefaultComponent) {
|
||||||
props = {
|
props = {
|
||||||
|
...props,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
mutation.mutate({ type, variant, slug });
|
mutation.mutate({ type, variant, slug });
|
||||||
},
|
},
|
||||||
@@ -190,7 +204,7 @@ const Component = ({
|
|||||||
<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", {
|
||||||
@@ -268,8 +282,10 @@ const Component = ({
|
|||||||
)}
|
)}
|
||||||
</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 })}
|
||||||
|
</span>
|
||||||
|
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||||
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,6 +316,7 @@ 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();
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,21 @@ 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 { Icon } from "@calcom/ui/Icon";
|
import {
|
||||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
Alert,
|
||||||
import { Button } from "@calcom/ui/components";
|
Button,
|
||||||
import { Alert, EmptyScreen } from "@calcom/ui/v2";
|
EmptyScreen,
|
||||||
import { List } from "@calcom/ui/v2/core/List";
|
Icon,
|
||||||
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
|
List,
|
||||||
import Switch from "@calcom/ui/v2/core/Switch";
|
showToast,
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
AppSkeletonLoader as SkeletonLoader,
|
||||||
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
Switch,
|
||||||
|
ShellSubHeading,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ function CalendarSwitch(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.invalidateQueries(["viewer.integrations"]);
|
await utils.viewer.integrations.invalidate();
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||||
@@ -107,7 +110,7 @@ function CalendarSwitch(props: {
|
|||||||
|
|
||||||
function CalendarList(props: Props) {
|
function CalendarList(props: Props) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: false }]);
|
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryCell
|
<QueryCell
|
||||||
@@ -144,7 +147,9 @@ function CalendarList(props: Props) {
|
|||||||
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
|
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
|
||||||
function ConnectedCalendarsList(props: Props) {
|
function ConnectedCalendarsList(props: Props) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
|
||||||
|
suspense: true,
|
||||||
|
});
|
||||||
const { fromOnboarding } = props;
|
const { fromOnboarding } = props;
|
||||||
return (
|
return (
|
||||||
<QueryCell
|
<QueryCell
|
||||||
@@ -154,6 +159,7 @@ 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 className="flex flex-col gap-6" noBorderTreatment>
|
||||||
{data.connectedCalendars.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
@@ -163,7 +169,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
slug={item.integration.slug}
|
slug={item.integration.slug}
|
||||||
title={item.integration.title}
|
title={item.integration.title}
|
||||||
logo={item.integration.logo}
|
logo={item.integration.logo}
|
||||||
description={item.primary?.externalId || "No external Id"}
|
description={item.primary?.email ?? item.integration.description}
|
||||||
separate={true}
|
separate={true}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex w-32 justify-end">
|
<div className="flex w-32 justify-end">
|
||||||
@@ -178,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
|
||||||
@@ -235,19 +239,19 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
|||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const onChanged = () =>
|
const onChanged = () =>
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
|
utils.viewer.integrations.invalidate(
|
||||||
exact: true,
|
{ variant: "calendar", onlyInstalled: true },
|
||||||
}),
|
{
|
||||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
exact: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
utils.viewer.connectedCalendars.invalidate(),
|
||||||
]);
|
]);
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||||
const installedCalendars = trpc.useQuery([
|
const installedCalendars = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||||
"viewer.integrations",
|
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||||
{ variant: "calendar", onlyInstalled: true },
|
|
||||||
]);
|
|
||||||
const mutation = trpc.useMutation("viewer.setDestinationCalendar", {
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.invalidateQueries(["viewer.connectedCalendars"]);
|
utils.viewer.connectedCalendars.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
@@ -324,7 +328,10 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
|||||||
})}
|
})}
|
||||||
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const DestinationCalendarSelector = ({
|
|||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
}: Props): JSX.Element | null => {
|
}: Props): JSX.Element | null => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||||
|
|
||||||
// Extra styles to show prefixed text in react-select
|
// Extra styles to show prefixed text in react-select
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { useRouter } from "next/router";
|
|||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Icon } from "@calcom/ui";
|
import { Badge, Icon, ListItem, ListItemText, ListItemTitle, showToast } from "@calcom/ui";
|
||||||
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/v2/core/List";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ function IntegrationListItem(props: {
|
|||||||
destination?: boolean;
|
destination?: boolean;
|
||||||
separate?: boolean;
|
separate?: boolean;
|
||||||
invalidCredential?: boolean;
|
invalidCredential?: boolean;
|
||||||
|
isTemplate?: boolean;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,6 +27,13 @@ function IntegrationListItem(props: {
|
|||||||
const [highlight, setHighlight] = useState(hl === props.slug);
|
const [highlight, setHighlight] = useState(hl === props.slug);
|
||||||
const title = props.name || props.title;
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setHighlight(false), 3000);
|
const timer = setTimeout(() => setHighlight(false), 3000);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -38,19 +45,24 @@ function IntegrationListItem(props: {
|
|||||||
expanded={!!props.children}
|
expanded={!!props.children}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
||||||
"my-0 flex-col border transition-colors duration-500 ",
|
"my-0 flex-col border transition-colors duration-500",
|
||||||
highlight ? "bg-yellow-100" : ""
|
highlight ? "bg-yellow-100" : ""
|
||||||
)}>
|
)}>
|
||||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
<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} />}
|
{props.logo && <img className="h-11 w-11" src={props.logo} alt={title} />}
|
||||||
<div className="flex-grow truncate pl-2">
|
<div className="flex-grow truncate pl-2">
|
||||||
<ListItemTitle component="h3">
|
<ListItemTitle component="h3" className="flex ">
|
||||||
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
|
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
|
||||||
|
{props.isTemplate && (
|
||||||
|
<Badge variant="red" className="ml-4">
|
||||||
|
Template
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</ListItemTitle>
|
</ListItemTitle>
|
||||||
<ListItemText component="p">{props.description}</ListItemText>
|
<ListItemText component="p">{props.description}</ListItemText>
|
||||||
{/* Alert error that key stopped working. */}
|
{/* Alert error that key stopped working. */}
|
||||||
{props.invalidCredential && (
|
{props.invalidCredential && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||||
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
|
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
|
||||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||||
{t("invalid_credential")}
|
{t("invalid_credential")}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
|
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={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>;
|
||||||
+17
-21
@@ -1,14 +1,11 @@
|
|||||||
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 { Icon } from "@calcom/ui";
|
||||||
import { Icon } from "../../../Icon";
|
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
|
||||||
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)[] = [
|
||||||
{
|
{
|
||||||
@@ -47,25 +44,24 @@ export default function InstalledAppsLayout({
|
|||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||||
const query = trpc.useQuery([
|
const variant: typeof InstalledAppVariants[number] = "payment";
|
||||||
"viewer.integrations",
|
|
||||||
{ variant: InstalledAppVariants.payment, onlyInstalled: true },
|
const query = trpc.viewer.integrations.useQuery({
|
||||||
]);
|
variant,
|
||||||
|
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
|
||||||
<div className="hidden xl:block">
|
baseURL="/apps/installed"
|
||||||
<VerticalTabs tabs={actualTabs} sticky linkProps={{ shallow: true }} />
|
containerClassname="w-full xl:mx-5 xl:w-4/5 xl:max-w-2xl xl:pr-5">
|
||||||
</div>
|
{children}
|
||||||
<div className="block xl:hidden">
|
</AppCategoryNavigation>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import useDigitInput from "react-digit-input";
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Label, Input } from "@calcom/ui/form/fields";
|
import { Label, Input } from "@calcom/ui";
|
||||||
|
|
||||||
export default function TwoFactor({ center = true }) {
|
export default function TwoFactor({ center = true }) {
|
||||||
const [value, onChange] = useState("");
|
const [value, onChange] = useState("");
|
||||||
|
|||||||
+7
-6
@@ -2,9 +2,10 @@ 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({
|
||||||
@@ -16,14 +17,14 @@ export default function AdminLayout({
|
|||||||
|
|
||||||
// Force redirect on component level
|
// Force redirect on component level
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session.data?.user.role !== UserPermissionRole.ADMIN) {
|
if (session.data && session.data.user.role !== UserPermissionRole.ADMIN) {
|
||||||
router.replace("/settings");
|
router.replace("/settings/my-account/profile");
|
||||||
}
|
}
|
||||||
}, [session, router]);
|
}, [session, router]);
|
||||||
|
|
||||||
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="flex flex-1 [&>*]:flex-1">
|
||||||
<ErrorBoundary>{children}</ErrorBoundary>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { SkeletonText } from "@calcom/ui/v2";
|
import { SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
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,10 +1,13 @@
|
|||||||
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 Tooltip from "@calcom/ui/v2/core/Tooltip";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Icon, Tooltip } from "@calcom/ui";
|
||||||
|
|
||||||
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-2 break-words text-sm text-gray-600">
|
||||||
{locations.map((location) => {
|
{locations.map((location) => {
|
||||||
@@ -15,16 +18,20 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
||||||
<img
|
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||||
src={eventLocationType.iconUrl}
|
<Icon.FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||||
className={classNames(
|
) : (
|
||||||
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
<img
|
||||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
src={eventLocationType.iconUrl}
|
||||||
)}
|
className={classNames(
|
||||||
alt={`${eventLocationType.label} icon`}
|
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||||
/>
|
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
||||||
<Tooltip content={locationKeyToString(location)}>
|
)}
|
||||||
<p className="truncate">{locationKeyToString(location)}</p>
|
alt={`${eventLocationType.label} icon`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip content={t(locationKeyToString(location) ?? "")}>
|
||||||
|
<p className="truncate">{t(locationKeyToString(location) ?? "")}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import { nameOfDay } from "@calcom/lib/weekday";
|
import { nameOfDay } from "@calcom/lib/weekday";
|
||||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
|
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
|
||||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
import { SkeletonContainer, SkeletonText, ToggleGroup } from "@calcom/ui";
|
||||||
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
@@ -58,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">
|
||||||
@@ -72,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 = {
|
||||||
@@ -88,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 } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,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
|
||||||
@@ -114,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>
|
||||||
@@ -152,13 +152,13 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
{isLoading && !slots.length && (
|
{isLoading && !slots.length && (
|
||||||
<>
|
<>
|
||||||
<SkeletonContainer className="mb-2">
|
<SkeletonContainer className="mb-2">
|
||||||
<SkeletonText width="full" height="20" />
|
<SkeletonText className="h-5 w-full" />
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
<SkeletonContainer className="mb-2">
|
<SkeletonContainer className="mb-2">
|
||||||
<SkeletonText width="full" height="20" />
|
<SkeletonText className="h-5 w-full" />
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
<SkeletonContainer className="mb-2">
|
<SkeletonContainer className="mb-2">
|
||||||
<SkeletonText width="full" height="20" />
|
<SkeletonText className="h-5 w-full" />
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
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/Icon";
|
import { Icon, Badge } from "@calcom/ui";
|
||||||
|
|
||||||
|
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 +42,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">
|
||||||
@@ -55,7 +85,7 @@ const BookingDescription: FC<Props> = (props) => {
|
|||||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
|
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
|
||||||
{eventType.title}
|
{eventType.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
|
<div className="dark:text-darkgray-600 flex flex-col space-y-4 text-sm font-medium text-gray-600">
|
||||||
{eventType?.description && (
|
{eventType?.description && (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -65,7 +95,7 @@ const BookingDescription: FC<Props> = (props) => {
|
|||||||
<div>
|
<div>
|
||||||
<Icon.FiInfo
|
<Icon.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 +105,61 @@ 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 " />
|
<Icon.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"
|
||||||
)}>
|
)}>
|
||||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
<Icon.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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus } from "@prisma/client";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState, useMemo } from "react";
|
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";
|
||||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
import {
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
Badge,
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
Button,
|
||||||
import { Button } from "@calcom/ui/components/button";
|
Dialog,
|
||||||
import { TextArea } from "@calcom/ui/form/fields";
|
DialogClose,
|
||||||
import MeetingTimeInTimezones from "@calcom/ui/v2/core/MeetingTimeInTimezones";
|
DialogContent,
|
||||||
import Tooltip from "@calcom/ui/v2/core/Tooltip";
|
DialogFooter,
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
DialogHeader,
|
||||||
|
Icon,
|
||||||
|
MeetingTimeInTimezones,
|
||||||
|
showToast,
|
||||||
|
TextArea,
|
||||||
|
Tooltip,
|
||||||
|
ActionType,
|
||||||
|
TableActions,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
|
||||||
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 = inferQueryInput<"viewer.bookings">["status"];
|
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||||
|
|
||||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||||
|
|
||||||
type BookingItemProps = BookingItem & {
|
type BookingItemProps = BookingItem & {
|
||||||
listingStatus: BookingListingStatus;
|
listingStatus: BookingListingStatus;
|
||||||
recurringInfo: inferQueryOutput<"viewer.bookings">["recurringInfo"][number] | undefined;
|
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookingListItem(booking: BookingItemProps) {
|
function BookingListItem(booking: BookingItemProps) {
|
||||||
@@ -42,15 +50,20 @@ 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 mutation = trpc.useMutation(["viewer.bookings.confirm"], {
|
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||||
onSuccess: () => {
|
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||||
setRejectionDialogIsOpen(false);
|
onSuccess: (data) => {
|
||||||
showToast(t("booking_confirmation_success"), "success");
|
if (data.status === BookingStatus.REJECTED) {
|
||||||
utils.invalidateQueries("viewer.bookings");
|
setRejectionDialogIsOpen(false);
|
||||||
|
showToast(t("booking_rejection_success"), "success");
|
||||||
|
} else {
|
||||||
|
showToast(t("booking_confirmation_success"), "success");
|
||||||
|
}
|
||||||
|
utils.viewer.bookings.invalidate();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showToast(t("booking_confirmation_failed"), "error");
|
showToast(t("booking_confirmation_failed"), "error");
|
||||||
utils.invalidateQueries("viewer.bookings");
|
utils.viewer.bookings.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,13 +115,26 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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: `/cancel/${booking.uid}${isTabRecurring && isRecurring ? "?allRemainingBookings=true" : ""}`,
|
href: `/booking/${booking.uid}?cancel=true${
|
||||||
|
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||||
|
}`,
|
||||||
icon: Icon.FiX,
|
icon: Icon.FiX,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,6 +172,10 @@ 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">
|
||||||
@@ -158,11 +188,11 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||||
const setLocationMutation = trpc.useMutation("viewer.bookings.editLocation", {
|
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showToast(t("location_updated"), "success");
|
showToast(t("location_updated"), "success");
|
||||||
setIsOpenLocationDialog(false);
|
setIsOpenLocationDialog(false);
|
||||||
utils.invalidateQueries("viewer.bookings");
|
utils.viewer.bookings.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,30 +211,17 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
||||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||||
|
|
||||||
const location = booking.location || "";
|
|
||||||
|
|
||||||
const onClickTableData = () => {
|
const onClickTableData = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${booking.uid}`,
|
||||||
query: {
|
query: {
|
||||||
date: booking.startTime,
|
allRemainingBookings: isTabRecurring,
|
||||||
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
|
||||||
type: booking.eventType.id || 0,
|
|
||||||
eventSlug: booking.eventType.slug,
|
|
||||||
username: user?.username || "",
|
|
||||||
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
|
||||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||||
location: location,
|
|
||||||
eventName: booking.eventType.eventName || "",
|
|
||||||
bookingId: booking.id,
|
|
||||||
recur: booking.recurringEventId,
|
|
||||||
reschedule: isConfirmed,
|
|
||||||
listingStatus: booking.listingStatus,
|
|
||||||
status: booking.status,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const showRecordingsButtons =
|
||||||
|
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RescheduleDialog
|
<RescheduleDialog
|
||||||
@@ -218,7 +235,14 @@ 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>
|
||||||
@@ -237,9 +261,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose>
|
<DialogClose />
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={mutation.isLoading}
|
disabled={mutation.isLoading}
|
||||||
@@ -252,7 +274,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 +301,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 +357,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 +399,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 />
|
||||||
@@ -467,8 +492,9 @@ const FirstAttendee = ({
|
|||||||
user: UserProps;
|
user: UserProps;
|
||||||
currentEmail: string | null | undefined;
|
currentEmail: string | null | undefined;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLocale();
|
||||||
return user.email === currentEmail ? (
|
return user.email === currentEmail ? (
|
||||||
<div className="inline-block">You</div>
|
<div className="inline-block">{t("you")}</div>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
key={user.email}
|
key={user.email}
|
||||||
@@ -480,19 +506,19 @@ const FirstAttendee = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Attendee: React.FC<{ email: string; children: React.ReactNode }> = ({ email, children }) => {
|
type AttendeeProps = {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||||
return (
|
return (
|
||||||
<a className=" hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
|
<a className="hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
|
||||||
{children}
|
{name || email}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AttendeeProps {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DisplayAttendees = ({
|
const DisplayAttendees = ({
|
||||||
attendees,
|
attendees,
|
||||||
user,
|
user,
|
||||||
@@ -500,50 +526,32 @@ const DisplayAttendees = ({
|
|||||||
}: {
|
}: {
|
||||||
attendees: AttendeeProps[];
|
attendees: AttendeeProps[];
|
||||||
user: UserProps | null;
|
user: UserProps | null;
|
||||||
currentEmail: string | null | undefined;
|
currentEmail?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
if (attendees.length === 1) {
|
const { t } = useLocale();
|
||||||
return (
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
|
||||||
<span> and </span>
|
|
||||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (attendees.length === 2) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
|
||||||
<span>, </span>
|
|
||||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
|
||||||
<div className="inline-block text-sm text-gray-900"> and </div>
|
|
||||||
<Attendee email={attendees[1].email}>{attendees[1].name}</Attendee>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
|
||||||
<span>, </span>
|
|
||||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
|
||||||
<span> & </span>
|
|
||||||
<Tooltip
|
|
||||||
content={attendees.slice(1).map((attendee, key) => (
|
|
||||||
<p key={key}>{attendee.name}</p>
|
|
||||||
))}>
|
|
||||||
<div className="inline-block">{attendees.length - 1} more</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<div className="text-sm text-gray-900">
|
||||||
className={`inline-flex items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ${className}`}>
|
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||||
{children}
|
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||||
</span>
|
<Attendee {...attendees[0]} />
|
||||||
|
{attendees.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="inline-block text-sm text-gray-900"> {t("and")} </div>
|
||||||
|
{attendees.length > 2 ? (
|
||||||
|
<Tooltip
|
||||||
|
content={attendees.slice(1).map((attendee) => (
|
||||||
|
<p key={attendee.email}>
|
||||||
|
<Attendee {...attendee} />
|
||||||
|
</p>
|
||||||
|
))}>
|
||||||
|
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Attendee {...attendees[1]} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ 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 } from "@calcom/ui/Button";
|
import { Button, Icon, TextArea } from "@calcom/ui";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
booking: {
|
booking: {
|
||||||
@@ -22,13 +21,14 @@ type Props = {
|
|||||||
team?: string | null;
|
team?: string | null;
|
||||||
setIsCancellationMode: (value: boolean) => void;
|
setIsCancellationMode: (value: boolean) => void;
|
||||||
theme: string | null;
|
theme: string | null;
|
||||||
|
allRemainingBookings: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CancelBooking(props: Props) {
|
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 } = props;
|
const { booking, profile, team, 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"));
|
||||||
@@ -37,7 +37,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<div>
|
<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" />
|
<Icon.FiX className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
@@ -51,30 +51,23 @@ export default function CancelBooking(props: Props) {
|
|||||||
{!error && (
|
{!error && (
|
||||||
<div className="mt-5 sm:mt-6">
|
<div className="mt-5 sm:mt-6">
|
||||||
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||||
<textarea
|
<TextArea
|
||||||
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-3 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:mb-3 "
|
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 sm:flex-row">
|
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||||
{!props.recurringEvent && (
|
<div className="ml-auto flex w-full space-x-4 ">
|
||||||
<div className="border-bookinglightest mt-5 flex w-full justify-center border-t pt-3 sm:mt-0 sm:justify-start sm:border-0 sm:pt-0">
|
<Button
|
||||||
<Button
|
className="ml-auto"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
className="border-0 sm:border"
|
onClick={() => props.setIsCancellationMode(false)}>
|
||||||
onClick={() => router.push(`/reschedule/${booking?.uid}`)}>
|
|
||||||
{t("reschedule_this")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mb-2 flex w-full space-x-2 text-right sm:mb-0 ">
|
|
||||||
<Button color="secondary" onClick={() => props.setIsCancellationMode(false)}>
|
|
||||||
{t("nevermind")}
|
{t("nevermind")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex grow justify-center"
|
className="flex justify-center"
|
||||||
data-testid="cancel"
|
data-testid="cancel"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -82,6 +75,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
id: booking?.id,
|
id: booking?.id,
|
||||||
cancellationReason: cancellationReason,
|
cancellationReason: cancellationReason,
|
||||||
|
allRemainingBookings,
|
||||||
};
|
};
|
||||||
|
|
||||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||||
@@ -95,11 +89,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.status >= 200 && res.status < 300) {
|
if (res.status >= 200 && res.status < 300) {
|
||||||
await router.push(
|
await router.replace(router.asPath);
|
||||||
`/cancel/success?name=${props.profile.name}&title=${booking?.title}&eventPage=${
|
|
||||||
profile.slug
|
|
||||||
}&team=${team ? 1 : 0}&recurring=${!!props.recurringEvent}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(
|
setError(
|
||||||
@@ -110,7 +100,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
loading={loading}>
|
loading={loading}>
|
||||||
{t("cancel_event")}
|
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { SkeletonText } from "@calcom/ui/v2";
|
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,7 +1,7 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/v2/core/TimezoneSelect";
|
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
|
||||||
|
|
||||||
import { timeZone } from "../../lib/clock";
|
import { timeZone } from "../../lib/clock";
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,42 @@
|
|||||||
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,
|
||||||
users,
|
users,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
profile: { image: string | null; name?: string | null };
|
profile: { image: string | null; name?: string | null; username?: string | null };
|
||||||
showMembers: boolean;
|
showMembers: boolean;
|
||||||
users: { username: string | null; name?: string | null }[];
|
users: { username: string | null; name?: string | null }[];
|
||||||
} & Pick<AvatarGroupProps, "size" | "truncateAfter">) => {
|
} & Pick<AvatarGroupProps, "size" | "truncateAfter">) => {
|
||||||
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={
|
||||||
[
|
[
|
||||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
{
|
||||||
|
image: profile.image,
|
||||||
|
alt: profile.name,
|
||||||
|
title: profile.name,
|
||||||
|
href: profile.username ? `${CAL_URL}/${profile.username}` : undefined,
|
||||||
|
},
|
||||||
...(showMembers
|
...(showMembers
|
||||||
? users.map((user) => ({
|
? users.map((user) => ({
|
||||||
title: user.name,
|
title: user.name,
|
||||||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||||
alt: user.name || undefined,
|
alt: user.name || undefined,
|
||||||
|
href: user.username ? `${CAL_URL}/${user.username}` : undefined,
|
||||||
}))
|
}))
|
||||||
: []),
|
: []),
|
||||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
].filter((item) => !!item.image) as {
|
||||||
|
image: string;
|
||||||
|
alt?: string;
|
||||||
|
title?: string;
|
||||||
|
href?: string;
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
size={props.size}
|
size="sm"
|
||||||
truncateAfter={props.truncateAfter}
|
truncateAfter={props.truncateAfter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 * 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 { useReducer, useEffect, useMemo, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
@@ -14,12 +13,13 @@ 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 getStripeAppData from "@calcom/lib/getStripeAppData";
|
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";
|
||||||
@@ -28,42 +28,21 @@ 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 { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon, HeadSeo } from "@calcom/ui";
|
||||||
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -71,6 +50,7 @@ const useSlots = ({
|
|||||||
endTime,
|
endTime,
|
||||||
usernameList,
|
usernameList,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
}: {
|
}: {
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
eventTypeSlug: string;
|
eventTypeSlug: string;
|
||||||
@@ -78,20 +58,21 @@ const useSlots = ({
|
|||||||
endTime?: Dayjs;
|
endTime?: Dayjs;
|
||||||
usernameList: string[];
|
usernameList: string[];
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
|
duration?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isPaused } = trpc.useQuery(
|
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||||
[
|
{
|
||||||
"viewer.public.slots.getSchedule",
|
eventTypeId,
|
||||||
{
|
eventTypeSlug,
|
||||||
eventTypeId,
|
usernameList,
|
||||||
eventTypeSlug,
|
startTime: startTime?.toISOString() || "",
|
||||||
usernameList,
|
endTime: endTime?.toISOString() || "",
|
||||||
startTime: startTime?.toISOString() || "",
|
timeZone,
|
||||||
endTime: endTime?.toISOString() || "",
|
duration,
|
||||||
timeZone,
|
},
|
||||||
},
|
{
|
||||||
],
|
enabled: !!startTime && !!endTime,
|
||||||
{ enabled: !!startTime && !!endTime }
|
}
|
||||||
);
|
);
|
||||||
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
||||||
|
|
||||||
@@ -128,6 +109,7 @@ const SlotPicker = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||||
|
const { duration } = 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();
|
||||||
@@ -153,7 +135,7 @@ 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: _1 } = useSlots({
|
||||||
@@ -163,14 +145,19 @@ const SlotPicker = ({
|
|||||||
startTime: selectedDate?.startOf("day"),
|
startTime: selectedDate?.startOf("day"),
|
||||||
endTime: selectedDate?.endOf("day"),
|
endTime: selectedDate?.endOf("day"),
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
});
|
});
|
||||||
const { slots: _2, isLoading } = useSlots({
|
const { slots: _2, isLoading } = useSlots({
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
usernameList: users,
|
usernameList: users,
|
||||||
startTime: browsingDate?.startOf("month"),
|
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"),
|
endTime: browsingDate?.endOf("month"),
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
||||||
@@ -231,14 +218,14 @@ function TimezoneDropdown({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
<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">
|
<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 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
|
||||||
<p className="text-sm font-medium">
|
<p className="flex items-center text-sm font-medium">
|
||||||
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
<Icon.FiGlobe className="min-h-4 min-w-4 ml-[2px] -mt-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
{timeZone}
|
{timeZone}
|
||||||
{isTimeOptionsOpen ? (
|
{isTimeOptionsOpen ? (
|
||||||
<Icon.FiChevronUp className="ml-1 inline-block h-4 w-4" />
|
<Icon.FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||||
) : (
|
) : (
|
||||||
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
|
<Icon.FiChevronDown className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
@@ -260,23 +247,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) => {
|
||||||
@@ -287,6 +257,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();
|
||||||
@@ -311,9 +282,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();
|
||||||
@@ -326,7 +294,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) : [];
|
||||||
|
|
||||||
@@ -339,6 +307,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
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`
|
||||||
@@ -372,7 +342,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")
|
||||||
)}>
|
)}>
|
||||||
@@ -387,60 +357,55 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
isEmbed && "mx-auto"
|
isEmbed && "mx-auto"
|
||||||
)}>
|
)}>
|
||||||
<div className="overflow-hidden md:flex">
|
<div className="overflow-hidden 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>
|
<Icon.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>
|
)}
|
||||||
)}
|
{stripeAppData.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">
|
<Icon.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={stripeAppData.price / 100.0}
|
||||||
value={stripeAppData.price / 100.0}
|
style="currency"
|
||||||
style="currency"
|
currency={stripeAppData.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"
|
||||||
@@ -448,12 +413,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"
|
||||||
@@ -479,7 +445,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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { isValidPhoneNumber } from "libphonenumber-js";
|
|||||||
import { useSession } from "next-auth/react";
|
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, useState, useReducer } from "react";
|
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, 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 { ReactMultiEmail } from "react-multi-email";
|
||||||
@@ -14,11 +14,11 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||||
import {
|
import {
|
||||||
locationKeyToString,
|
|
||||||
getEventLocationValue,
|
|
||||||
getEventLocationType,
|
|
||||||
EventLocationType,
|
EventLocationType,
|
||||||
|
getEventLocationType,
|
||||||
|
getEventLocationValue,
|
||||||
getHumanReadableLocationValue,
|
getHumanReadableLocationValue,
|
||||||
|
locationKeyToString,
|
||||||
} from "@calcom/app-store/locations";
|
} from "@calcom/app-store/locations";
|
||||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||||
@@ -26,28 +26,26 @@ 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 { APP_NAME } from "@calcom/lib/constants";
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
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 { 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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { AddressInput, Button, EmailInput, Form, Icon, PhoneInput, Tooltip } from "@calcom/ui";
|
||||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
import { Group, RadioField } from "@calcom/ui";
|
||||||
import { Button } from "@calcom/ui/components";
|
|
||||||
import AddressInput from "@calcom/ui/form/AddressInputLazy";
|
|
||||||
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
|
|
||||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
|
||||||
|
|
||||||
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";
|
||||||
@@ -90,10 +88,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();
|
||||||
@@ -108,6 +105,11 @@ const BookingPage = ({
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
const stripeAppData = getStripeAppData(eventType);
|
||||||
|
// Define duration now that we support multiple duration eventTypes
|
||||||
|
let duration = eventType.length;
|
||||||
|
if (queryDuration && !isNaN(Number(queryDuration))) {
|
||||||
|
duration = Number(queryDuration);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (top !== window) {
|
if (top !== window) {
|
||||||
@@ -122,7 +124,7 @@ const BookingPage = ({
|
|||||||
|
|
||||||
const mutation = useMutation(createBooking, {
|
const mutation = useMutation(createBooking, {
|
||||||
onSuccess: async (responseData) => {
|
onSuccess: async (responseData) => {
|
||||||
const { id, paymentUid } = responseData;
|
const { uid, paymentUid } = responseData;
|
||||||
if (paymentUid) {
|
if (paymentUid) {
|
||||||
return await router.push(
|
return await router.push(
|
||||||
createPaymentLink({
|
createPaymentLink({
|
||||||
@@ -136,19 +138,12 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return router.push({
|
return router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${uid}`,
|
||||||
query: {
|
query: {
|
||||||
date,
|
|
||||||
type: eventType.id,
|
|
||||||
eventSlug: eventType.slug,
|
|
||||||
username: profile.slug,
|
|
||||||
reschedule: !!rescheduleUid,
|
|
||||||
name: bookingForm.getValues("name"),
|
|
||||||
email: bookingForm.getValues("email"),
|
|
||||||
location: responseData.location,
|
|
||||||
eventName: profile.eventName || "",
|
|
||||||
bookingId: id,
|
|
||||||
isSuccessBookingPage: true,
|
isSuccessBookingPage: true,
|
||||||
|
email: bookingForm.getValues("email"),
|
||||||
|
eventTypeSlug: eventType.slug,
|
||||||
|
formerTime: booking?.startTime.toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -156,31 +151,15 @@ const BookingPage = ({
|
|||||||
|
|
||||||
const recurringMutation = useMutation(createRecurringBooking, {
|
const recurringMutation = useMutation(createRecurringBooking, {
|
||||||
onSuccess: async (responseData = []) => {
|
onSuccess: async (responseData = []) => {
|
||||||
const { attendees = [], id, recurringEventId } = responseData[0] || {};
|
const { uid } = responseData[0] || {};
|
||||||
const location = (function humanReadableLocation(location) {
|
|
||||||
if (!location) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (location.includes("integration")) {
|
|
||||||
return t("web_conferencing_details_to_follow");
|
|
||||||
}
|
|
||||||
return location;
|
|
||||||
})(responseData[0].location);
|
|
||||||
|
|
||||||
return router.push({
|
return router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${uid}`,
|
||||||
query: {
|
query: {
|
||||||
date,
|
allRemainingBookings: true,
|
||||||
type: eventType.id,
|
email: bookingForm.getValues("email"),
|
||||||
eventSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
recur: recurringEventId,
|
formerTime: booking?.startTime.toString(),
|
||||||
username: profile.slug,
|
|
||||||
reschedule: !!rescheduleUid,
|
|
||||||
name: attendees[0].name,
|
|
||||||
email: attendees[0].email,
|
|
||||||
location,
|
|
||||||
eventName: profile.eventName || "",
|
|
||||||
bookingId: id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -265,7 +244,7 @@ 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(),
|
||||||
phone: z
|
phone: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
@@ -322,6 +301,39 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookEvent = (booking: BookingFormValues) => {
|
const bookEvent = (booking: BookingFormValues) => {
|
||||||
|
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/") }
|
||||||
@@ -340,13 +352,29 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -362,10 +390,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:
|
||||||
@@ -379,7 +404,7 @@ const BookingPage = ({
|
|||||||
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(),
|
||||||
@@ -393,10 +418,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:
|
||||||
@@ -431,6 +453,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
|
||||||
@@ -454,7 +477,7 @@ 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="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
@@ -464,104 +487,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 ">
|
{stripeAppData.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">
|
<Icon.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={stripeAppData.price / 100.0}
|
||||||
currency={stripeAppData.currency.toUpperCase()}
|
style="currency"
|
||||||
/>
|
currency={stripeAppData.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" />
|
<Icon.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
|
<Icon.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 ">
|
||||||
|
<Icon.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">
|
||||||
|
<Icon.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">
|
||||||
@@ -598,7 +623,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" />
|
<Icon.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>
|
||||||
)}
|
)}
|
||||||
@@ -639,8 +664,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>
|
||||||
);
|
);
|
||||||
@@ -650,7 +675,7 @@ const BookingPage = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
{/* TODO: Change name and id ="phone" to something generic */}
|
{/* TODO: Change name and id ="phone" to something generic */}
|
||||||
{AttendeeInput && (
|
{AttendeeInput && !disableInput && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label
|
<label
|
||||||
htmlFor={
|
htmlFor={
|
||||||
@@ -664,7 +689,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">
|
||||||
@@ -687,12 +712,11 @@ const BookingPage = ({
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
disabled={disableInput}
|
|
||||||
/>
|
/>
|
||||||
</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" />
|
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
<p>{t("invalid_number")}</p>
|
<p>{t("invalid_number")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -705,8 +729,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 && (
|
||||||
@@ -770,6 +797,49 @@ const BookingPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{input.options && input.type === EventTypeCustomInputType.RADIO && (
|
||||||
|
<div className="flex">
|
||||||
|
<Group
|
||||||
|
name={`customInputs.${input.id}`}
|
||||||
|
required={input.required}
|
||||||
|
onValueChange={(e) => {
|
||||||
|
bookingForm.setValue(`customInputs.${input.id}`, e);
|
||||||
|
}}>
|
||||||
|
<>
|
||||||
|
{input.options.map((option, i) => (
|
||||||
|
<RadioField
|
||||||
|
label={option.label}
|
||||||
|
key={`option.${input.id}.${i}.radio`}
|
||||||
|
value={option.label}
|
||||||
|
id={`option.${input.id}.${i}.radio`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
{bookingForm.formState.errors.customInputs?.[input.id] && (
|
||||||
|
<div className="mt-px flex items-center text-xs text-red-700 ">
|
||||||
|
<p>{t("required")}</p>
|
||||||
|
</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 ">
|
||||||
|
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
|
<p>{t("invalid_number")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!eventType.disableGuests && guestToggle && (
|
{!eventType.disableGuests && guestToggle && (
|
||||||
@@ -844,7 +914,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" />
|
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
<p>{t("invalid_number")}</p>
|
<p>{t("invalid_number")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -868,7 +938,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}
|
||||||
@@ -883,25 +953,19 @@ const BookingPage = ({
|
|||||||
{!eventType.disableGuests && !guestToggle && (
|
{!eventType.disableGuests && !guestToggle && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
color="minimalSecondary"
|
color="minimal"
|
||||||
size="icon"
|
variant="icon"
|
||||||
tooltip={t("additional_guests")}
|
tooltip={t("additional_guests")}
|
||||||
StartIcon={Icon.FiUserPlus}
|
StartIcon={Icon.FiUserPlus}
|
||||||
onClick={() => setGuestToggle(!guestToggle)}
|
onClick={() => setGuestToggle(!guestToggle)}
|
||||||
className="mr-auto"
|
className="mr-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button color="minimal" type="button" onClick={() => router.back()}>
|
||||||
color="minimal"
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
// We override this for this component only for now - as we don't support darkmode everywhere in the app
|
|
||||||
className="dark:hover:bg-darkgray-200 dark:border-none dark:text-white">
|
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast mr-auto dark:border-transparent"
|
|
||||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||||
loading={mutation.isLoading || recurringMutation.isLoading}>
|
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||||
@@ -934,7 +998,7 @@ function ErrorMessage({ error }: { error: unknown }) {
|
|||||||
<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">
|
||||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||||
{error instanceof HttpError || error instanceof Error ? error.message : "Unknown error"}
|
{error instanceof HttpError || error instanceof Error ? t(error.message) : "Unknown error"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
||||||
import React, { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { Button } from "@calcom/ui/Button";
|
|
||||||
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
|
|
||||||
export type DeleteStripeDialogContentProps = {
|
|
||||||
cancelAllBookingsBtnText?: string;
|
|
||||||
removeBtnText?: string;
|
|
||||||
cancelBtnText?: string;
|
|
||||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
|
||||||
onRemove?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
|
||||||
title: string;
|
|
||||||
variety?: "danger" | "warning" | "success";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteStripeDialogContent(props: PropsWithChildren<DeleteStripeDialogContentProps>) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
variety,
|
|
||||||
cancelAllBookingsBtnText,
|
|
||||||
removeBtnText,
|
|
||||||
cancelBtnText = t("cancel"),
|
|
||||||
onConfirm,
|
|
||||||
onRemove,
|
|
||||||
children,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogContent>
|
|
||||||
<div className="flex">
|
|
||||||
{variety && (
|
|
||||||
<div className="mt-0.5 ltr:mr-3">
|
|
||||||
{variety === "danger" && (
|
|
||||||
<div className="mx-auto rounded-full bg-red-100 p-2 text-center">
|
|
||||||
<Icon.FiAlertTriangle className="h-5 w-5 text-red-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{variety === "warning" && (
|
|
||||||
<div className="mx-auto rounded-full bg-orange-100 p-2 text-center">
|
|
||||||
<Icon.FiAlertTriangle className="h-5 w-5 text-orange-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{variety === "success" && (
|
|
||||||
<div className="mx-auto rounded-full bg-green-100 p-2 text-center">
|
|
||||||
<Icon.FiCheck className="h-5 w-5 text-green-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
|
|
||||||
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
|
||||||
{children}
|
|
||||||
</DialogPrimitive.Description>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
|
||||||
<DialogClose onClick={onConfirm} asChild>
|
|
||||||
<Button color="alert">{cancelAllBookingsBtnText}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<DialogClose onClick={onRemove} asChild>
|
|
||||||
<Button color="alert2">{removeBtnText}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button color="secondary">{cancelBtnText}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,42 +6,33 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LocationType,
|
|
||||||
getEventLocationType,
|
|
||||||
EventLocationType,
|
EventLocationType,
|
||||||
|
getEventLocationType,
|
||||||
|
getHumanReadableLocationValue,
|
||||||
|
getMessageForOrganizer,
|
||||||
LocationObject,
|
LocationObject,
|
||||||
|
LocationType,
|
||||||
} from "@calcom/app-store/locations";
|
} from "@calcom/app-store/locations";
|
||||||
import { getMessageForOrganizer } from "@calcom/app-store/locations";
|
|
||||||
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
||||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
|
|
||||||
import { Form } from "@calcom/ui/form/fields";
|
|
||||||
|
|
||||||
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 = inferQueryOutput<"viewer.bookings">["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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LocationInput = (props: {
|
const LocationInput = (props: {
|
||||||
@@ -79,9 +70,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
isOpenDialog,
|
isOpenDialog,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
setSelectedLocation,
|
setSelectedLocation,
|
||||||
|
setEditingLocationType,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const locationsQuery = trpc.useQuery(["viewer.locationOptions"]);
|
const locationsQuery = trpc.viewer.locationOptions.useQuery();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -211,14 +203,14 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={(open) => setShowLocationModal(open)}>
|
||||||
<DialogContent asChild>
|
<DialogContent disableOverflow>
|
||||||
<div className="inline-block transform rounded-sm bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="mb-4 sm:flex sm:items-start">
|
<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" />
|
||||||
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
|
</div>
|
||||||
</div>
|
<div className="w-full">
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||||
{t("edit_location")}
|
{t("edit_location")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -226,118 +218,125 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left" />
|
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
||||||
</div>
|
|
||||||
{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}
|
|
||||||
handleSubmit={async (values) => {
|
|
||||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
|
||||||
|
|
||||||
let details = {};
|
{booking && (
|
||||||
if (newLocation === LocationType.InPerson) {
|
<>
|
||||||
details = {
|
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
||||||
address: values.locationAddress,
|
<p className="mb-2 ml-1 text-sm text-black">
|
||||||
};
|
{getHumanReadableLocationValue(booking.location, t)}
|
||||||
}
|
</p>
|
||||||
const eventLocationType = getEventLocationType(newLocation);
|
</>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
form={locationFormMethods}
|
||||||
|
handleSubmit={async (values) => {
|
||||||
|
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||||
|
|
||||||
// TODO: There can be a property that tells if it is to be saved in `link`
|
let details = {};
|
||||||
if (
|
if (newLocation === LocationType.InPerson) {
|
||||||
newLocation === LocationType.Link ||
|
details = {
|
||||||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
address: values.locationAddress,
|
||||||
) {
|
};
|
||||||
details = { link: values.locationLink };
|
}
|
||||||
}
|
const eventLocationType = getEventLocationType(newLocation);
|
||||||
|
|
||||||
if (newLocation === LocationType.UserPhone) {
|
// TODO: There can be a property that tells if it is to be saved in `link`
|
||||||
details = { hostPhoneNumber: values.locationPhoneNumber };
|
if (
|
||||||
}
|
newLocation === LocationType.Link ||
|
||||||
|
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
||||||
|
) {
|
||||||
|
details = { link: values.locationLink };
|
||||||
|
}
|
||||||
|
|
||||||
if (eventLocationType?.organizerInputType) {
|
if (newLocation === LocationType.UserPhone) {
|
||||||
details = {
|
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||||
...details,
|
}
|
||||||
displayLocationPublicly,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
saveLocation(newLocation, details);
|
if (eventLocationType?.organizerInputType) {
|
||||||
setShowLocationModal(false);
|
details = {
|
||||||
setSelectedLocation?.(undefined);
|
...details,
|
||||||
locationFormMethods.unregister([
|
displayLocationPublicly,
|
||||||
"locationType",
|
};
|
||||||
"locationLink",
|
}
|
||||||
"locationAddress",
|
|
||||||
"locationPhoneNumber",
|
saveLocation(newLocation, details);
|
||||||
]);
|
setShowLocationModal(false);
|
||||||
}}>
|
setSelectedLocation?.(undefined);
|
||||||
<QueryCell
|
locationFormMethods.unregister([
|
||||||
query={locationsQuery}
|
"locationType",
|
||||||
success={({ data: locationOptions }) => {
|
"locationLink",
|
||||||
if (!locationOptions.length) return null;
|
"locationAddress",
|
||||||
return (
|
"locationPhoneNumber",
|
||||||
<Controller
|
]);
|
||||||
name="locationType"
|
}}>
|
||||||
control={locationFormMethods.control}
|
<QueryCell
|
||||||
render={() => (
|
query={locationsQuery}
|
||||||
<Select
|
success={({ data: locationOptions }) => {
|
||||||
maxMenuHeight={150}
|
if (!locationOptions.length) return null;
|
||||||
name="location"
|
if (booking) {
|
||||||
defaultValue={selection}
|
locationOptions.forEach((location) => {
|
||||||
options={
|
if (location.label === "phone") {
|
||||||
booking
|
location.options.filter((l) => l.value !== "phone");
|
||||||
? locationOptions.filter(
|
} else if (location.label === "in person") {
|
||||||
(location) =>
|
location.options.filter((l) => l.value !== "attendeeInPerson");
|
||||||
location.value !== "phone" && location.value !== "attendeeInPerson"
|
}
|
||||||
)
|
});
|
||||||
: locationOptions
|
}
|
||||||
}
|
return (
|
||||||
isSearchable
|
<Controller
|
||||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
name="locationType"
|
||||||
onChange={(val) => {
|
control={locationFormMethods.control}
|
||||||
if (val) {
|
render={() => (
|
||||||
locationFormMethods.setValue("locationType", val.value);
|
<LocationSelect
|
||||||
locationFormMethods.unregister([
|
maxMenuHeight={300}
|
||||||
"locationLink",
|
name="location"
|
||||||
"locationAddress",
|
defaultValue={selection}
|
||||||
"locationPhoneNumber",
|
options={locationOptions}
|
||||||
]);
|
isSearchable
|
||||||
locationFormMethods.clearErrors([
|
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
||||||
"locationLink",
|
onChange={(val) => {
|
||||||
"locationPhoneNumber",
|
if (val) {
|
||||||
"locationAddress",
|
locationFormMethods.setValue("locationType", val.value);
|
||||||
]);
|
locationFormMethods.unregister([
|
||||||
setSelectedLocation?.(val);
|
"locationLink",
|
||||||
}
|
"locationAddress",
|
||||||
}}
|
"locationPhoneNumber",
|
||||||
/>
|
]);
|
||||||
)}
|
locationFormMethods.clearErrors([
|
||||||
/>
|
"locationLink",
|
||||||
);
|
"locationPhoneNumber",
|
||||||
}}
|
"locationAddress",
|
||||||
/>
|
]);
|
||||||
{selectedLocation && LocationOptions}
|
setSelectedLocation?.(val);
|
||||||
<div className="mt-4 flex justify-end space-x-2">
|
}
|
||||||
<Button
|
}}
|
||||||
onClick={() => {
|
/>
|
||||||
setShowLocationModal(false);
|
)}
|
||||||
setSelectedLocation?.(undefined);
|
/>
|
||||||
locationFormMethods.unregister("locationType");
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
/>
|
||||||
color="secondary">
|
{selectedLocation && LocationOptions}
|
||||||
{t("cancel")}
|
<DialogFooter>
|
||||||
</Button>
|
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||||
<Button type="submit">{t("update")}</Button>
|
<Button
|
||||||
</div>
|
onClick={() => {
|
||||||
</Form>
|
setShowLocationModal(false);
|
||||||
|
setSelectedLocation?.(undefined);
|
||||||
|
setEditingLocationType?.("");
|
||||||
|
locationFormMethods.unregister("locationType");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
color="secondary">
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">{t("update")}</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import React, { useState, Dispatch, SetStateAction } from "react";
|
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 { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
import {
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
Button,
|
||||||
import { Button } from "@calcom/ui/components/button";
|
Dialog,
|
||||||
import { TextArea } from "@calcom/ui/form/fields";
|
DialogClose,
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
Icon,
|
||||||
|
showToast,
|
||||||
|
TextArea,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
|
||||||
interface IRescheduleDialog {
|
interface IRescheduleDialog {
|
||||||
isOpenDialog: boolean;
|
isOpenDialog: boolean;
|
||||||
@@ -20,11 +26,11 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
|
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
|
||||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||||
|
|
||||||
const { mutate: rescheduleApi, isLoading } = trpc.useMutation("viewer.bookings.requestReschedule", {
|
const { mutate: rescheduleApi, isLoading } = trpc.viewer.bookings.requestReschedule.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
showToast(t("reschedule_request_sent"), "success");
|
showToast(t("reschedule_request_sent"), "success");
|
||||||
setIsOpenDialog(false);
|
setIsOpenDialog(false);
|
||||||
await utils.invalidateQueries(["viewer.bookings"]);
|
await utils.viewer.bookings.invalidate();
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showToast(t("unexpected_error_try_again"), "error");
|
showToast(t("unexpected_error_try_again"), "error");
|
||||||
@@ -41,8 +47,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<DialogHeader title={t("send_reschedule_request")} />
|
<DialogHeader title={t("send_reschedule_request")} />
|
||||||
|
<p className="text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
||||||
<p className="-mt-8 text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
|
||||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||||
{t("reason_for_reschedule_request")}
|
{t("reason_for_reschedule_request")}
|
||||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||||
@@ -56,9 +61,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose />
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
<Button
|
||||||
data-testid="send_request"
|
data-testid="send_request"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
+14
-14
@@ -1,6 +1,6 @@
|
|||||||
import { FormValues } from "pages/event-types/[type]";
|
import { FormValues } from "pages/event-types/[type]";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { SingleValueProps, OptionProps, components } from "react-select";
|
import { components, OptionProps, SingleValueProps } from "react-select";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
@@ -8,12 +8,7 @@ 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 { Icon } from "@calcom/ui";
|
import { Badge, Button, Icon, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
|
||||||
import { Button } from "@calcom/ui/components/button";
|
|
||||||
import { SettingsToggle } from "@calcom/ui/v2";
|
|
||||||
import Select from "@calcom/ui/v2/core/form/select";
|
|
||||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
|
||||||
|
|
||||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||||
|
|
||||||
@@ -25,12 +20,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>
|
||||||
@@ -39,12 +35,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>
|
||||||
@@ -61,7 +58,9 @@ const AvailabilitySelect = ({
|
|||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
onChange: (value: AvailabilityOption | null) => void;
|
onChange: (value: AvailabilityOption | null) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading } = trpc.useQuery(["viewer.availability.list"]);
|
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SelectSkeletonLoader />;
|
return <SelectSkeletonLoader />;
|
||||||
}
|
}
|
||||||
@@ -82,6 +81,7 @@ const AvailabilitySelect = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
placeholder={t("select")}
|
||||||
options={options}
|
options={options}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
@@ -109,7 +109,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="min-w-4 mb-2">
|
<div className="min-w-4 mb-2">
|
||||||
<label htmlFor="availability" className="mt-0 flex text-sm font-medium text-neutral-700">
|
<label htmlFor="availability" className="mt-0 flex text-sm font-medium text-gray-700">
|
||||||
{t("availability")}
|
{t("availability")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +149,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
<span className="w-16 sm:w-28 sm:text-left">
|
<span className="w-16 sm:w-28 sm:text-left">
|
||||||
{format(dayRange.startTime, timeFormat === 12)}
|
{format(dayRange.startTime, timeFormat === 12)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-4">-</span>
|
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -164,7 +164,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
<hr />
|
<hr />
|
||||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
<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">
|
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||||
<Icon.FiGlobe className="mr-2" />
|
<Icon.FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -183,7 +183,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scheduleId = watch("schedule");
|
const scheduleId = watch("schedule");
|
||||||
const { isLoading, data: schedule } = trpc.useQuery(["viewer.availability.schedule", { scheduleId }]);
|
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId });
|
||||||
|
|
||||||
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)) || [];
|
||||||
@@ -1,349 +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 } from "@calcom/ui/Alert";
|
|
||||||
import { Button } from "@calcom/ui/Button";
|
|
||||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
|
||||||
import Dropdown, {
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@calcom/ui/Dropdown";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import { Form, InputLeading, TextAreaField, TextField } from "@calcom/ui/form/fields";
|
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
|
||||||
|
|
||||||
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.useMutation("viewer.eventTypes.create", {
|
|
||||||
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.useQuery([
|
|
||||||
"viewer.teams.getMembershipbyUser",
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
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 { Control, Controller, useFieldArray, useForm, UseFormRegister, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button, Icon, Label, Select, TextField } from "@calcom/ui";
|
||||||
|
|
||||||
|
interface OptionTypeBase {
|
||||||
|
label: string;
|
||||||
|
value: EventTypeCustomInputType;
|
||||||
|
options?: { label: string; type: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (output: CustomInputParsed) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
selectedCustomInput?: CustomInputParsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { 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") },
|
||||||
|
{
|
||||||
|
value: EventTypeCustomInputType.RADIO,
|
||||||
|
label: t("radio"),
|
||||||
|
},
|
||||||
|
{ value: EventTypeCustomInputType.PHONE, label: t("phone_number") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { selectedCustomInput } = props;
|
||||||
|
|
||||||
|
const defaultValues = selectedCustomInput
|
||||||
|
? { ...selectedCustomInput, id: selectedCustomInput?.id || randomId() }
|
||||||
|
: {
|
||||||
|
id: randomId(),
|
||||||
|
type: EventTypeCustomInputType.TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { register, control, getValues } = useForm<IFormInput>({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
const selectedInputType = useWatch({ name: "type", control });
|
||||||
|
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
props.onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<TextField
|
||||||
|
label={t("label")}
|
||||||
|
type="text"
|
||||||
|
id="label"
|
||||||
|
required
|
||||||
|
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||||
|
defaultValue={selectedCustomInput?.label}
|
||||||
|
{...register("label", { required: true })}
|
||||||
|
/>
|
||||||
|
{(selectedInputType === EventTypeCustomInputType.TEXT ||
|
||||||
|
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
|
||||||
|
<TextField
|
||||||
|
label={t("placeholder")}
|
||||||
|
type="text"
|
||||||
|
id="placeholder"
|
||||||
|
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||||
|
defaultValue={selectedCustomInput?.placeholder}
|
||||||
|
{...register("placeholder")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedInputType === EventTypeCustomInputType.RADIO && (
|
||||||
|
<RadioInputHandler control={control} register={register} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 justify-end space-x-2 rtl:space-x-reverse sm:mt-4">
|
||||||
|
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2 rtl:ml-2">
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
props.onSubmit(getValues());
|
||||||
|
}}>
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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={Icon.FiX}
|
||||||
|
onClick={() => {
|
||||||
|
remove(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={Icon.FiPlus}
|
||||||
|
className="!text-sm !font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
append({ label: "", type: "text" });
|
||||||
|
}}>
|
||||||
|
{t("add_an_option")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomInputTypeForm;
|
||||||
+107
-51
@@ -1,20 +1,34 @@
|
|||||||
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 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 { Icon } from "@calcom/ui";
|
import {
|
||||||
import { Checkbox, Button, TextField, Label } from "@calcom/ui/components";
|
Badge,
|
||||||
import { CustomInputItem, Dialog, DialogContent, SettingsToggle, showToast, Tooltip } from "@calcom/ui/v2";
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
Icon,
|
||||||
|
Label,
|
||||||
|
SettingsToggle,
|
||||||
|
showToast,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import CustomInputTypeForm from "@components/v2/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();
|
||||||
@@ -23,21 +37,33 @@ const generateHashedLink = (id: number) => {
|
|||||||
return uid;
|
return uid;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
|
const getRandomId = (length = 8) => {
|
||||||
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
|
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 formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||||
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 [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.getValues("seatsPerTimeSlotEnabled");
|
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||||
|
|
||||||
const removeCustom = (index: number) => {
|
const removeCustom = (index: number) => {
|
||||||
@@ -50,6 +76,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
!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">
|
||||||
{/**
|
{/**
|
||||||
@@ -61,10 +93,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">
|
||||||
@@ -74,6 +107,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
defaultValue={eventType.destinationCalendar || undefined}
|
defaultValue={eventType.destinationCalendar || undefined}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<DestinationCalendarSelector
|
<DestinationCalendarSelector
|
||||||
|
destinationCalendar={eventType.destinationCalendar}
|
||||||
value={value ? value.externalId : undefined}
|
value={value ? value.externalId : undefined}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hidePlaceholder
|
hidePlaceholder
|
||||||
@@ -88,16 +122,16 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<TextField
|
<TextField
|
||||||
label={t("event_name")}
|
label={t("event_name")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user")}
|
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName")}
|
||||||
addOnSuffix={
|
addOnSuffix={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
StartIcon={Icon.FiEdit}
|
StartIcon={Icon.FiEdit}
|
||||||
size="icon"
|
variant="icon"
|
||||||
color="minimal"
|
color="minimal"
|
||||||
className="hover:stroke-3 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)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -112,15 +146,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}
|
||||||
@@ -143,24 +175,17 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
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
|
||||||
@@ -233,17 +258,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);
|
||||||
@@ -296,6 +336,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);
|
||||||
@@ -341,15 +382,11 @@ 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")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user")}
|
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName")}
|
||||||
/>
|
/>
|
||||||
@@ -359,42 +396,61 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
||||||
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||||
|
{t("create")}
|
||||||
|
</Button>
|
||||||
|
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")} />
|
||||||
|
</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={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={() => {
|
||||||
+11
-44
@@ -1,55 +1,22 @@
|
|||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import React from "react";
|
|
||||||
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 { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui";
|
import { Button, EmptyScreen, Icon } from "@calcom/ui";
|
||||||
import ErrorBoundary from "@calcom/ui/ErrorBoundary";
|
|
||||||
import { Button } from "@calcom/ui/components";
|
|
||||||
import { EmptyScreen } from "@calcom/ui/v2";
|
|
||||||
|
|
||||||
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
|
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||||
EventTypeAppCardComponentProps["eventType"];
|
EventTypeAppCardComponentProps["eventType"];
|
||||||
|
|
||||||
function AppCardWrapper({
|
|
||||||
app,
|
|
||||||
eventType,
|
|
||||||
getAppData,
|
|
||||||
setAppData,
|
|
||||||
}: {
|
|
||||||
app: inferQueryOutput<"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.useQuery([
|
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
|
||||||
"viewer.apps",
|
extendsFeature: "EventType",
|
||||||
{
|
});
|
||||||
extendsFeature: "EventType",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const methods = useFormContext<FormValues>();
|
const methods = useFormContext<FormValues>();
|
||||||
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
||||||
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
||||||
@@ -104,7 +71,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}
|
||||||
@@ -120,7 +87,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}
|
||||||
+209
-127
@@ -1,23 +1,107 @@
|
|||||||
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 { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useFormContext, Controller, 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 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 { Icon } from "@calcom/ui";
|
import { Button, DateRangePicker, Icon, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
||||||
import { Button } from "@calcom/ui/components";
|
|
||||||
import { Label, Input, MinutesField } from "@calcom/ui/components/form";
|
|
||||||
import { Select, SettingsToggle } from "@calcom/ui/v2";
|
|
||||||
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
|
|
||||||
|
|
||||||
export const EventLimitsTab = (props: 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 { eventType } = props;
|
|
||||||
const PERIOD_TYPES = [
|
const PERIOD_TYPES = [
|
||||||
{
|
{
|
||||||
type: "ROLLING" as const,
|
type: "ROLLING" as const,
|
||||||
@@ -48,119 +132,114 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
|||||||
});
|
});
|
||||||
|
|
||||||
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}
|
<Select
|
||||||
onChange={(val) => {
|
isSearchable={false}
|
||||||
if (val) onChange(val.value);
|
onChange={(val) => {
|
||||||
}}
|
if (val) onChange(val.value);
|
||||||
defaultValue={
|
}}
|
||||||
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
defaultValue={
|
||||||
}
|
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
||||||
options={beforeBufferOptions}
|
}
|
||||||
/>
|
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="afterBufferTime">{t("after_event")} </Label>
|
<div className="w-full">
|
||||||
<Controller
|
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
||||||
name="afterBufferTime"
|
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||||
control={formMethods.control}
|
</div>
|
||||||
defaultValue={eventType.afterEventBuffer || 0}
|
<div className="w-full">
|
||||||
render={({ field: { onChange, value } }) => {
|
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
||||||
const afterBufferOptions = [
|
<Controller
|
||||||
{
|
name="slotInterval"
|
||||||
label: t("event_buffer_default"),
|
control={formMethods.control}
|
||||||
value: 0,
|
render={() => {
|
||||||
},
|
const slotIntervalOptions = [
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
{
|
||||||
label: minutes + " " + t("minutes"),
|
label: t("slot_interval_default"),
|
||||||
value: minutes,
|
value: -1,
|
||||||
})),
|
},
|
||||||
];
|
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||||
return (
|
label: minutes + " " + t("minutes"),
|
||||||
<Select
|
value: minutes,
|
||||||
isSearchable={false}
|
})),
|
||||||
onChange={(val) => {
|
];
|
||||||
if (val) onChange(val.value);
|
return (
|
||||||
}}
|
<Select
|
||||||
defaultValue={
|
isSearchable={false}
|
||||||
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
|
onChange={(val) => {
|
||||||
}
|
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||||
options={afterBufferOptions}
|
}}
|
||||||
/>
|
defaultValue={
|
||||||
);
|
slotIntervalOptions.find((option) => option.value === eventType.slotInterval) ||
|
||||||
}}
|
slotIntervalOptions[0]
|
||||||
/>
|
}
|
||||||
|
options={slotIntervalOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col space-y-4 pt-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
<hr />
|
||||||
<div className="w-full">
|
|
||||||
<MinutesField
|
|
||||||
required
|
|
||||||
label={t("minimum_booking_notice")}
|
|
||||||
type="number"
|
|
||||||
placeholder="120"
|
|
||||||
{...formMethods.register("minimumBookingNotice", { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
|
||||||
<Controller
|
|
||||||
name="slotInterval"
|
|
||||||
control={formMethods.control}
|
|
||||||
render={() => {
|
|
||||||
const slotIntervalOptions = [
|
|
||||||
{
|
|
||||||
label: t("slot_interval_default"),
|
|
||||||
value: -1,
|
|
||||||
},
|
|
||||||
...[5, 10, 15, 20, 30, 45, 60].map((minutes) => ({
|
|
||||||
label: minutes + " " + t("minutes"),
|
|
||||||
value: minutes,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
isSearchable={false}
|
|
||||||
onChange={(val) => {
|
|
||||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
|
||||||
}}
|
|
||||||
defaultValue={
|
|
||||||
slotIntervalOptions.find((option) => option.value === eventType.slotInterval) ||
|
|
||||||
slotIntervalOptions[0]
|
|
||||||
}
|
|
||||||
options={slotIntervalOptions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="my-8" />
|
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="bookingLimits"
|
name="bookingLimits"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -182,8 +261,7 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
|||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<hr />
|
||||||
<hr className="my-8" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="periodType"
|
name="periodType"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -191,7 +269,7 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
|||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("limit_future_bookings")}
|
title={t("limit_future_bookings")}
|
||||||
description={t("limit_future_bookings_description")}
|
description={t("limit_future_bookings_description")}
|
||||||
checked={value !== "UNLIMITED"}
|
checked={value && value !== "UNLIMITED"}
|
||||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||||
<RadioGroup.Root
|
<RadioGroup.Root
|
||||||
defaultValue={watchPeriodType}
|
defaultValue={watchPeriodType}
|
||||||
@@ -233,7 +311,7 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
|||||||
</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}
|
||||||
@@ -253,7 +331,9 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
|||||||
/>
|
/>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -316,7 +396,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"
|
||||||
@@ -348,7 +430,7 @@ const BookingLimits = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
variant="icon"
|
||||||
StartIcon={Icon.FiTrash}
|
StartIcon={Icon.FiTrash}
|
||||||
color="destructive"
|
color="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
+2
-3
@@ -1,11 +1,10 @@
|
|||||||
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 getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||||
|
|
||||||
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 stripeAppData = getStripeAppData(eventType);
|
||||||
|
|
||||||
const requirePayment = stripeAppData.price > 0;
|
const requirePayment = stripeAppData.price > 0;
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||||
|
import { MultiValue } from "react-select";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { EventLocationType, getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||||
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button, Icon, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { slugify } from "@lib/slugify";
|
||||||
|
|
||||||
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
|
import LocationSelect, {
|
||||||
|
SingleValueLocationOption,
|
||||||
|
LocationOption,
|
||||||
|
} from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
|
const getLocationFromType = (
|
||||||
|
type: EventLocationType["type"],
|
||||||
|
locationOptions: Pick<EventTypeSetupProps, "locationOptions">["locationOptions"]
|
||||||
|
) => {
|
||||||
|
for (const locationOption of locationOptions) {
|
||||||
|
const option = locationOption.options.find((option) => option.value === type);
|
||||||
|
if (option) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EventSetupTab = (
|
||||||
|
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||||
|
) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const formMethods = useFormContext<FormValues>();
|
||||||
|
const { eventType, locationOptions, team } = props;
|
||||||
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
|
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||||
|
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 option = getLocationFromType(type, locationOptions);
|
||||||
|
setSelectedLocation(option);
|
||||||
|
setShowLocationModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
|
||||||
|
formMethods.setValue(
|
||||||
|
"locations",
|
||||||
|
formMethods.getValues("locations").filter((location) => location.type !== selectedLocation.type),
|
||||||
|
{ shouldValidate: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLocation = (newLocationType: EventLocationType["type"], details = {}) => {
|
||||||
|
const locationType = editingLocationType !== "" ? editingLocationType : newLocationType;
|
||||||
|
const existingIdx = formMethods.getValues("locations").findIndex((loc) => locationType === loc.type);
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
const copy = formMethods.getValues("locations");
|
||||||
|
if (editingLocationType !== "") {
|
||||||
|
copy[existingIdx] = {
|
||||||
|
...details,
|
||||||
|
type: newLocationType,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
copy[existingIdx] = {
|
||||||
|
...formMethods.getValues("locations")[existingIdx],
|
||||||
|
...details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
formMethods.setValue("locations", copy);
|
||||||
|
} else {
|
||||||
|
formMethods.setValue(
|
||||||
|
"locations",
|
||||||
|
formMethods.getValues("locations").concat({ type: newLocationType, ...details })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingLocationType("");
|
||||||
|
setShowLocationModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const locationFormSchema = z.object({
|
||||||
|
locationType: z.string(),
|
||||||
|
locationAddress: z.string().optional(),
|
||||||
|
displayLocationPublicly: z.boolean().optional(),
|
||||||
|
locationPhoneNumber: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
|
.optional(),
|
||||||
|
locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationFormMethods = useForm<{
|
||||||
|
locationType: EventLocationType["type"];
|
||||||
|
locationPhoneNumber?: string;
|
||||||
|
locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid?
|
||||||
|
locationLink?: string; // Currently this only accepts links that are HTTPS://
|
||||||
|
displayLocationPublicly?: boolean;
|
||||||
|
}>({
|
||||||
|
resolver: zodResolver(locationFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const Locations = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
|
|
||||||
|
const validLocations = formMethods.getValues("locations").filter((location) => {
|
||||||
|
const eventLocation = getEventLocationType(location.type);
|
||||||
|
if (!eventLocation) {
|
||||||
|
// It's possible that the location app in use got uninstalled.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{validLocations.length === 0 && (
|
||||||
|
<div className="flex">
|
||||||
|
<LocationSelect
|
||||||
|
placeholder={t("select")}
|
||||||
|
options={locationOptions}
|
||||||
|
isSearchable={false}
|
||||||
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
|
onChange={(e: SingleValueLocationOption) => {
|
||||||
|
if (e?.value) {
|
||||||
|
const newLocationType = e.value;
|
||||||
|
const eventLocationType = getEventLocationType(newLocationType);
|
||||||
|
if (!eventLocationType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locationFormMethods.setValue("locationType", newLocationType);
|
||||||
|
if (eventLocationType.organizerInputType) {
|
||||||
|
openLocationModal(newLocationType);
|
||||||
|
} else {
|
||||||
|
saveLocation(newLocationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validLocations.length > 0 && (
|
||||||
|
<ul ref={animationRef}>
|
||||||
|
{validLocations.map((location, index) => {
|
||||||
|
const eventLocationType = getEventLocationType(location.type);
|
||||||
|
if (!eventLocationType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={location.type} className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
|
||||||
|
<div className="flex max-w-full justify-between">
|
||||||
|
<div key={index} className="flex flex-grow items-center">
|
||||||
|
<img
|
||||||
|
src={eventLocationType.iconUrl}
|
||||||
|
className="h-4 w-4"
|
||||||
|
alt={`${eventLocationType.label} logo`}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
|
||||||
|
{t(location[eventLocationType.defaultValueVariable] || eventLocationType.label)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
locationFormMethods.setValue("locationType", location.type);
|
||||||
|
locationFormMethods.unregister("locationLink");
|
||||||
|
locationFormMethods.unregister("locationAddress");
|
||||||
|
locationFormMethods.unregister("locationPhoneNumber");
|
||||||
|
setEditingLocationType(location.type);
|
||||||
|
openLocationModal(location.type);
|
||||||
|
}}
|
||||||
|
aria-label={t("edit")}
|
||||||
|
className="mr-1 p-1 text-gray-500 hover:text-gray-900">
|
||||||
|
<Icon.FiEdit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<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 " />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{validLocations.some((location) => location.type === MeetLocationType) && (
|
||||||
|
<div className="flex text-sm text-gray-600">
|
||||||
|
<Icon.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 && (
|
||||||
|
<li>
|
||||||
|
<Button StartIcon={Icon.FiPlus} color="minimal" onClick={() => setShowLocationModal(true)}>
|
||||||
|
{t("add_location")}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label={t("title")}
|
||||||
|
defaultValue={eventType.title}
|
||||||
|
{...formMethods.register("title")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t("description")}
|
||||||
|
placeholder={t("quick_video_meeting")}
|
||||||
|
defaultValue={eventType.description ?? ""}
|
||||||
|
{...formMethods.register("description")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label={t("URL")}
|
||||||
|
defaultValue={eventType.slug}
|
||||||
|
addOnLeading={
|
||||||
|
<>
|
||||||
|
{CAL_URL?.replace(/^(https?:|)\/\//, "")}/
|
||||||
|
{team ? "team/" + team.slug : eventType.users[0].username}/
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{...formMethods.register("slug", {
|
||||||
|
setValueAs: (v) => slugify(v),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{multipleDuration ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
|
{t("available_durations")}
|
||||||
|
</Skeleton>
|
||||||
|
<Select
|
||||||
|
isMulti
|
||||||
|
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>
|
||||||
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
|
{t("location")}
|
||||||
|
</Skeleton>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="locations"
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={eventType.locations || []}
|
||||||
|
render={() => <Locations />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||||
|
<EditLocationDialog
|
||||||
|
isOpenDialog={showLocationModal}
|
||||||
|
setShowLocationModal={setShowLocationModal}
|
||||||
|
saveLocation={saveLocation}
|
||||||
|
defaultValues={formMethods.getValues("locations")}
|
||||||
|
selection={
|
||||||
|
selectedLocation
|
||||||
|
? { value: selectedLocation.value, label: t(selectedLocation.label), icon: selectedLocation.icon }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
setSelectedLocation={setSelectedLocation}
|
||||||
|
setEditingLocationType={setEditingLocationType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { SchedulingType } from "@prisma/client";
|
||||||
|
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Label, Select } from "@calcom/ui";
|
||||||
|
|
||||||
|
interface IMemberToValue {
|
||||||
|
id: number | null;
|
||||||
|
name: string | null;
|
||||||
|
username: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
|
||||||
|
value: `${id || ""}`,
|
||||||
|
label: `${name || ""}`,
|
||||||
|
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { t } = useLocale();
|
||||||
|
|
||||||
|
const schedulingType = useWatch({
|
||||||
|
control: formMethods.control,
|
||||||
|
name: "schedulingType",
|
||||||
|
});
|
||||||
|
|
||||||
|
const schedulingTypeOptions: {
|
||||||
|
value: SchedulingType;
|
||||||
|
label: string;
|
||||||
|
// description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: SchedulingType.COLLECTIVE,
|
||||||
|
label: t("collective"),
|
||||||
|
// description: t("collective_description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: SchedulingType.ROUND_ROBIN,
|
||||||
|
label: t("round_robin"),
|
||||||
|
// description: t("round_robin_description"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{team && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label>{t("scheduling_type")}</Label>
|
||||||
|
<Controller
|
||||||
|
name="schedulingType"
|
||||||
|
control={formMethods.control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Select
|
||||||
|
options={schedulingTypeOptions}
|
||||||
|
value={schedulingTypeOptions.find((opt) => opt.value === value)}
|
||||||
|
className="w-full"
|
||||||
|
onChange={(val) => {
|
||||||
|
onChange(val?.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{schedulingType === SchedulingType.COLLECTIVE && (
|
||||||
|
<FixedHosts
|
||||||
|
options={teamMembersOptions}
|
||||||
|
placeholder={t("add_attendees")}
|
||||||
|
labelText={t("team")}
|
||||||
|
control={formMethods.control}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||||
|
<>
|
||||||
|
<FixedHosts
|
||||||
|
options={teamMembersOptions}
|
||||||
|
isOptionDisabled={(option) =>
|
||||||
|
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
|
||||||
|
}
|
||||||
|
placeholder={t("add_fixed_hosts")}
|
||||||
|
labelText={t("fixed_hosts")}
|
||||||
|
control={formMethods.control}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||||
|
<div>
|
||||||
|
<Label>{t("round_robin_hosts")}</Label>
|
||||||
|
<Controller
|
||||||
|
name="hosts"
|
||||||
|
control={formMethods.control}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CheckedTeamSelect
|
||||||
|
isDisabled={false}
|
||||||
|
onChange={(options) =>
|
||||||
|
onChange(
|
||||||
|
options.map((option) => ({
|
||||||
|
isFixed: false,
|
||||||
|
userId: parseInt(option.value, 10),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={value
|
||||||
|
.map(
|
||||||
|
(host) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
teamMembers
|
||||||
|
.map(mapUserToValue)
|
||||||
|
.find((member) => member.value === host.userId.toString())!
|
||||||
|
)
|
||||||
|
.filter(Boolean)}
|
||||||
|
controlShouldRenderValue={false}
|
||||||
|
options={teamMembers.map(mapUserToValue)}
|
||||||
|
isOptionDisabled={(option) =>
|
||||||
|
!!formMethods
|
||||||
|
.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { EventTypeSetupProps, FormValues } 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, Icon, showToast } from "@calcom/ui";
|
||||||
|
|
||||||
|
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={Icon.FiPlus}
|
||||||
|
onClick={() => setCreateModalOpen(true)}>
|
||||||
|
{t("new_webhook")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{team && webhooks && !isLoading && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<>
|
||||||
|
{webhooks.length ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 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 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+73
-48
@@ -1,38 +1,45 @@
|
|||||||
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";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import {
|
||||||
import { Button, ButtonGroup, Label } from "@calcom/ui/components";
|
Button,
|
||||||
import { HorizontalTabs, showToast, Switch, Tooltip, VerticalTabs } from "@calcom/ui/v2";
|
ButtonGroup,
|
||||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
ConfirmationDialogContent,
|
||||||
import { Dialog } from "@calcom/ui/v2/core/Dialog";
|
Dialog,
|
||||||
import Divider from "@calcom/ui/v2/core/Divider";
|
DropdownMenuSeparator,
|
||||||
import Dropdown, {
|
Dropdown,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@calcom/ui/v2/core/Dropdown";
|
HorizontalTabs,
|
||||||
import Shell from "@calcom/ui/v2/core/Shell";
|
Icon,
|
||||||
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
Label,
|
||||||
import { Skeleton } from "@calcom/ui/v2/core/skeleton";
|
showToast,
|
||||||
|
Skeleton,
|
||||||
|
Switch,
|
||||||
|
Tooltip,
|
||||||
|
VerticalDivider,
|
||||||
|
VerticalTabs,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
|
||||||
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;
|
||||||
@@ -49,18 +56,21 @@ 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: 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: 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",
|
||||||
@@ -115,9 +125,9 @@ function EventTypeSingleLayout({
|
|||||||
|
|
||||||
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
|
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
|
||||||
|
|
||||||
const deleteMutation = trpc.useMutation("viewer.eventTypes.delete", {
|
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.invalidateQueries(["viewer.eventTypes"]);
|
await utils.viewer.eventTypes.invalidate();
|
||||||
showToast(t("event_type_deleted_successfully"), "success");
|
showToast(t("event_type_deleted_successfully"), "success");
|
||||||
await router.push("/event-types");
|
await router.push("/event-types");
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
@@ -143,13 +153,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: 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]);
|
||||||
|
|
||||||
@@ -191,7 +208,7 @@ 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={Icon.FiExternalLink}
|
||||||
@@ -200,9 +217,8 @@ function EventTypeSingleLayout({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="icon"
|
variant="icon"
|
||||||
StartIcon={Icon.FiLink}
|
StartIcon={Icon.FiLink}
|
||||||
combined
|
|
||||||
tooltip={t("copy_link")}
|
tooltip={t("copy_link")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(permalink);
|
navigator.clipboard.writeText(permalink);
|
||||||
@@ -213,12 +229,12 @@ function EventTypeSingleLayout({
|
|||||||
embedUrl={encodeURIComponent(embedLink)}
|
embedUrl={encodeURIComponent(embedLink)}
|
||||||
StartIcon={Icon.FiCode}
|
StartIcon={Icon.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={Icon.FiTrash}
|
||||||
tooltip={t("delete")}
|
tooltip={t("delete")}
|
||||||
disabled={!hasPermsToDelete}
|
disabled={!hasPermsToDelete}
|
||||||
@@ -229,37 +245,46 @@ 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={Icon.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={Icon.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={Icon.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}
|
StartIcon={Icon.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}
|
||||||
@@ -288,7 +313,7 @@ function EventTypeSingleLayout({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<ClientSuspense fallback={<Loader />}>
|
<Suspense fallback={<Icon.FiLoader />}>
|
||||||
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
<VerticalTabs
|
<VerticalTabs
|
||||||
@@ -304,14 +329,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 xl:-mt-4 " : "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}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export { default } from "@calcom/features/ee/workflows/components/v2/EventWorkflowsTab";
|
export { default } from "@calcom/features/ee/workflows/components/EventWorkflowsTab";
|
||||||
+4
-5
@@ -5,8 +5,7 @@ import { useFormContext } from "react-hook-form";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert, Select, SettingsToggle } from "@calcom/ui";
|
||||||
import { Select, SettingsToggle } from "@calcom/ui/v2";
|
|
||||||
|
|
||||||
type RecurringEventControllerProps = {
|
type RecurringEventControllerProps = {
|
||||||
recurringEvent: RecurringEvent | null;
|
recurringEvent: RecurringEvent | null;
|
||||||
@@ -58,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"
|
||||||
@@ -90,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"
|
||||||
@@ -106,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,18 +1,16 @@
|
|||||||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
import { Icon, SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
|
|
||||||
/** @deprecated Use `apps/web/components/v2/eventtype/SkeletonLoader.tsx` */
|
|
||||||
function SkeletonLoader() {
|
function SkeletonLoader() {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<div className="mb-4 flex items-center">
|
<div className="mb-4 flex items-center">
|
||||||
<SkeletonAvatar width="8" height="8" />
|
<SkeletonAvatar className="h-8 w-8" />
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<SkeletonText className="h-4 w-16" />
|
<SkeletonText className="h-4 w-16" />
|
||||||
<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 />
|
||||||
@@ -28,17 +26,17 @@ function SkeletonItem() {
|
|||||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||||
<div className="flex-grow truncate text-sm">
|
<div className="flex-grow truncate text-sm">
|
||||||
<div>
|
<div>
|
||||||
<SkeletonText width="32" height="5" />
|
<SkeletonText className="h-5 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<Icon.FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||||
<SkeletonText width="12" height="4" />
|
<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" />
|
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||||
<SkeletonText width="16" height="4" />
|
<SkeletonText className="h-4 w-12" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui";
|
||||||
|
|
||||||
interface ICalendarItem {
|
interface ICalendarItem {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Switch } from "@calcom/ui/v2";
|
import { showToast, Switch } from "@calcom/ui";
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.invalidateQueries(["viewer.integrations"]);
|
await utils.viewer.integrations.invalidate();
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showToast(`Something went wrong when toggling "${title}""`, "error");
|
showToast(`Something went wrong when toggling "${title}""`, "error");
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { inferMutationInput, trpc } from "@calcom/trpc/react";
|
import { RouterInputs, trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||||
|
|
||||||
interface ICreateEventsOnCalendarSelectProps {
|
interface ICreateEventsOnCalendarSelectProps {
|
||||||
calendar?: inferMutationInput<"viewer.setDestinationCalendar"> | null;
|
calendar?: RouterInputs["viewer"]["setDestinationCalendar"] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps) => {
|
const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps) => {
|
||||||
const { calendar } = props;
|
const { calendar } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const mutation = trpc.useMutation(["viewer.setDestinationCalendar"]);
|
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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">
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { ArrowRightIcon } from "@heroicons/react/solid";
|
|||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
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 { List } from "@calcom/ui/List";
|
import { SkeletonAvatar, SkeletonText, SkeletonButton, List } from "@calcom/ui";
|
||||||
import { SkeletonAvatar, SkeletonText, SkeletonButton } from "@calcom/ui/v2";
|
|
||||||
|
|
||||||
import { CalendarItem } from "../components/CalendarItem";
|
import { CalendarItem } from "../components/CalendarItem";
|
||||||
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
|
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
|
||||||
@@ -16,12 +15,9 @@ interface IConnectCalendarsProps {
|
|||||||
|
|
||||||
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||||
const { nextStep } = props;
|
const { nextStep } = props;
|
||||||
const queryConnectedCalendars = trpc.useQuery(["viewer.connectedCalendars"]);
|
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const queryIntegrations = trpc.useQuery([
|
const queryIntegrations = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||||
"viewer.integrations",
|
|
||||||
{ variant: "calendar", onlyInstalled: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
|
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
|
||||||
(item) => item.calendars && item.calendars?.length > 0
|
(item) => item.calendars && item.calendars?.length > 0
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc, TRPCClientErrorLike } from "@calcom/trpc/react";
|
import { trpc, TRPCClientErrorLike } from "@calcom/trpc/react";
|
||||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button, Form } from "@calcom/ui";
|
||||||
import { Form } from "@calcom/ui/form/fields";
|
|
||||||
|
|
||||||
interface ISetupAvailabilityProps {
|
interface ISetupAvailabilityProps {
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
@@ -24,9 +23,12 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
let queryAvailability;
|
let queryAvailability;
|
||||||
if (defaultScheduleId) {
|
if (defaultScheduleId) {
|
||||||
queryAvailability = trpc.useQuery(["viewer.availability.schedule", { scheduleId: defaultScheduleId }], {
|
queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||||
enabled: router.isReady,
|
{ scheduleId: defaultScheduleId },
|
||||||
});
|
{
|
||||||
|
enabled: router.isReady,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availabilityForm = useForm({
|
const availabilityForm = useForm({
|
||||||
@@ -43,8 +45,8 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||||||
nextStep();
|
nextStep();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const createSchedule = trpc.useMutation("viewer.availability.schedule.create", mutationOptions);
|
const createSchedule = trpc.viewer.availability.schedule.create.useMutation(mutationOptions);
|
||||||
const updateSchedule = trpc.useMutation("viewer.availability.schedule.update", mutationOptions);
|
const updateSchedule = trpc.viewer.availability.schedule.update.useMutation(mutationOptions);
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
className="w-full bg-white text-black dark:bg-opacity-5 dark:text-white"
|
className="w-full bg-white text-black dark:bg-opacity-5 dark:text-white"
|
||||||
|
|||||||
@@ -4,24 +4,20 @@ 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 { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button } from "@calcom/ui/components";
|
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
|
||||||
import { TextArea } from "@calcom/ui/components/form";
|
import { Avatar } from "@calcom/ui";
|
||||||
import { showToast } from "@calcom/ui/v2";
|
|
||||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
|
||||||
|
|
||||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||||
|
|
||||||
interface IUserProfile {
|
|
||||||
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!);
|
||||||
@@ -32,17 +28,17 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({ defaultValues: { bio: user?.bio || "" } });
|
} = useForm<FormData>({ defaultValues: { bio: user?.bio || "" } });
|
||||||
const { data: eventTypes } = trpc.useQuery(["viewer.eventTypes.list"]);
|
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.useMutation("viewer.eventTypes.create");
|
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
||||||
|
|
||||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (_data, context) => {
|
onSuccess: async (_data, context) => {
|
||||||
if (context.avatar) {
|
if (context.avatar) {
|
||||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||||
await utils.refetchQueries(["viewer.me"]);
|
await utils.viewer.me.refetch();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
if (eventTypes?.length === 0) {
|
if (eventTypes?.length === 0) {
|
||||||
@@ -56,7 +52,7 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await utils.refetchQueries(["viewer.me"]);
|
await utils.viewer.me.refetch();
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -103,14 +99,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">
|
||||||
@@ -143,7 +146,7 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
ref={bioRef}
|
ref={bioRef}
|
||||||
name="bio"
|
name="bio"
|
||||||
id="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"
|
className="mt-1 block h-[60px] w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
|
||||||
defaultValue={user?.bio || undefined}
|
defaultValue={user?.bio || undefined}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setValue("bio", event.target.value);
|
setValue("bio", event.target.value);
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import { useRef, useState } from "react";
|
import { 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 { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button, TimezoneSelect } from "@calcom/ui";
|
||||||
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
|
|
||||||
|
|
||||||
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 {
|
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",
|
||||||
});
|
});
|
||||||
@@ -36,45 +35,25 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
|
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const onSuccess = async () => {
|
const onSuccess = async () => {
|
||||||
await utils.invalidateQueries(["viewer.me"]);
|
await utils.viewer.me.invalidate();
|
||||||
nextStep();
|
nextStep();
|
||||||
};
|
};
|
||||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: onSuccess,
|
onSuccess: onSuccess,
|
||||||
});
|
});
|
||||||
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
|
|
||||||
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,290 +0,0 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Fragment } from "react";
|
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { List } from "@calcom/ui/List";
|
|
||||||
import { ShellSubHeading } from "@calcom/ui/Shell";
|
|
||||||
import Switch from "@calcom/ui/Switch";
|
|
||||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
|
||||||
|
|
||||||
import AdditionalCalendarSelector from "@components/AdditionalCalendarSelector";
|
|
||||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
|
||||||
|
|
||||||
import DisconnectIntegration from "./DisconnectIntegration";
|
|
||||||
import IntegrationListItem from "./IntegrationListItem";
|
|
||||||
import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onChanged: () => unknown | Promise<unknown>;
|
|
||||||
fromOnboarding?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CalendarSwitch(props: {
|
|
||||||
type: string;
|
|
||||||
externalId: string;
|
|
||||||
title: string;
|
|
||||||
defaultSelected: boolean;
|
|
||||||
}) {
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
|
|
||||||
const mutation = useMutation<
|
|
||||||
unknown,
|
|
||||||
unknown,
|
|
||||||
{
|
|
||||||
isOn: boolean;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
async ({ isOn }) => {
|
|
||||||
const body = {
|
|
||||||
integration: props.type,
|
|
||||||
externalId: props.externalId,
|
|
||||||
};
|
|
||||||
if (isOn) {
|
|
||||||
const res = await fetch("/api/availability/calendar", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Something went wrong");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const res = await fetch("/api/availability/calendar", {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Something went wrong");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
async onSettled() {
|
|
||||||
await utils.invalidateQueries(["viewer.integrations"]);
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="py-1">
|
|
||||||
<Switch
|
|
||||||
key={props.externalId}
|
|
||||||
name="enabled"
|
|
||||||
label={props.title}
|
|
||||||
defaultChecked={props.defaultSelected}
|
|
||||||
onCheckedChange={(isOn: boolean) => {
|
|
||||||
mutation.mutate({ isOn });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarList(props: Props) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: false }]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryCell
|
|
||||||
query={query}
|
|
||||||
success={({ data }) => (
|
|
||||||
<List>
|
|
||||||
{data.items.map((item) => (
|
|
||||||
<IntegrationListItem
|
|
||||||
name={item.name}
|
|
||||||
slug={item.slug}
|
|
||||||
key={item.title}
|
|
||||||
title={item.title}
|
|
||||||
logo={item.logo}
|
|
||||||
description={item.description}
|
|
||||||
actions={
|
|
||||||
<InstallAppButton
|
|
||||||
type={item.type}
|
|
||||||
render={(buttonProps) => (
|
|
||||||
<Button color="secondary" {...buttonProps}>
|
|
||||||
{t("connect")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
onChanged={() => props.onChanged()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectedCalendarsList(props: Props) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
|
||||||
const { fromOnboarding } = props;
|
|
||||||
return (
|
|
||||||
<QueryCell
|
|
||||||
query={query}
|
|
||||||
empty={() => null}
|
|
||||||
success={({ data }) => {
|
|
||||||
if (!data.connectedCalendars.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<List>
|
|
||||||
{data.connectedCalendars.map((item) => (
|
|
||||||
<Fragment key={item.credentialId}>
|
|
||||||
{item.calendars ? (
|
|
||||||
<IntegrationListItem
|
|
||||||
slug={item.integration.slug}
|
|
||||||
title={item.integration.title}
|
|
||||||
logo={item.integration.logo}
|
|
||||||
description={item.primary?.externalId || "No external Id"}
|
|
||||||
actions={
|
|
||||||
<DisconnectIntegration
|
|
||||||
id={item.credentialId}
|
|
||||||
externalId={item.primary?.externalId}
|
|
||||||
render={(btnProps) => (
|
|
||||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
|
||||||
{t("disconnect")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
onOpenChange={props.onChanged}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
{!fromOnboarding && (
|
|
||||||
<>
|
|
||||||
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
|
|
||||||
<ul className="space-y-2 p-4">
|
|
||||||
{item.calendars.map((cal) => (
|
|
||||||
<CalendarSwitch
|
|
||||||
key={cal.externalId}
|
|
||||||
externalId={cal.externalId}
|
|
||||||
title={cal.name || "Nameless calendar"}
|
|
||||||
type={item.integration.type}
|
|
||||||
defaultSelected={cal.isSelected}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</IntegrationListItem>
|
|
||||||
) : (
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
title={t("something_went_wrong")}
|
|
||||||
message={t("calendar_error")}
|
|
||||||
actions={
|
|
||||||
<DisconnectIntegration
|
|
||||||
id={item.credentialId}
|
|
||||||
render={(btnProps) => (
|
|
||||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
|
||||||
Disconnect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
onOpenChange={() => props.onChanged()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarListContainer(props: {
|
|
||||||
heading?: boolean;
|
|
||||||
items?: inferQueryOutput<"viewer.integrations">["items"];
|
|
||||||
fromOnboarding?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const { heading = true, fromOnboarding } = props;
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
const onChanged = () =>
|
|
||||||
Promise.allSettled([
|
|
||||||
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
|
|
||||||
exact: true,
|
|
||||||
}),
|
|
||||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
|
||||||
]);
|
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
|
||||||
const installedCalendars = trpc.useQuery([
|
|
||||||
"viewer.integrations",
|
|
||||||
{ variant: "calendar", onlyInstalled: true },
|
|
||||||
]);
|
|
||||||
const mutation = trpc.useMutation("viewer.setDestinationCalendar");
|
|
||||||
return (
|
|
||||||
<QueryCell
|
|
||||||
query={query}
|
|
||||||
customLoader={<SkeletonLoader className="mt-10" />}
|
|
||||||
success={({ data }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{(!!data.connectedCalendars.length || !!installedCalendars.data?.items.length) && (
|
|
||||||
<>
|
|
||||||
{heading && (
|
|
||||||
<ShellSubHeading
|
|
||||||
className="mt-10 mb-0"
|
|
||||||
title={
|
|
||||||
<SubHeadingTitleWithConnections
|
|
||||||
title="Calendars"
|
|
||||||
numConnections={data.connectedCalendars.length}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
subtitle={t("configure_how_your_event_types_interact")}
|
|
||||||
actions={
|
|
||||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
|
||||||
<div className="sm:min-w-80 block max-w-full">
|
|
||||||
<DestinationCalendarSelector
|
|
||||||
onChange={mutation.mutate}
|
|
||||||
isLoading={mutation.isLoading}
|
|
||||||
value={data.destinationCalendar?.externalId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!!data.connectedCalendars.length && (
|
|
||||||
<div className="sm:min-w-80 inline max-w-full">
|
|
||||||
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ConnectedCalendarsList onChanged={onChanged} fromOnboarding={fromOnboarding} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{fromOnboarding && (
|
|
||||||
<>
|
|
||||||
{!!query.data?.connectedCalendars.length && (
|
|
||||||
<ShellSubHeading
|
|
||||||
className="mt-4"
|
|
||||||
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CalendarList onChanged={onChanged} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
|
||||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
|
||||||
import { Dialog } from "@calcom/ui/Dialog";
|
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
|
||||||
|
|
||||||
export default function DisconnectIntegration(props: {
|
|
||||||
/** Integration credential id */
|
|
||||||
id: number;
|
|
||||||
externalId?: string;
|
|
||||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
|
||||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
|
||||||
}) {
|
|
||||||
const { id, externalId = "" } = props;
|
|
||||||
const { t } = useLocale();
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const mutation = trpc.useMutation("viewer.deleteCredential", {
|
|
||||||
onSettled: async () => {
|
|
||||||
await props.onOpenChange(modalOpen);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
showToast("Integration deleted successfully", "success");
|
|
||||||
setModalOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
throw new Error("Something went wrong");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
||||||
<ConfirmationDialogContent
|
|
||||||
variety="danger"
|
|
||||||
title={t("remove_app")}
|
|
||||||
confirmBtnText={t("yes_remove_app")}
|
|
||||||
cancelBtnText="Cancel"
|
|
||||||
onConfirm={() => {
|
|
||||||
mutation.mutate({ id, externalId });
|
|
||||||
}}>
|
|
||||||
{t("are_you_sure_you_want_to_remove_this_app")}
|
|
||||||
</ConfirmationDialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{props.render({
|
|
||||||
onClick() {
|
|
||||||
setModalOpen(true);
|
|
||||||
},
|
|
||||||
disabled: modalOpen,
|
|
||||||
loading: mutation.isLoading,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
|
||||||
import { Dialog } from "@calcom/ui/Dialog";
|
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
|
||||||
|
|
||||||
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
|
|
||||||
|
|
||||||
export default function DisconnectStripeIntegration(props: {
|
|
||||||
/** Integration credential id */
|
|
||||||
id: number;
|
|
||||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
|
||||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
|
||||||
}) {
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const mutation = useMutation(
|
|
||||||
async (action: string) => {
|
|
||||||
const res = await fetch("/api/integrations", {
|
|
||||||
method: "DELETE",
|
|
||||||
body: JSON.stringify({ id: props.id, action }),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Something went wrong");
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
async onSettled() {
|
|
||||||
await props.onOpenChange(modalOpen);
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
showToast(data.message, "success");
|
|
||||||
setModalOpen(false);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
||||||
<DeleteStripeDialogContent
|
|
||||||
variety="warning"
|
|
||||||
title="Disconnect Stripe Integration"
|
|
||||||
cancelAllBookingsBtnText="Cancel all bookings"
|
|
||||||
removeBtnText="Remove payment"
|
|
||||||
cancelBtnText="Cancel"
|
|
||||||
onConfirm={() => {
|
|
||||||
mutation.mutate("cancel");
|
|
||||||
}}
|
|
||||||
onRemove={() => {
|
|
||||||
mutation.mutate("remove");
|
|
||||||
}}>
|
|
||||||
If you have unpaid and unconfirmed bookings, you must choose to cancel them or remove the required
|
|
||||||
payment field.
|
|
||||||
</DeleteStripeDialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{props.render({
|
|
||||||
onClick() {
|
|
||||||
setModalOpen(true);
|
|
||||||
},
|
|
||||||
disabled: modalOpen,
|
|
||||||
loading: mutation.isLoading,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/List";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
|
||||||
|
|
||||||
function IntegrationListItem(props: {
|
|
||||||
imageSrc?: string;
|
|
||||||
slug: string;
|
|
||||||
name?: string;
|
|
||||||
title?: string;
|
|
||||||
description: string;
|
|
||||||
actions?: ReactNode;
|
|
||||||
children?: ReactNode;
|
|
||||||
logo: string;
|
|
||||||
}): JSX.Element {
|
|
||||||
const title = props.name || props.title;
|
|
||||||
return (
|
|
||||||
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
|
||||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
props.logo && <img className="h-10 w-10" 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>
|
|
||||||
</div>
|
|
||||||
<div>{props.actions}</div>
|
|
||||||
</div>
|
|
||||||
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IntegrationListItem;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui";
|
||||||
|
|
||||||
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
||||||
if (opts.num === 0) {
|
if (opts.num === 0) {
|
||||||
|
|||||||
Loaded 100 of 1420 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user