Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b83ee2d57d | ||
|
|
5f39bbe730 | ||
|
|
cf3d8cb4bb | ||
|
|
fbe0018d01 | ||
|
|
6586ed4d87 | ||
|
|
81e57afcf4 | ||
|
|
5cc530593b | ||
|
|
4111f78431 | ||
|
|
def80ea4cf | ||
|
|
2fc4676f6d | ||
|
|
318bafaab8 | ||
|
|
fc10c29037 | ||
|
|
ccb7b1f9ac | ||
|
|
c6c6544f50 | ||
|
|
8a6b93a747 | ||
|
|
ff6c6ccec0 | ||
|
|
c8db177f7d | ||
|
|
8871e1eb21 | ||
|
|
92b2d57871 | ||
|
|
77623c72c5 | ||
|
|
3239976e18 | ||
|
|
611939ffe8 | ||
|
|
1b8ecbe449 | ||
|
|
cd4882429d | ||
|
|
4fd2d80637 | ||
|
|
a343d934fe | ||
|
|
933cc739bd | ||
|
|
c7c75ff4d3 | ||
|
|
41f1920a7a | ||
|
|
e4836ea8f3 | ||
|
|
bfa30aa504 | ||
|
|
b36043b637 | ||
|
|
75026289fd | ||
|
|
80649c9f1a | ||
|
|
7dd75afb63 | ||
|
|
f66e4aeeed | ||
|
|
8225e61710 | ||
|
|
175fc4fcf3 | ||
|
|
f20a5df322 | ||
|
|
62544931b2 | ||
|
|
f6ad1d6ed5 | ||
|
|
d22b1072b5 | ||
|
|
4b356dd937 | ||
|
|
1f51199a7b | ||
|
|
b80a631998 | ||
|
|
bef4422080 | ||
|
|
cfd1a72c64 | ||
|
|
23b3a6661c | ||
|
|
b8b6c48d7d | ||
|
|
81655f9988 | ||
|
|
ff74822c48 | ||
|
|
8ec178debd | ||
|
|
f881061d8d | ||
|
|
2525559d6b | ||
|
|
38fb7a3f77 | ||
|
|
08956e54c5 | ||
|
|
ea65a2a27e | ||
|
|
0d45cfb7ea | ||
|
|
a4fc25e260 | ||
|
|
796c932311 | ||
|
|
4137f63ec7 | ||
|
|
2147cc82a5 | ||
|
|
40e646e9a4 | ||
|
|
cb2225259c | ||
|
|
372cd94d9f | ||
|
|
c7c282f30f | ||
|
|
959488910f | ||
|
|
0ca0cc3265 | ||
|
|
69bc12187e | ||
|
|
5e772530b2 | ||
|
|
3088b1174a | ||
|
|
fadf12b2bd | ||
|
|
c81f30b092 | ||
|
|
476119d627 | ||
|
|
040f03a9b5 | ||
|
|
b552bf6956 | ||
|
|
82d3c4370d | ||
|
|
492b4d3e97 | ||
|
|
0ae319d206 | ||
|
|
749311be2a | ||
|
|
3cce4e5009 | ||
|
|
341f82ccb9 | ||
|
|
e028a339f5 | ||
|
|
c55e432bc3 | ||
|
|
f47c3cd538 | ||
|
|
4b70a20941 | ||
|
|
c873b83965 | ||
|
|
ff859737ec | ||
|
|
607ef71f9f | ||
|
|
24dd4002fa | ||
|
|
078c9ddca5 | ||
|
|
b44e5a7aae | ||
|
|
4374da08e2 | ||
|
|
98b531e6b5 | ||
|
|
74414db760 | ||
|
|
bdf3e34ea1 | ||
|
|
738e0f3e50 | ||
|
|
16ae19abdb | ||
|
|
a2e18ae9ba | ||
|
|
e2cfd0d1d1 | ||
|
|
067b77faf1 | ||
|
|
740c01ac29 | ||
|
|
97cfd1ffa7 | ||
|
|
a7b2cfc90f | ||
|
|
d067b0782a | ||
|
|
203a36e2b5 | ||
|
|
f5a38c3f89 | ||
|
|
f179c23cc5 | ||
|
|
9f83885e8c | ||
|
|
8ffb5529e6 | ||
|
|
2e686d03fa | ||
|
|
cc3be0a6ac | ||
|
|
ec3a3268c9 | ||
|
|
77eed5611e | ||
|
|
a827d1defb | ||
|
|
b08a62d30b | ||
|
|
d6fb0df64f | ||
|
|
03548d8381 | ||
|
|
36d8554fd5 | ||
|
|
6fc9e9315d | ||
|
|
6eb8119a82 | ||
|
|
f4c613e6d1 | ||
|
|
082507aea5 | ||
|
|
90c1270e3d | ||
|
|
94917dbb1e | ||
|
|
fbbcc401cb | ||
|
|
af7d7ec678 | ||
|
|
3b7d85852a | ||
|
|
5362cd66b4 | ||
|
|
2907405d4e | ||
|
|
3c46c39114 | ||
|
|
c6ab889b98 | ||
|
|
a2e2b0ed34 | ||
|
|
bc38b3bc55 | ||
|
|
7611d5c24e | ||
|
|
5d2fd7aafb | ||
|
|
b9ade55ac1 | ||
|
|
d7051c4a3f | ||
|
|
ab50067194 | ||
|
|
cdba1920fc | ||
|
|
98a3508b8f | ||
|
|
446f384405 | ||
|
|
be79b8ecd6 | ||
|
|
371e7b024a | ||
|
|
76807a7e70 | ||
|
|
d3f77bf292 | ||
|
|
2f175eacf5 | ||
|
|
b45aea3f26 | ||
|
|
c401a58bb1 | ||
|
|
05b42c001f | ||
|
|
20f7a5841e | ||
|
|
dbf6bb991f | ||
|
|
75cd5a05b8 | ||
|
|
ca6afed27c | ||
|
|
45250f6f3e | ||
|
|
bd5af9e821 | ||
|
|
4267c9063b | ||
|
|
2b49d96e4a | ||
|
|
127c96b46c | ||
|
|
3595ba9824 | ||
|
|
56acfa38f1 | ||
|
|
e78fb22451 | ||
|
|
99f736b317 | ||
|
|
ee9f80ae3b | ||
|
|
a3c283da5e | ||
|
|
0ef678b2cf | ||
|
|
4f466fd95d | ||
|
|
5672a721d9 | ||
|
|
5ad45900ea | ||
|
|
ada88a2f7e | ||
|
|
9ba90e18de | ||
|
|
a88eb0fdc5 | ||
|
|
4b4c1200cd | ||
|
|
1cea7f9760 | ||
|
|
28dcf69d18 | ||
|
|
83d5176ddb | ||
|
|
3815a4c4bf | ||
|
|
53748eb380 | ||
|
|
28af11f90e | ||
|
|
ea676907b2 | ||
|
|
221c8b75bc | ||
|
|
5b9b7ab22d | ||
|
|
668ea088ef | ||
|
|
8a6c420268 | ||
|
|
d2f8a5c076 | ||
|
|
9c0f7bbfc7 | ||
|
|
8297824051 | ||
|
|
7ff5b66f46 | ||
|
|
55570ed4bf | ||
|
|
51695ee79d | ||
|
|
543466f1b9 | ||
|
|
4abf2d67d9 | ||
|
|
1eeb91a793 | ||
|
|
a4725920ff | ||
|
|
4cc7766f88 | ||
|
|
0c78715e9a | ||
|
|
671d9b4903 | ||
|
|
524cefcbad | ||
|
|
72f72d86b2 | ||
|
|
c5c29322d3 | ||
|
|
863b04e9c1 | ||
|
|
c2833f1b44 | ||
|
|
25df264823 | ||
|
|
e870312ebe | ||
|
|
5371bff6ff | ||
|
|
c791344c8b | ||
|
|
5e149d9f5f | ||
|
|
3e53aa13eb | ||
|
|
317f961bc8 | ||
|
|
b0c57a5c38 | ||
|
|
39c16bebd5 | ||
|
|
b3a5f4a6c0 | ||
|
|
379785196d | ||
|
|
04f8de238f | ||
|
|
0af2be9d8f | ||
|
|
98d31157cb | ||
|
|
f99d518a06 | ||
|
|
62770ecc49 | ||
|
|
be05ec66ce | ||
|
|
8defc351be | ||
|
|
ab112d92ea | ||
|
|
cf1b124dc1 | ||
|
|
38158ee043 | ||
|
|
8d214cd8ac | ||
|
|
01ed11ae33 | ||
|
|
5c7756ce6b | ||
|
|
a8c5227536 | ||
|
|
f3730daf05 | ||
|
|
47d5d800b0 | ||
|
|
f6d00a41fb | ||
|
|
692f2778e4 | ||
|
|
81d8628a87 | ||
|
|
07fbc4cbd3 | ||
|
|
8cb9a96c3a | ||
|
|
73b1c1dfb7 | ||
|
|
224a8864a6 | ||
|
|
6d02ac6729 | ||
|
|
f838e8bd53 | ||
|
|
5dac6896d2 | ||
|
|
496cb265d1 | ||
|
|
c037a62a9d | ||
|
|
88d17165ec | ||
|
|
0e7e1c2cc0 | ||
|
|
eb97feb173 | ||
|
|
7d702dd546 | ||
|
|
82b6b83885 | ||
|
|
39b6def444 | ||
|
|
7f5cdbab9b | ||
|
|
1770b832f4 | ||
|
|
d30c0a626d | ||
|
|
2eed750ef6 | ||
|
|
dd8968d0e7 | ||
|
|
827bebbc31 | ||
|
|
d97ac0e0f8 | ||
|
|
600d2725aa | ||
|
|
2a09dc6a46 | ||
|
|
1f746b67f4 | ||
|
|
bb95f2ba39 | ||
|
|
3d93b4127c | ||
|
|
094fae38fc | ||
|
|
3af614f778 | ||
|
|
77b41a4623 | ||
|
|
c23738b885 | ||
|
|
afe1f5b72f | ||
|
|
5ce341093b | ||
|
|
fc6d2839fb | ||
|
|
2fadba85f4 | ||
|
|
473b36a6be | ||
|
|
f74b9eb70a | ||
|
|
a845841ad0 | ||
|
|
dba80a1b64 | ||
|
|
41558d297c | ||
|
|
cd3946d482 | ||
|
|
dcbf695217 | ||
|
|
49cbdda4cf | ||
|
|
22f458cb74 | ||
|
|
561d9f154b | ||
|
|
0c9b9bc296 | ||
|
|
552bfcc7db | ||
|
|
986f9dca8d | ||
|
|
3af5a7404e | ||
|
|
10d3c6ecaa | ||
|
|
a0c37ea4b7 | ||
|
|
798999ad1a | ||
|
|
d40e306baa | ||
|
|
e558e61f6b | ||
|
|
210eafcd6e | ||
|
|
c05e7d75a5 | ||
|
|
d0ebf3319a | ||
|
|
9eb4187644 | ||
|
|
958cebc82c | ||
|
|
0c36dcac4c | ||
|
|
fe996b4e41 | ||
|
|
c1c95f711b | ||
|
|
7cb7006c54 | ||
|
|
e1403bfbeb | ||
|
|
c38dd2d072 | ||
|
|
f9d9e74d6b | ||
|
|
2ab597addc | ||
|
|
33e394d5ed | ||
|
|
4577de3b17 | ||
|
|
658b529840 | ||
|
|
4aed769da4 | ||
|
|
6c753290aa | ||
|
|
59c91607db | ||
|
|
b0530d59af | ||
|
|
b1ff829745 | ||
|
|
d202b536b5 | ||
|
|
2ac5ef10ba | ||
|
|
8a2ab68e9a | ||
|
|
877220caa0 | ||
|
|
0686c08de3 | ||
|
|
d3f1f8b906 | ||
|
|
33a69d3ef6 | ||
|
|
d91b451d7b | ||
|
|
9e17149e5e | ||
|
|
de61b032b7 | ||
|
|
bdbf9bd04d | ||
|
|
ccd589ef38 | ||
|
|
b4c3753566 | ||
|
|
2fdadb98ac | ||
|
|
a33847a878 | ||
|
|
88cf535c8e | ||
|
|
ea0b59d7c0 | ||
|
|
39b93cdb5b | ||
|
|
e27fc05d5f | ||
|
|
20c010fef1 | ||
|
|
1a7911e9e2 | ||
|
|
cb09b5bc5f | ||
|
|
a99031bdd2 | ||
|
|
ada8a98362 | ||
|
|
e176e81bae | ||
|
|
b64dd66c2c | ||
|
|
7fd162601b | ||
|
|
c1f346c935 | ||
|
|
36d47bd2d8 | ||
|
|
26cdb6521f | ||
|
|
014f1986de | ||
|
|
3ef328421f | ||
|
|
c4fe69d826 | ||
|
|
6fd64b2209 | ||
|
|
585e36793a | ||
|
|
09b9370e13 | ||
|
|
a76a4996b4 | ||
|
|
b28d60b6dc | ||
|
|
a716eda14c | ||
|
|
99f1524183 | ||
|
|
7ce6454915 | ||
|
|
93cb21f55a | ||
|
|
0aca769d06 | ||
|
|
c0bda6257e | ||
|
|
ae22246f05 | ||
|
|
e2931d9fa2 | ||
|
|
e6a8a31b53 | ||
|
|
ee86c751f7 | ||
|
|
073b212c05 | ||
|
|
cd8e2124c4 | ||
|
|
9dd36bd20e | ||
|
|
b1404356ff | ||
|
|
f467bb7957 | ||
|
|
597b14cf4c | ||
|
|
2bdd6df755 | ||
|
|
ad958f10bd | ||
|
|
e5eb7c3906 | ||
|
|
667d42d5da | ||
|
|
a23a9ad15a | ||
|
|
4482ffc66b | ||
|
|
493efabd2e | ||
|
|
a0355e276d | ||
|
|
b5c993e153 | ||
|
|
da3ee3f1fe | ||
|
|
d9fc595444 | ||
|
|
06d13e0f6e | ||
|
|
7dc3266133 | ||
|
|
2c96444058 | ||
|
|
39c48e7f79 | ||
|
|
b6e1989f17 | ||
|
|
45b5b2a376 | ||
|
|
05726ec94c | ||
|
|
6340ff43d8 | ||
|
|
e64b26c7e1 | ||
|
|
c829a7de82 | ||
|
|
0205bfb066 | ||
|
|
5fd7d63388 | ||
|
|
e1ea2cb532 | ||
|
|
88d151aa35 | ||
|
|
4e7c93a322 | ||
|
|
281c7628c1 | ||
|
|
1b24bc14cd | ||
|
|
be3d2fca05 | ||
|
|
327e11e7c7 | ||
|
|
bd999345f4 | ||
|
|
081776b4f9 | ||
|
|
8c328ce5d7 | ||
|
|
0fa2a6aea3 | ||
|
|
440251f9c3 | ||
|
|
7f6847f7c7 | ||
|
|
9f1fa83a97 | ||
|
|
f00b112792 | ||
|
|
5339a489f5 | ||
|
|
f7df204e0b | ||
|
|
4975aba5e0 | ||
|
|
29d61ad67b | ||
|
|
6e555e64fa | ||
|
|
c748bbc7e6 | ||
|
|
0681359bf3 | ||
|
|
4c9d4d7972 | ||
|
|
67f8de42b3 | ||
|
|
87584132b5 | ||
|
|
5b943742e0 | ||
|
|
fe155b0b97 | ||
|
|
6639588c2d | ||
|
|
7f9e8ca7a6 | ||
|
|
e478a46358 | ||
|
|
7c9012738a | ||
|
|
5c763389f7 | ||
|
|
7fde30edf9 | ||
|
|
0e22e92cab | ||
|
|
17f48614ff | ||
|
|
2f33b57eb7 | ||
|
|
6e5c3a88ee | ||
|
|
9024914f23 | ||
|
|
966bc08eb4 | ||
|
|
c545331cfe | ||
|
|
ce763f30f6 | ||
|
|
d453ae9f2b | ||
|
|
9f2b41e597 | ||
|
|
86e903ce4a | ||
|
|
9d3963436b | ||
|
|
d8218a6243 | ||
|
|
cdc3833e09 | ||
|
|
52b7e401a1 | ||
|
|
5170fc2424 | ||
|
|
7349fb9f6d | ||
|
|
a8368aac79 | ||
|
|
91f381bce9 | ||
|
|
fb9c1b18e0 | ||
|
|
1d18a09cc8 | ||
|
|
887d63ceef | ||
|
|
555fcc21f2 | ||
|
|
f45fb03838 | ||
|
|
46611fdf77 | ||
|
|
c0f7c40bec | ||
|
|
f6f257c705 | ||
|
|
d61f50724b | ||
|
|
960aa0e00b | ||
|
|
817e20f11e | ||
|
|
af0123a606 | ||
|
|
709bbf6626 | ||
|
|
53097ee3bc | ||
|
|
31f2002654 | ||
|
|
ee155c3643 | ||
|
|
8ab20c3c9f | ||
|
|
4f6d035d29 | ||
|
|
2e5b42ef47 | ||
|
|
b055672ea0 | ||
|
|
e4fbca388c | ||
|
|
2b8bba09c9 | ||
|
|
98cf29f655 | ||
|
|
ae8706316f | ||
|
|
ec8e08d442 | ||
|
|
c33f424333 | ||
|
|
6f2312d1e2 | ||
|
|
c2b6070b7e | ||
|
|
a5bebe2b48 | ||
|
|
e3a1f20cc8 | ||
|
|
ab639591da | ||
|
|
21a9b64eb0 | ||
|
|
636ee7b8a1 | ||
|
|
543e526568 | ||
|
|
e465c1d71d | ||
|
|
cdab9037ce | ||
|
|
21e454d0e2 | ||
|
|
7384f29a1c | ||
|
|
9016a854ca | ||
|
|
446c29dd9c | ||
|
|
2cf1f07d8f | ||
|
|
4f03f8e66d | ||
|
|
e1096620e6 | ||
|
|
d416386d7b | ||
|
|
5b7c55b029 | ||
|
|
9b05311d7f | ||
|
|
313d25d7d8 | ||
|
|
5ec9c41f7c | ||
|
|
d56386d016 | ||
|
|
0968ede733 | ||
|
|
8e3ed28ba9 | ||
|
|
82ea4e9fb7 | ||
|
|
8a440c24c0 | ||
|
|
88a8da3ef6 | ||
|
|
49290fba72 | ||
|
|
a363b1e903 | ||
|
|
7b5e2c8bab | ||
|
|
4b0d22655d | ||
|
|
4f92f60d85 | ||
|
|
6986b5af40 | ||
|
|
e043a5efd8 | ||
|
|
bff28e5d6f | ||
|
|
f10ff18d9f | ||
|
|
cefe4754c7 | ||
|
|
d7aa3c7163 | ||
|
|
e899a92071 | ||
|
|
ebdbe407cd | ||
|
|
e7f917bab9 | ||
|
|
ba9688a04a | ||
|
|
f0e15d77dc | ||
|
|
30b72b687e | ||
|
|
bbf50dbc53 | ||
|
|
bb547a0111 | ||
|
|
1758288948 | ||
|
|
11789c725a | ||
|
|
73bfe0aebe | ||
|
|
24140f0f29 | ||
|
|
e504f5cd9e | ||
|
|
72629ffee0 | ||
|
|
5b6f007d8f | ||
|
|
2e0951d4dc | ||
|
|
fd84b5754d | ||
|
|
73afb03308 | ||
|
|
99dba73d0b | ||
|
|
db4449d849 | ||
|
|
53e52a0510 | ||
|
|
cf4bd8a77d | ||
|
|
9d61dc2f7c | ||
|
|
caf5918b72 | ||
|
|
84f64f5d06 | ||
|
|
13a53c020b | ||
|
|
a72f90fe02 | ||
|
|
283d5b4a65 | ||
|
|
81b2e37efd | ||
|
|
469967b320 | ||
|
|
d708625da0 | ||
|
|
acf2487eaa | ||
|
|
b81ca0e2ab | ||
|
|
2357f76cc8 | ||
|
|
304e8aaa68 | ||
|
|
9fe912c392 | ||
|
|
ab7ecff580 | ||
|
|
19e830acad | ||
|
|
458300bc83 | ||
|
|
352a467104 | ||
|
|
c052c66abc | ||
|
|
8f8ed4d80d | ||
|
|
04fb7e07b4 | ||
|
|
20d2129f81 | ||
|
|
ae5425f908 | ||
|
|
762810684e | ||
|
|
4720dc410f | ||
|
|
715aea5733 | ||
|
|
6cbb9bf967 | ||
|
|
d8176b2eb4 | ||
|
|
6c51e2a970 | ||
|
|
3a08eb3dca | ||
|
|
cee905bc5a | ||
|
|
72fd6334c6 | ||
|
|
63f51abd84 | ||
|
|
6e856b3bd1 | ||
|
|
5fceee27fe | ||
|
|
4f590ebbf7 | ||
|
|
736cd63509 | ||
|
|
3aaa1cde0d | ||
|
|
02d32321aa | ||
|
|
697f6db2c4 | ||
|
|
83364a9e97 | ||
|
|
1620ae660f | ||
|
|
605f9c61f3 | ||
|
|
564862369f | ||
|
|
be6cf4705c | ||
|
|
a2457ab619 | ||
|
|
867966dd75 | ||
|
|
08ad4c07ea | ||
|
|
78da57055e | ||
|
|
d5c3d9d967 | ||
|
|
aa0e9e6a02 | ||
|
|
cad97f1779 | ||
|
|
31ee689672 | ||
|
|
3edb6f5ffd | ||
|
|
a8825badec | ||
|
|
04c634ec4b | ||
|
|
8dd9b8d74b | ||
|
|
0365157638 | ||
|
|
a571d40958 | ||
|
|
69491d5700 | ||
|
|
20022e2cdd | ||
|
|
492858b45b | ||
|
|
b49ad8294b | ||
|
|
aa30ff7d1a | ||
|
|
cfa720a523 | ||
|
|
9ab196c30e | ||
|
|
47e13cd2fd | ||
|
|
74ab6d209e | ||
|
|
46f0b55a45 | ||
|
|
e4893c2218 | ||
|
|
fa17139e8f | ||
|
|
1cdc97dd37 | ||
|
|
f630e8af40 | ||
|
|
fe0745121b | ||
|
|
f6d7568d0a | ||
|
|
520e7fe036 | ||
|
|
1bf50e09cd | ||
|
|
d1ca7b4bdd | ||
|
|
4e2473531f | ||
|
|
efb9bf7116 | ||
|
|
49ee5d300b | ||
|
|
1512551903 | ||
|
|
1b459eb212 | ||
|
|
e4eb21bf8d | ||
|
|
79f7a16f62 | ||
|
|
7fc884912e | ||
|
|
20b3efbcb6 | ||
|
|
427b19238d | ||
|
|
696122a701 | ||
|
|
7d9c21cf9a | ||
|
|
eb93524f49 | ||
|
|
07fd3ad29f | ||
|
|
389633394b | ||
|
|
d27817ad51 | ||
|
|
7af23305eb | ||
|
|
db947591ad | ||
|
|
2ef4a05750 | ||
|
|
f1ac45ee19 | ||
|
|
03c9fc7952 | ||
|
|
de2a56ebcb | ||
|
|
0bae688da1 | ||
|
|
7967a65ade | ||
|
|
1cd96d4c25 | ||
|
|
19bc329377 | ||
|
|
0c29f2a135 | ||
|
|
a145de56ee | ||
|
|
d2352da54b | ||
|
|
5d7685f2c0 | ||
|
|
ee8b2e67f0 | ||
|
|
a0c9123ee7 | ||
|
|
131611e114 | ||
|
|
b3407e19b5 | ||
|
|
0d4b0847d4 | ||
|
|
5d8575abf4 | ||
|
|
631bf4d9fe | ||
|
|
8fd1d76227 | ||
|
|
22b189cb19 | ||
|
|
27c29c65e2 | ||
|
|
f0c7d7ff29 | ||
|
|
679965782e | ||
|
|
650ecc7a85 | ||
|
|
8d27dcdea3 | ||
|
|
6128be8860 | ||
|
|
13577a5dbe | ||
|
|
9ba13a3f3c | ||
|
|
9ac562cfba | ||
|
|
e1411b3fb2 | ||
|
|
0f89415ec3 | ||
|
|
c6ff3dca4a | ||
|
|
e001c0b88f | ||
|
|
5cbd334a42 | ||
|
|
470ef737b5 | ||
|
|
f3e8bed1cf | ||
|
|
750362c0cf | ||
|
|
3811dd564d | ||
|
|
99fe67b879 | ||
|
|
2685e8154f | ||
|
|
4308ea8d52 | ||
|
|
358aa8be2b | ||
|
|
b20c0df4e8 | ||
|
|
09af14b3f4 | ||
|
|
77df4117d0 | ||
|
|
84f7924215 | ||
|
|
c8554e6878 | ||
|
|
5993e0842c | ||
|
|
7179b09d7b | ||
|
|
325dc9805f | ||
|
|
0f6a678b74 | ||
|
|
96308cd607 | ||
|
|
91464fb5e5 | ||
|
|
49d6dd9802 | ||
|
|
54cefcb16e | ||
|
|
f20d78bec1 | ||
|
|
089f52b425 | ||
|
|
47bf6d27d9 | ||
|
|
2cc937ba87 | ||
|
|
a57519040e | ||
|
|
3924731266 | ||
|
|
74986bb6a3 | ||
|
|
9568e16ef6 | ||
|
|
e88716758e | ||
|
|
fcb06a131b | ||
|
|
d778fe6a1b | ||
|
|
877f72d11a | ||
|
|
f728272c7f | ||
|
|
84fd277a97 | ||
|
|
ae0ed4176b | ||
|
|
ec4228ab6a | ||
|
|
19b1f8a05f | ||
|
|
1ba6b08edf | ||
|
|
f027f018ff | ||
|
|
9fd7ca65b5 | ||
|
|
c0f4c8e035 | ||
|
|
ed750c8df1 | ||
|
|
d545a9e127 | ||
|
|
6c55138589 | ||
|
|
18414e857a | ||
|
|
1c8f9cb3fd | ||
|
|
0871314d53 | ||
|
|
02d70c63a5 | ||
|
|
71a3374930 | ||
|
|
a22e4eed32 | ||
|
|
1625f462c2 | ||
|
|
5560f9def7 | ||
|
|
14c38f5108 | ||
|
|
229aeeb0c9 | ||
|
|
6a9704349d | ||
|
|
d2ffa08c83 | ||
|
|
2317473545 | ||
|
|
d81d772cdf | ||
|
|
cbc9cd60d5 | ||
|
+24 |
eb66931366 | ||
|
|
164f8918f5 | ||
|
|
7b21839c73 | ||
|
|
f3360e6476 | ||
|
|
e24f1d8bdb | ||
|
|
d50473468a | ||
|
|
4e8653cf4e | ||
|
|
7edebbcef0 | ||
|
|
f22bf721f2 | ||
|
|
ba0e443c52 | ||
|
|
aebfc7362e | ||
|
|
6d82e3f1b6 | ||
|
|
dbf74890a5 | ||
|
|
95c1c81b3b | ||
|
|
2305b926cd | ||
|
|
d9a555d94a | ||
|
|
943142e77f | ||
|
|
4ea729ca53 | ||
|
|
fa383d671b | ||
|
|
77bfd3773b | ||
|
|
4cd32fca4a | ||
|
|
c374f4fa20 | ||
|
|
2ddada49f9 | ||
|
|
feb8218e4e | ||
|
|
df4fedd61d | ||
|
|
d80581ad58 | ||
|
|
65687d6110 | ||
|
|
b3c973e824 | ||
|
|
913466d06d | ||
|
|
18c1a6a3a2 | ||
|
|
fe17203cea | ||
|
|
3a6f05f1e3 | ||
|
|
5ebbd514d8 | ||
|
|
58b439ca65 | ||
|
|
c32aadf297 | ||
|
|
736c30f134 | ||
|
|
7b169ebfc2 | ||
|
|
8f5843ab95 | ||
|
|
57d17b74c3 | ||
|
|
d40b934866 | ||
|
|
2e5c0c6332 | ||
|
|
b03bcc7470 | ||
|
|
2da5b19a76 | ||
|
|
0ee3b26ca8 | ||
|
|
e836a042e9 | ||
|
|
37038a0cbf | ||
|
|
8b47c064ff |
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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"
|
||||
}
|
||||
+14
-1
@@ -15,16 +15,23 @@
|
||||
# - LARK
|
||||
# - WEB3
|
||||
# - SALESFORCE
|
||||
# - ZOHOCRM
|
||||
# - ZOHO_BIGIN
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
# - DAILY.CO VIDEO
|
||||
# Enables Cal Video. to get your key
|
||||
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
# 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
# @see https://github.com/calcom/cal.com#obtaining-daily-api-credentials
|
||||
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS=
|
||||
|
||||
# To enable Login with Google you need to:
|
||||
@@ -109,4 +116,10 @@ INFURA_API_KEY=""
|
||||
# Used for the Salesforce (Sales Cloud) app
|
||||
SALESFORCE_CONSUMER_KEY=""
|
||||
SALESFORCE_CONSUMER_SECRET=""
|
||||
|
||||
# - ZOHOCRM
|
||||
# Used for the Zoho CRM integration
|
||||
ZOHOCRM_CLIENT_ID=""
|
||||
ZOHOCRM_CLIENT_SECRET=""
|
||||
|
||||
# *********************************************************************************************************
|
||||
|
||||
+20
-7
@@ -22,6 +22,13 @@ CALCOM_LICENSE_KEY=
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
|
||||
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
|
||||
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
|
||||
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
|
||||
# PRISMA_GENERATE_DATAPROXY=true
|
||||
PRISMA_GENERATE_DATAPROXY=
|
||||
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - SHARED **************************************************************************************************
|
||||
@@ -72,6 +79,9 @@ CALENDSO_ENCRYPTION_KEY=
|
||||
# Intercom Config
|
||||
NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
|
||||
# Secret to enable Intercom Identity Verification
|
||||
INTERCOM_SECRET=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
|
||||
@@ -128,12 +138,16 @@ API_KEY_PREFIX=cal_
|
||||
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
||||
|
||||
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
||||
# Configuration to receive emails locally (mailhog)
|
||||
EMAIL_SERVER_HOST='localhost'
|
||||
EMAIL_SERVER_PORT=1025
|
||||
|
||||
# Note: The below configuration for Office 365 has been verified to work.
|
||||
EMAIL_SERVER_HOST='smtp.office365.com'
|
||||
EMAIL_SERVER_PORT=587
|
||||
EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||
# EMAIL_SERVER_HOST='smtp.office365.com'
|
||||
# EMAIL_SERVER_PORT=587
|
||||
# EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
|
||||
EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
# EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
|
||||
# The following configuration for Gmail has been verified to work.
|
||||
# EMAIL_SERVER_HOST='smtp.gmail.com'
|
||||
@@ -153,9 +167,6 @@ CLOSECOM_API_KEY=
|
||||
# Sendgrid internal sync service
|
||||
SENDGRID_SYNC_API_KEY=
|
||||
|
||||
# Sentry
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_IGNORE_API_RESOLUTION_ERROR=
|
||||
|
||||
# Change your Brand
|
||||
NEXT_PUBLIC_APP_NAME="Cal.com"
|
||||
@@ -170,3 +181,5 @@ CSP_POLICY=
|
||||
|
||||
# Vercel Edge Config
|
||||
EDGE_CONFIG=
|
||||
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
packages/prisma/zod
|
||||
apps/web/public/embed
|
||||
+3
-2
@@ -5,7 +5,8 @@
|
||||
# the repo. Unless a later match takes precedence,
|
||||
# @global-owner1 and @global-owner2 will be requested for
|
||||
# review when someone opens a pull request.
|
||||
* @calcom/reviewers
|
||||
# ⬇️ Removed so we can manually trigger checks
|
||||
# * @calcom/reviewers
|
||||
|
||||
.github/ @calcom/core
|
||||
.husky/ @calcom/core
|
||||
@@ -30,4 +31,4 @@ packages/features/ee/workflows @CarinaWolli @calcom/core
|
||||
packages/features/tips @PeerRich @calcom/core
|
||||
packages/ui @calcom/ui
|
||||
packages/config @calcom/core
|
||||
packages/app-store/ee/routing-forms @hariombalhara @calcom/core
|
||||
packages/app-store/routing-forms @hariombalhara @calcom/core
|
||||
|
||||
@@ -12,13 +12,13 @@ Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete options that are not relevant. -->
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
- Bug fix (non-breaking change which fixes an issue)
|
||||
- Chore (refactoring code, technical debt, workflow improvements)
|
||||
- New feature (non-breaking change which adds functionality)
|
||||
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- This change requires a documentation update
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ description: "Cache or restore if necessary"
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
default: v18.x
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -20,6 +20,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/apps/web/.next
|
||||
${{ github.workspace }}/apps/web/public/embed
|
||||
**/.turbo/**
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
name: Yarn install
|
||||
description: "Install all NPM dependencies, caches them and restores if necessary"
|
||||
########################################################################################
|
||||
# "yarn install" composite action for yarn 2/3/4+ and "nodeLinker: node-modules" #
|
||||
#--------------------------------------------------------------------------------------#
|
||||
# Cache: #
|
||||
# - Downloaded zip archive (multi-arch, preserved across yarn.lock changes) #
|
||||
# - Yarn install state (discarded on yarn.lock changes) #
|
||||
# References: #
|
||||
# - bench: https://gist.github.com/belgattitude/0ecd26155b47e7be1be6163ecfbb0f0b #
|
||||
# - vs @setup/node: https://github.com/actions/setup-node/issues/325 #
|
||||
########################################################################################
|
||||
|
||||
name: "Yarn install"
|
||||
description: "Run yarn install with node_modules linker and cache enabled"
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
default: v18.x
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -11,9 +23,40 @@ runs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
cache: "yarn"
|
||||
- name: Yarn install
|
||||
- name: Expose yarn config as "$GITHUB_OUTPUT"
|
||||
id: yarn-config
|
||||
shell: bash
|
||||
run: |
|
||||
yarn install --prefer-offline --frozen-lockfile
|
||||
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
|
||||
# Yarn cache is also reusable between arch and os.
|
||||
- name: Restore yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-download-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
|
||||
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: |
|
||||
yarn-download-cache-
|
||||
|
||||
# Invalidated on yarn.lock changes
|
||||
- name: Restore yarn install state
|
||||
id: yarn-install-state-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .yarn/ci-cache/
|
||||
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
yarn install --inline-builds
|
||||
yarn prisma generate
|
||||
env:
|
||||
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS: "false" # So it doesn't try to remove our private submodule deps
|
||||
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
|
||||
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
|
||||
# Other environment variables
|
||||
HUSKY: "0" # By default do not run HUSKY install
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "tsc-absolute",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "(?:^|\\s)([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"severity": 4,
|
||||
"code": 5,
|
||||
"message": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: "Apply issue labels to PR"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
label_on_pr:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Apply labels from linked issue to PR
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_DATA: ${{ toJson(github.event.pull_request) }}
|
||||
run: node ./.github/workflows/scripts/apply-issue-labels-to-pr.ts
|
||||
@@ -10,8 +10,10 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Show info
|
||||
run: node -e "console.log(require('v8').getHeapStatistics())"
|
||||
|
||||
- run: yarn type-check
|
||||
- name: Configure TSC problem matcher
|
||||
run: |
|
||||
echo "::remove-matcher owner=tsc::"
|
||||
echo "::add-matcher::.github/matchers/tsc-absolute.json"
|
||||
- run: yarn type-check:ci
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Cleanup Preview Databases
|
||||
|
||||
env:
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_PROJECT_ID: cl4u26bwz7962859ply7ibuo43t
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
delete:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: snaplet/action@v1
|
||||
if: ${{ env.SNAPLET_ACCESS_TOKEN && env.SNAPLET_PROJECT_ID }}
|
||||
with:
|
||||
delete: true
|
||||
@@ -1,11 +1,12 @@
|
||||
name: Cron - downgradeUsers
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
# Runs “Every month at 1st (see https://crontab.guru)
|
||||
- cron: "0 0 1 * *"
|
||||
jobs:
|
||||
cron-downgradeUsers:
|
||||
env:
|
||||
|
||||
@@ -2,6 +2,7 @@ name: Cron - mark stale for inactive issues
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
@@ -15,8 +16,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v7
|
||||
with:
|
||||
days-before-stale: 60
|
||||
include-only-assigned: true
|
||||
days-before-close: -1
|
||||
|
||||
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: 7
|
||||
stale-pr-message: "This PR is being marked as stale due to inactivity."
|
||||
close-pr-message: "This PR is being closed due to inactivity. Please reopen if work is intended to be continued."
|
||||
|
||||
@@ -15,14 +15,19 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@1.4.9
|
||||
uses: crowdin/github-action@1.5.1
|
||||
with:
|
||||
upload_translations: true
|
||||
upload_sources: true
|
||||
download_translations: true
|
||||
pull_request_labels: "♻️ autoupdate, automerge"
|
||||
push_translations: true
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
localization_branch_name: main
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# .github/workflows/preview.yml
|
||||
|
||||
name: Preview Environment
|
||||
|
||||
env:
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_PROJECT_ID: ${{ secrets.SNAPLET_PROJECT_ID }}
|
||||
VERCEL_ACCESS_TOKEN: ${{ secrets.VERCEL_ACCESS_TOKEN }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
db-preview-deploy:
|
||||
if: ${{ github.event.action == 'opened' || github.event.action == 'synchronize' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: snaplet
|
||||
uses: snaplet/action@v1
|
||||
- uses: snaplet/vercel-action@v1
|
||||
with:
|
||||
env: |
|
||||
DATABASE_URL=${{ steps.snaplet.outputs.database-url }}
|
||||
db-preview-delete:
|
||||
if: ${{ github.event.action == 'closed' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: snaplet/action@v1
|
||||
with:
|
||||
delete: true
|
||||
- uses: snaplet/vercel-action@v1
|
||||
with:
|
||||
delete: true
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Staging DB Snapshot
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1-5" # At 04:00 on every day-of-week from Monday through Friday.
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
db-staging-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Snaplet CLI
|
||||
run: curl -sL https://app.snaplet.dev/get-cli/ | bash
|
||||
- name: Restore Snapshot
|
||||
run: snaplet snapshot restore -y
|
||||
env:
|
||||
SNAPLET_DATABASE_URL: ${{ secrets.SNAPLET_DATA_TARGET_DB_URL }}
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_DATABASE_ID: ${{ secrets.SNAPLET_DATABASE_ID }}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: E2E Embed tests and booking flow(for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- uses: ./.github/actions/env-read-file
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn test-e2e:embed-react
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results
|
||||
@@ -25,19 +25,12 @@ jobs:
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn turbo run embed-tests-update-snapshots:ci --scope=@calcom/embed-react --concurrency=1
|
||||
run: yarn test-e2e:embed
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload embed-core results
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-embed-core
|
||||
path: packages/embeds/embed-core/playwright/results
|
||||
|
||||
- name: Upload embed-react results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-embed-react
|
||||
path: packages/embeds/embed-react/playwright/results
|
||||
name: test-results
|
||||
path: test-results
|
||||
|
||||
@@ -20,7 +20,7 @@ env:
|
||||
INPUT_ENV_PAYMENT_FEE_FIXED: 10
|
||||
INPUT_ENV_SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
INPUT_ENV_SAML_ADMINS: pro@example.com
|
||||
INPUT_ENV_NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
INPUT_ENV_NEXTAUTH_URL: http://127.0.0.1:3000/api/auth
|
||||
INPUT_ENV_NEXT_PUBLIC_IS_E2E: 1
|
||||
# INPUT_ENV_EMAIL_FROM: e2e@cal.com
|
||||
# INPUT_ENV_EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ozaytsev86/create-env-file@v1
|
||||
with:
|
||||
file-name: ${{ github.workspace }}/gh.env
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: Analyze bundle
|
||||
run: |
|
||||
cd apps/web
|
||||
npx -p nextjs-bundle-analysis report
|
||||
npx -p nextjs-bundle-analysis@0.5.0 report
|
||||
|
||||
- name: Upload bundle
|
||||
uses: actions/upload-artifact@v2
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body-includes: "<!-- __NEXTJS_BUNDLE -->"
|
||||
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
||||
|
||||
- name: Create Comment
|
||||
uses: peter-evans/create-or-update-comment@v1.4.4
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -21,6 +22,7 @@ jobs:
|
||||
outputs:
|
||||
app-store: ${{ steps.filter.outputs.app-store }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
embed-react: ${{ steps.filter.outputs.embed-react }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
@@ -34,6 +36,9 @@ jobs:
|
||||
embed:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
embed-react:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
|
||||
env:
|
||||
name: Create env file
|
||||
@@ -81,6 +86,13 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
if: ${{ needs.changes.outputs.embed-react == 'true' }}
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
needs: build
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
const https = require("https");
|
||||
|
||||
async function applyLabelFromLinkedIssueToPR(pr, token) {
|
||||
// Get the labels from issues linked to the PR
|
||||
const query = `
|
||||
query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $prNumber) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 10) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const graphqlData = JSON.stringify({
|
||||
query,
|
||||
variables: {
|
||||
owner: pr.base.repo.owner.login,
|
||||
repo: pr.base.repo.name,
|
||||
prNumber: pr.number,
|
||||
},
|
||||
});
|
||||
|
||||
const requestOptions = {
|
||||
hostname: "api.github.com",
|
||||
path: "/graphql",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": graphqlData.length,
|
||||
Authorization: "Bearer " + token,
|
||||
"User-Agent": "Node.js",
|
||||
},
|
||||
};
|
||||
|
||||
// Use the native Node.js request library to make the request
|
||||
let linkedIssues;
|
||||
linkedIssues = await new Promise((resolve, reject) => {
|
||||
const request = https.request(requestOptions, (response) => {
|
||||
let responseBody = "";
|
||||
response.on("data", (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
resolve(JSON.parse(responseBody)?.data?.repository?.pullRequest?.closingIssuesReferences?.nodes);
|
||||
});
|
||||
});
|
||||
|
||||
request.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
request.write(graphqlData);
|
||||
request.end();
|
||||
});
|
||||
|
||||
if (!linkedIssues || linkedIssues.length === 0) {
|
||||
console.log("No issue linked.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate over linked issues and apply labels to PR
|
||||
for (const issue of linkedIssues) {
|
||||
const labels = issue?.labels?.nodes?.map((label) => label.name);
|
||||
|
||||
if (!labels || labels.length === 0) {
|
||||
console.log(`No labels found on the linked issue #${issue.number}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelsData = JSON.stringify({
|
||||
labels: labels,
|
||||
});
|
||||
|
||||
const requestOptions = {
|
||||
hostname: "api.github.com",
|
||||
path: `/repos/${pr.base.repo.owner.login}/${pr.base.repo.name}/issues/${pr.number}/labels`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": labelsData.length,
|
||||
Authorization: "Bearer " + token,
|
||||
"User-Agent": "Node.js",
|
||||
},
|
||||
};
|
||||
|
||||
let labelResult;
|
||||
labelResult = await new Promise((resolve, reject) => {
|
||||
const request = https.request(requestOptions, (response) => {
|
||||
let responseBody = "";
|
||||
response.on("data", (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
response.on("data", () => {});
|
||||
response.on("end", () => {
|
||||
resolve(JSON.parse(responseBody));
|
||||
});
|
||||
});
|
||||
|
||||
request.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
request.write(labelsData);
|
||||
request.end();
|
||||
});
|
||||
|
||||
if (labelResult.message) {
|
||||
console.log(`Error labelling PR: ${labelResult.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Applied labels: ${labels.join(", ")} to PR #${pr.number} from linked issue #${issue.number}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass the PR data and GitHub token to the script
|
||||
(async () => {
|
||||
if (!process.env.PR_DATA) {
|
||||
console.log("No PR data found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const prData = JSON.parse(process.env.PR_DATA);
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
|
||||
await applyLabelFromLinkedIssueToPR(prData, token);
|
||||
})();
|
||||
@@ -1,28 +0,0 @@
|
||||
name: TODO to Issue
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
MANUAL_COMMIT_REF:
|
||||
description: "The SHA of the commit to get the diff for"
|
||||
required: true
|
||||
MANUAL_BASE_REF:
|
||||
description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here"
|
||||
required: false
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
todos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: "Add TODOs to issues"
|
||||
uses: "alstr/todo-to-issue-action@v4"
|
||||
env:
|
||||
MANUAL_COMMIT_REF: ${{ inputs.MANUAL_COMMIT_REF }}
|
||||
MANUAL_BASE_REF: ${{ inputs.MANUAL_BASE_REF }}
|
||||
with:
|
||||
IDENTIFIERS: '[{"name": "TODO", "labels": ["🙋🏻♂️help wanted"]}, {"name": "FIXME", "labels": ["🐛 bug"]}]'
|
||||
+11
-4
@@ -65,8 +65,9 @@ tsconfig.tsbuildinfo
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# Prisma-Zod
|
||||
# Prisma generated files
|
||||
packages/prisma/zod/*.ts
|
||||
packages/prisma/enums
|
||||
|
||||
# Builds
|
||||
dist
|
||||
@@ -77,9 +78,6 @@ lint-results
|
||||
#Storybook
|
||||
apps/storybook/build-storybook.log
|
||||
|
||||
# Infisical
|
||||
.infisical.json
|
||||
|
||||
# Snaplet
|
||||
.snaplet/snapshots
|
||||
.snaplet/structure.d.ts
|
||||
@@ -90,3 +88,12 @@ apps/api
|
||||
apps/website
|
||||
apps/console
|
||||
apps/auth
|
||||
|
||||
# Yarn Modern
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
tasks:
|
||||
- init: |
|
||||
yarn &&
|
||||
cp .env.example .env &&
|
||||
next_auth_secret=$(openssl rand -base64 32) &&
|
||||
calendso_encryption_key=$(openssl rand -base64 24) &&
|
||||
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
||||
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
|
||||
command: yarn dx
|
||||
|
||||
ports:
|
||||
- port: 3000
|
||||
visibility: public
|
||||
onOpen: open-preview
|
||||
- port: 5420
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
- port: 1025
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
- port: 8025
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
|
||||
github:
|
||||
prebuilds:
|
||||
master: true
|
||||
pullRequests: true
|
||||
pullRequestsFromForks: true
|
||||
addCheck: true
|
||||
addComment: true
|
||||
addBadge: true
|
||||
|
||||
vscode:
|
||||
extensions:
|
||||
- DavidAnson.vscode-markdownlint
|
||||
- yzhang.markdown-all-in-one
|
||||
- esbenp.prettier-vscode
|
||||
- dbaeumer.vscode-eslint
|
||||
- bradlc.vscode-tailwindcss
|
||||
- ban.spellright
|
||||
- stripe.vscode-stripe
|
||||
- Prisma.prisma
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"workspaceId": "63ea8121b6e2b0543ba79616",
|
||||
"defaultEnvironment": "dev",
|
||||
"gitBranchToEnvironmentMapping": {
|
||||
"main": "staging",
|
||||
"production": "prod"
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,5 @@ public
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
packages/prisma/zod
|
||||
packages/prisma/enums
|
||||
apps/web/public/embed
|
||||
|
||||
Vendored
+1
-1
@@ -6,6 +6,6 @@
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"spellright.language": ["en"],
|
||||
"spellright.documentTypes": ["markdown", "typescript"],
|
||||
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
|
||||
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
|
||||
}
|
||||
|
||||
+541
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+873
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.4.1.cjs
|
||||
+7
-3
@@ -4,7 +4,7 @@ Contributions are what make the open source community such an amazing place to l
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
## Priorities
|
||||
## Priorities
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -57,7 +57,6 @@ Contributions are what make the open source community such an amazing place to l
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
@@ -87,7 +86,12 @@ To develop locally:
|
||||
yarn
|
||||
```
|
||||
|
||||
5. Start developing and watch for code changes:
|
||||
5. Set up your `.env` file:
|
||||
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
6. Start developing and watch for code changes:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
|
||||
@@ -2,7 +2,7 @@ Copyright (c) 2020-present Cal.com, Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee directory of this repository (Enterprise Edition) is licensed under the license defined in "ee/LICENSE".
|
||||
* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
|
||||
* All third party components incorporated into the Cal.com Software are licensed under the original license provided by the owner of the applicable component.
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cal.com/slack"><img src="https://img.shields.io/badge/Slack-calendso.slack.com-%234A154B" alt="Join Cal.com Slack"></a>
|
||||
<a href="https://www.producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=34507672"><img src="https://img.shields.io/badge/Hacker%20News-%231-%23FF6600" alt="Hacker News"></a>
|
||||
@@ -38,13 +38,13 @@
|
||||
<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://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://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://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
|
||||
</p>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
|
||||
## About The Project
|
||||
## About the Project
|
||||
|
||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/176390354-f1bc7069-0341-437a-9bb8-eb41092b4016.gif">
|
||||
|
||||
@@ -53,13 +53,13 @@
|
||||
The open source Calendly alternative. You are in charge
|
||||
of your own data, workflow and appearance.
|
||||
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customisations.
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customizations.
|
||||
|
||||
That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data.
|
||||
|
||||
## Recognition
|
||||
|
||||
#### Hacker News
|
||||
#### [Hacker News](https://news.ycombinator.com/from?site=cal.com)
|
||||
|
||||
<a href="https://news.ycombinator.com/item?id=34507672">
|
||||
<img
|
||||
@@ -77,21 +77,22 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
||||
/>
|
||||
</a>
|
||||
|
||||
#### [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso)
|
||||
#### [Product Hunt](https://producthunt.com/products/cal-com?utm_source=badge-top-post-badge&utm_medium=badge)
|
||||
|
||||
<a href="https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
### Built With
|
||||
|
||||
- [Next.js](https://nextjs.org/?ref=cal.com)
|
||||
- [tRPC](https://trpc.io/?ref=cal.com)
|
||||
- [React](https://reactjs.org/?ref=cal.com)
|
||||
- [Tailwind](https://tailwindcss.com/?ref=cal.com)
|
||||
- [Prisma](https://prisma.io/?ref=cal.com)
|
||||
- [React.js](https://reactjs.org/?ref=cal.com)
|
||||
- [Tailwind CSS](https://tailwindcss.com/?ref=cal.com)
|
||||
- [Prisma.io](https://prisma.io/?ref=cal.com)
|
||||
- [Daily.co](https://go.cal.com/daily)
|
||||
|
||||
## Stay Up-to-Date
|
||||
|
||||
Cal officially launched as v.1.0 on 15th of September, however a lot of new features are coming. Watch **releases** of this repository to be notified for future updates:
|
||||
Cal.com officially launched as v.1.0 on 15th of September, however a lot of new features are coming. Watch **releases** of this repository to be notified for future updates:
|
||||
|
||||

|
||||
|
||||
@@ -103,7 +104,7 @@ To get a local copy up and running, please follow these simple steps.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Here is what you need to be able to run Cal.
|
||||
Here is what you need to be able to run Cal.com.
|
||||
|
||||
- Node.js (Version: >=15.x <17)
|
||||
- PostgreSQL
|
||||
@@ -120,6 +121,9 @@ Here is what you need to be able to run Cal.
|
||||
```sh
|
||||
git clone https://github.com/calcom/cal.com.git
|
||||
```
|
||||
> If you are on windows, run the following command on `gitbash` with admin privileges: <br>
|
||||
```git clone -c core.symlinks=true https://github.com/calcom/cal.com.git``` <br>
|
||||
See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
||||
|
||||
1. Go to the project folder
|
||||
|
||||
@@ -133,10 +137,10 @@ Here is what you need to be able to run Cal.
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Set up your .env file
|
||||
1. Set up your `.env` file
|
||||
- Duplicate `.env.example` to `.env`
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the .env file.
|
||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the .env file.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
#### Quick start with `yarn dx`
|
||||
|
||||
@@ -149,53 +153,63 @@ yarn dx
|
||||
|
||||
#### Development tip
|
||||
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **tRPC**.
|
||||
|
||||
```sh
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
```
|
||||
#### Gitpod Setup
|
||||
|
||||
1. Click the button below to open this project in Gitpod.
|
||||
|
||||
2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed.
|
||||
|
||||
[](https://gitpod.io/#https://github.com/calcom/cal.com)
|
||||
|
||||
|
||||
|
||||
#### Manual setup
|
||||
|
||||
1. Configure environment variables in the `.env` file. Replace `<user>`, `<pass>`, `<db-host>`, `<db-port>` with their applicable values
|
||||
1. Configure environment variables in the `.env` file. Replace `<user>`, `<pass>`, `<db-host>`, and `<db-port>` with their applicable values
|
||||
|
||||
```
|
||||
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick DB using Heroku</summary>
|
||||
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick local DB</summary>
|
||||
|
||||
1. Create a free account with [Heroku](https://www.heroku.com/).
|
||||
1. [Download](https://www.postgresql.org/download/) and install postgres in your local (if you don't have it already).
|
||||
|
||||
2. Create a new app.
|
||||
<img width="306" alt="Create an App" src="https://user-images.githubusercontent.com/16905768/115322780-b3d58c00-a17e-11eb-8a52-b758fb0ea942.png">
|
||||
2. Create your own local db by executing `createDB <DB name>`
|
||||
|
||||
3. In your new app, go to `Overview` and next to `Installed add-ons`, click `Configure Add-ons`. We need this to set up our database.
|
||||

|
||||
3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d <DB name>`
|
||||
|
||||
4. Once you clicked on `Configure Add-ons`, click on `Find more add-ons` and search for `postgres`. One of the options will be `Heroku Postgres` - click on that option.
|
||||

|
||||
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
|
||||

|
||||
|
||||
5. Once the pop-up appears, click `Submit Order Form` - plan name should be `Hobby Dev - Free`.
|
||||
<img width="512" alt="Submit Order Form" src="https://user-images.githubusercontent.com/16905768/115323265-b4baed80-a17f-11eb-99f0-d67f019aa6df.png">
|
||||
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
|
||||
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`.
|
||||
|
||||
6. Once you completed the above steps, click on your newly created `Heroku Postgres` and go to its `Settings`.
|
||||

|
||||
|
||||
7. In `Settings`, copy your URI to your Cal.com .env file and replace the `postgresql://<user>:<pass>@<db-host>:<db-port>` with it.
|
||||

|
||||

|
||||
|
||||
8. To view your DB, once you add new data in Prisma, you can use [Heroku Data Explorer](https://heroku-data-explorer.herokuapp.com/).
|
||||
</details>
|
||||
|
||||
1. Set a 32 character random string in your .env file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
||||
If you don't want to create a local DB. Then you can also consider using services like railway.app or render.
|
||||
- [Setup postgres DB with railway.app](https://arctype.com/postgres/setup/railway-postgres)
|
||||
- [Setup postgres DB with render](https://render.com/docs/databases)
|
||||
|
||||
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
|
||||
|
||||
1. Set a 32 character random string in your `.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
||||
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
|
||||
|
||||
```sh
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
||||
|
||||
```sh
|
||||
docker pull mailhog/mailhog
|
||||
docker run -d -p 8025:8025 -p 1025:1025 mailhog/mailhog
|
||||
```
|
||||
|
||||
1. Run (in development mode)
|
||||
|
||||
@@ -205,7 +219,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
#### Setting up your first user
|
||||
|
||||
1. Open [Prisma Studio](https://www.prisma.io/studio) to look at or modify the database content:
|
||||
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
||||
|
||||
```sh
|
||||
yarn db-studio
|
||||
@@ -250,7 +264,7 @@ yarn playwright show-report test-results/reports/playwright-html-report
|
||||
yarn workspace @calcom/prisma db-migrate
|
||||
```
|
||||
|
||||
(this can clear your development database in some cases)
|
||||
(This can clear your development database in some cases)
|
||||
|
||||
In a production environment, run:
|
||||
|
||||
@@ -284,7 +298,7 @@ yarn playwright show-report test-results/reports/playwright-html-report
|
||||
|
||||
### Docker
|
||||
|
||||
The Docker configuration for Cal is an effort powered by people within the community.
|
||||
The Docker configuration for Cal.com is an effort powered by people within the community.
|
||||
|
||||
If you want to contribute to the Docker repository, [reply here](https://github.com/calcom/docker/discussions/32).
|
||||
|
||||
@@ -297,14 +311,14 @@ Cal.com, Inc. does not provide official support for Docker, but we will accept f
|
||||
### Heroku
|
||||
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/calcom/cal.com">
|
||||
<img width="185px" height="auto" src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy">
|
||||
<img width="185px" height="auto" src="https://herokucdn.com/deploy/button.svg" alt="Deploy">
|
||||
</a>
|
||||
|
||||
### Railway
|
||||
|
||||
[](https://railway.app/new/template/cal)
|
||||
|
||||
You can deploy Cal on [Railway](https://railway.app/) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal on their platform.
|
||||
You can deploy Cal.com on [Railway](https://railway.app) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal.com on their platform.
|
||||
|
||||
### Vercel
|
||||
|
||||
@@ -349,18 +363,16 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=dark">
|
||||
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</a>
|
||||
|
||||
<!-- CONTRIBUTORS -->
|
||||
|
||||
### Contributors
|
||||
|
||||
|
||||
<a href="https://github.com/calcom/cal.com/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=calcom/cal.com" />
|
||||
</a>
|
||||
|
||||
|
||||
<!-- TRANSLATIONS -->
|
||||
|
||||
### Translations
|
||||
@@ -387,7 +399,7 @@ Don't code but still want to contribute? Join our [slack](https://cal.com/slack)
|
||||
8. Select Web Application as the Application Type.
|
||||
9. Under Authorized redirect URI's, select Add URI and then add the URI `<Cal.com URL>/api/integrations/googlecalendar/callback` and `<Cal.com URL>/api/auth/callback/google` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. The key will be created and you will be redirected back to the Credentials page. Select the newly generated client ID under OAuth 2.0 Client IDs.
|
||||
11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the .env file as the value for GOOGLE_API_CREDENTIALS key.
|
||||
11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the `.env` file as the value for `GOOGLE_API_CREDENTIALS` key.
|
||||
|
||||
#### _Adding google calendar to Cal.com App Store_
|
||||
|
||||
@@ -424,7 +436,7 @@ following
|
||||
5. Choose "User-managed app" as the app type.
|
||||
6. De-select the option to publish the app on the Zoom App Marketplace.
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
@@ -433,11 +445,11 @@ following
|
||||
|
||||
### Obtaining Daily API Credentials
|
||||
|
||||
1. Open [Daily](https://www.daily.co/) and sign into your account.
|
||||
1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
3. Copy your API key.
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
5. If you have the [Daily Scale Plan](https://www.daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file.
|
||||
5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
@@ -446,31 +458,50 @@ following
|
||||
3. Click "Create app" button top right.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
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/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`
|
||||
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.
|
||||
|
||||
### Obtaining Webex Client ID and Secret
|
||||
|
||||
[See Webex Readme](./packages/app-store/webex/)
|
||||
|
||||
### Obtaining ZohoCRM Client ID and Secret
|
||||
|
||||
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
|
||||
2. From within the API console page, go to "Applications".
|
||||
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
|
||||
4. Fill in any information you want in the "Client Details" tab
|
||||
5. Go to tab "Client Secret" tab.
|
||||
6. Now copy the Client ID and Client Secret to your `.env` file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
|
||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
||||
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
||||
|
||||
### Obtaining Zoho Bigin Client ID and Secret
|
||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||
## Workflows
|
||||
|
||||
### Setting up SendGrid for Email reminders
|
||||
|
||||
1. Create a SendGrid account (https://signup.sendgrid.com/)
|
||||
2. Go to Settings -> API keys and create an API key
|
||||
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
|
||||
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)
|
||||
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
|
||||
|
||||
1. Create a Twilio account (https://www.twilio.com/try-twilio)
|
||||
1. Create a Twilio account (https://twilio.com/try-twilio)
|
||||
2. Click ‘Get a Twilio phone number’
|
||||
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
|
||||
5. Copy your Twilio phone number to your .env file into the TWILIO_PHONE_NUMBER field
|
||||
6. Add your own sender id to the .env file into the NEXT_PUBLIC_SENDER_ID field (fallback is Cal)
|
||||
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
|
||||
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
||||
6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||
8. Choose any name for the messaging service
|
||||
9. Click 'Add Senders'
|
||||
@@ -478,15 +509,15 @@ following
|
||||
11. Add the listed phone number
|
||||
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
|
||||
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
|
||||
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the AGPLv3 License. See `LICENSE` for more information.
|
||||
Distributed under the [AGPLv3 License](https://github.com/calcom/cal.com/blob/main/LICENSE). See `LICENSE` for more information.
|
||||
|
||||
<!-- ACKNOWLEDGEMENTS -->
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
14.17
|
||||
v18
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@calcom/docs",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +12,8 @@
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
|
||||
"start": "PORT=4000 next start",
|
||||
"type-check": "tsc --pretty --noEmit"
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"type-check:ci": "tsc-absolute --pretty --noEmit"
|
||||
},
|
||||
"author": "Cal.com, Inc.",
|
||||
"license": "MIT",
|
||||
@@ -24,7 +26,6 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/config": "*",
|
||||
"eslint": "^8.34.0"
|
||||
"@calcom/config": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ module.exports = {
|
||||
stories: [
|
||||
"../intro.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.mdx",
|
||||
"../../../packages/atoms/**/*.stories.mdx",
|
||||
"../../../packages/features/**/*.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
||||
],
|
||||
@@ -70,4 +71,5 @@ module.exports = {
|
||||
|
||||
return config;
|
||||
},
|
||||
typescript: { reactDocgen: "react-docgen" },
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { ReactElement, ReactNode } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
@@ -42,7 +43,7 @@ export function VariantsTable({
|
||||
</table>
|
||||
</div>
|
||||
{!isDark && (
|
||||
<div data-mode="dark">
|
||||
<div data-mode="dark" className="dark">
|
||||
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
|
||||
{children}
|
||||
</VariantsTable>
|
||||
|
||||
@@ -193,3 +193,88 @@
|
||||
.docs-story {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
@layer {
|
||||
:root {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #E5E7EB;
|
||||
--cal-bg: white;
|
||||
--cal-bg-subtle: #F3F4F6;
|
||||
--cal-bg-muted: #F9FAFB;
|
||||
--cal-bg-inverted: #111827;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #DEE9FC;
|
||||
--cal-bg-success: #E2FBE8;
|
||||
--cal-bg-attention: #FCEED8;
|
||||
--cal-bg-error: #F9E3E2;
|
||||
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis:#9CA3AF;
|
||||
--cal-border: #D1D5DB;
|
||||
--cal-border-subtle:#E5E7EB;
|
||||
--cal-border-muted:#F3F4F6;
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #111827;
|
||||
--cal-text:#374151;
|
||||
--cal-text-subtle:#6B7280;
|
||||
--cal-text-muted:#9CA3AF;
|
||||
--cal-text-inverted:white;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info:#253985;
|
||||
--cal-text-success:#285231;
|
||||
--cal-text-attention:#73321B;
|
||||
--cal-text-error:#752522;
|
||||
|
||||
/* Brand shinanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand:#111827;
|
||||
--cal-brand-emphasis:#101010;
|
||||
}
|
||||
.dark {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #2B2B2B;
|
||||
--cal-bg: #101010;
|
||||
--cal-bg-subtle: #2B2B2B;
|
||||
--cal-bg-muted: #1C1C1C;
|
||||
--cal-bg-inverted: #F3F4F6;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #DEE9FC;
|
||||
--cal-bg-success: #E2FBE8;
|
||||
--cal-bg-attention: #FCEED8;
|
||||
--cal-bg-error: #F9E3E2;
|
||||
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis: #575757;
|
||||
--cal-border: #444444;
|
||||
--cal-border-subtle: #2B2B2B;
|
||||
--cal-border-muted: #1C1C1C;
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #F3F4F6;
|
||||
--cal-text: #D6D6D6;
|
||||
--cal-text-subtle: #757575;
|
||||
--cal-text-muted: #575757;
|
||||
--cal-text-inverted: #101010;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321B;
|
||||
--cal-text-error: #752522;
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: #111827;
|
||||
--cal-brand-emphasis: #101010;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
node_modules
|
||||
prisma/zod
|
||||
public/embed
|
||||
|
||||
@@ -68,5 +68,3 @@ public/embed
|
||||
|
||||
# Copied app-store images
|
||||
public/app-store
|
||||
# Sentry
|
||||
.sentryclirc
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v16
|
||||
v18
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public/embed
|
||||
@@ -0,0 +1,10 @@
|
||||
# @calcom/web
|
||||
|
||||
## 2.7.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @calcom/embed-snippet@1.0.7
|
||||
- @calcom/embed-react@1.0.12
|
||||
- @calcom/embed-core@1.1.5
|
||||
@@ -103,59 +103,6 @@ paths:
|
||||
summary: Reset a user's password
|
||||
tags:
|
||||
- Authentication
|
||||
"/api/availability/{user}?dateFrom={dateFrom}&dateTo={dateTo}":
|
||||
get:
|
||||
description: "Gets the busy times for a particular user, by username."
|
||||
summary: Gets the busy times for a user
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
description: ""
|
||||
minItems: 1
|
||||
uniqueItems: true
|
||||
x-examples:
|
||||
example-1:
|
||||
- start: "Fri, 03 Sep 2021 17:00:00 GMT"
|
||||
end: "Fri, 03 Sep 2021 17:40:00 GMT"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
start:
|
||||
type: string
|
||||
minLength: 1
|
||||
end:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
name: user
|
||||
in: path
|
||||
required: true
|
||||
description: The username of who you want to check availability for
|
||||
- schema:
|
||||
type: string
|
||||
name: dateFrom
|
||||
in: path
|
||||
required: true
|
||||
description: The timestamp of which time you want to get busy times from
|
||||
- schema:
|
||||
type: string
|
||||
name: dateTo
|
||||
in: path
|
||||
required: true
|
||||
description: The timestamp of which time you want to get busy times until
|
||||
/api/availability/calendar:
|
||||
get:
|
||||
description: Gets the user's selected calendars.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { FiX } from "@calcom/ui/components/icon";
|
||||
import { X } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function AddToHomescreen() {
|
||||
const { t } = useLocale();
|
||||
@@ -29,7 +29,7 @@ export default function AddToHomescreen() {
|
||||
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-xs font-medium text-white ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-inverted ms-3 text-xs font-medium">
|
||||
<span className="inline">{t("add_to_homescreen")}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ export default function AddToHomescreen() {
|
||||
type="button"
|
||||
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||
<span className="sr-only">{t("dismiss")}</span>
|
||||
<FiX className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
<X className="text-inverted h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from "react";
|
||||
import Select from "react-select";
|
||||
import type { OptionProps } from "react-select";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
interface AdditionalCalendarSelectorProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App["type"] }>) => {
|
||||
const { data } = optionProps;
|
||||
return (
|
||||
<InstallAppButton
|
||||
type={data.type}
|
||||
render={(installProps) => {
|
||||
return (
|
||||
<Button {...installProps} className="w-full" color="minimal">
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
{data.image && (
|
||||
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
|
||||
)}
|
||||
<p>{data.label}</p>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
const options = data.items.map((item) => ({
|
||||
label: item.name,
|
||||
slug: item.slug,
|
||||
image: item.logo,
|
||||
type: item.type,
|
||||
}));
|
||||
return (
|
||||
<Select
|
||||
name="additionalCalendar"
|
||||
placeholder={t("connect_additional_calendar")}
|
||||
options={options}
|
||||
styles={{
|
||||
placeholder: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
color: "#3E3E3E",
|
||||
marginLeft: "3px",
|
||||
};
|
||||
},
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "2px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
maxWidth: "320px",
|
||||
},
|
||||
};
|
||||
},
|
||||
}}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 text-sm font-medium text-gray-700"
|
||||
isLoading={isLoading}
|
||||
components={{ Option: ImageOption }}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdditionalCalendarSelector;
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { Badge, ListItemText } from "@calcom/ui";
|
||||
import { FiAlertCircle } from "@calcom/ui/components/icon";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
|
||||
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
||||
|
||||
@@ -63,12 +63,12 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${highlight ? "bg-yellow-100" : ""}`}>
|
||||
<div className="flex gap-x-3 p-4">
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex gap-x-3 px-5 py-4">
|
||||
{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>
|
||||
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isDefault && <Badge variant="green">{t("default")}</Badge>}
|
||||
{isTemplate && <Badge variant="red">Template</Badge>}
|
||||
@@ -77,7 +77,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
<ListItemText component="p">{description}</ListItemText>
|
||||
{invalidCredential && (
|
||||
<div className="flex gap-x-2 pt-2">
|
||||
<FiAlertCircle className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
|
||||
<AlertCircle className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
|
||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||
{t("invalid_credential")}
|
||||
</ListItemText>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { DestinationCalendar } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
interface Props {
|
||||
onChange: (value: { externalId: string; integration: string }) => void;
|
||||
isLoading?: boolean;
|
||||
hidePlaceholder?: boolean;
|
||||
/** The external Id of the connected calendar */
|
||||
value: string | undefined;
|
||||
destinationCalendar?: DestinationCalendar | null;
|
||||
}
|
||||
|
||||
const DestinationCalendarSelector = ({
|
||||
onChange,
|
||||
isLoading,
|
||||
value,
|
||||
hidePlaceholder,
|
||||
destinationCalendar,
|
||||
}: Props): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// Extra styles to show prefixed text in react-select
|
||||
const content = (hidePlaceholder = false) => {
|
||||
if (!hidePlaceholder) {
|
||||
return {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
":before": {
|
||||
content: `'${t("select_destination_calendar")}:'`,
|
||||
display: "block",
|
||||
marginRight: 8,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const selected = query.data?.connectedCalendars
|
||||
.map((connected) => connected.calendars ?? [])
|
||||
.flat()
|
||||
.find((cal) => cal.externalId === value);
|
||||
|
||||
if (selected) {
|
||||
setSelectedOption({
|
||||
value: `${selected.integration}:${selected.externalId}`,
|
||||
label: selected.name || "",
|
||||
});
|
||||
}
|
||||
}, [query.data?.connectedCalendars, value]);
|
||||
|
||||
if (!query.data?.connectedCalendars.length) {
|
||||
return null;
|
||||
}
|
||||
const options =
|
||||
query.data.connectedCalendars.map((selectedCalendar) => ({
|
||||
key: selectedCalendar.credentialId,
|
||||
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
|
||||
options: (selectedCalendar.calendars ?? [])
|
||||
.filter((cal) => cal.readOnly === false)
|
||||
.map((cal) => ({
|
||||
label: cal.name || "",
|
||||
value: `${cal.integration}:${cal.externalId}`,
|
||||
})),
|
||||
})) ?? [];
|
||||
const defaultCalendarSelectedString = destinationCalendar?.externalId
|
||||
? `(${
|
||||
destinationCalendar.externalId.length > 15
|
||||
? destinationCalendar.externalId.substring(0, 15) + "..."
|
||||
: destinationCalendar.externalId
|
||||
})`
|
||||
: "";
|
||||
return (
|
||||
<div className="relative" title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||
<Select
|
||||
name="primarySelectedCalendar"
|
||||
placeholder={
|
||||
!hidePlaceholder ? (
|
||||
`${t("select_destination_calendar")}`
|
||||
) : (
|
||||
<span className="whitespace-nowrap">
|
||||
{t("default_calendar_selected")} {defaultCalendarSelectedString}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
options={options}
|
||||
styles={{
|
||||
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
option: (defaultStyles, state) => ({
|
||||
...defaultStyles,
|
||||
backgroundColor: state.isSelected
|
||||
? state.isFocused
|
||||
? "var(--brand-color)"
|
||||
: "var(--brand-color)"
|
||||
: state.isFocused
|
||||
? "var(--brand-color-dark-mode)"
|
||||
: "var(--brand-text-color)",
|
||||
}),
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "6px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
width: "100%",
|
||||
},
|
||||
};
|
||||
},
|
||||
}}
|
||||
isSearchable={false}
|
||||
className={classNames(
|
||||
"mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 text-sm",
|
||||
!hidePlaceholder && "font-medium"
|
||||
)}
|
||||
onChange={(option) => {
|
||||
setSelectedOption(option);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Split only the first `:`, since Apple uses the full URL as externalId */
|
||||
const [integration, externalId] = option.value.split(/:(.+)/);
|
||||
|
||||
onChange({
|
||||
integration,
|
||||
externalId,
|
||||
});
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
value={selectedOption}
|
||||
components={{
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DestinationCalendarSelector;
|
||||
+332
-332
@@ -1,5 +1,6 @@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
@@ -21,10 +22,9 @@ import {
|
||||
TextArea,
|
||||
TextField,
|
||||
ColorPicker,
|
||||
Select,
|
||||
} from "@calcom/ui";
|
||||
import { FiCode, FiTrello, FiSun, FiArrowLeft, FiChevronRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
import { Code, Trello, Sun, ArrowLeft } from "@calcom/ui/components/icon";
|
||||
|
||||
type EmbedType = "inline" | "floating-popup" | "element-click";
|
||||
type EmbedFramework = "react" | "HTML";
|
||||
@@ -167,7 +167,7 @@ const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
|
||||
const height = getDimension(previewState.inline.height);
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
|
||||
import { useEffect } from "react";
|
||||
function MyComponent() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
@@ -187,12 +187,12 @@ function MyComponent() {
|
||||
}) => {
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
|
||||
import { useEffect } from "react";
|
||||
function MyComponent() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
Cal("floatingButton", ${floatingButtonArg});
|
||||
cal("floatingButton", ${floatingButtonArg});
|
||||
${uiInstructionCode}
|
||||
})();
|
||||
}, [])
|
||||
@@ -201,7 +201,7 @@ function MyComponent() {
|
||||
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
|
||||
import { useEffect } from "react";
|
||||
function MyComponent() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
@@ -305,192 +305,194 @@ const getEmbedTypeSpecificString = ({
|
||||
return "";
|
||||
};
|
||||
|
||||
const embeds: {
|
||||
illustration: React.ReactElement;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: EmbedType;
|
||||
}[] = [
|
||||
{
|
||||
title: "Inline Embed",
|
||||
subtitle: "Loads your Cal scheduling page directly inline with your other website content",
|
||||
type: "inline",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="125.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M61 124.5H67V122.5H61V124.5ZM68 125.5V131.5H70V125.5H68ZM67 132.5H61V134.5H67V132.5ZM60 131.5V125.5H58V131.5H60ZM61 132.5C60.4477 132.5 60 132.052 60 131.5H58C58 133.157 59.3431 134.5 61 134.5V132.5ZM68 131.5C68 132.052 67.5523 132.5 67 132.5V134.5C68.6569 134.5 70 133.157 70 131.5H68ZM67 124.5C67.5523 124.5 68 124.948 68 125.5H70C70 123.843 68.6569 122.5 67 122.5V124.5ZM61 122.5C59.3431 122.5 58 123.843 58 125.5H60C60 124.948 60.4477 124.5 61 124.5V122.5Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="73" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Floating pop-up button",
|
||||
subtitle: "Adds a floating button on your site that launches Cal in a dialog.",
|
||||
type: "floating-popup",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Pop up via element click",
|
||||
subtitle: "Open your Cal dialog when someone clicks an element.",
|
||||
type: "element-click",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="108" y="85" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="108" y="97" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect x="109" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="110" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="133" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="136" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M121 135H127V133H121V135ZM128 136V142H130V136H128ZM127 143H121V145H127V143ZM120 142V136H118V142H120ZM121 143C120.448 143 120 142.552 120 142H118C118 143.657 119.343 145 121 145V143ZM128 142C128 142.552 127.552 143 127 143V145C128.657 145 130 143.657 130 142H128ZM127 135C127.552 135 128 135.448 128 136H130C130 134.343 128.657 133 127 133V135ZM121 133C119.343 133 118 134.343 118 136H120C120 135.448 120.448 135 121 135V133Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="133" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
const embeds = (t: TFunction) =>
|
||||
(() => {
|
||||
return [
|
||||
{
|
||||
title: t("inline_embed"),
|
||||
subtitle: t("load_inline_content"),
|
||||
type: "inline",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="6" fill="#F3F4F6" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="6" fill="#F3F4F6" />
|
||||
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="125.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M61 124.5H67V122.5H61V124.5ZM68 125.5V131.5H70V125.5H68ZM67 132.5H61V134.5H67V132.5ZM60 131.5V125.5H58V131.5H60ZM61 132.5C60.4477 132.5 60 132.052 60 131.5H58C58 133.157 59.3431 134.5 61 134.5V132.5ZM68 131.5C68 132.052 67.5523 132.5 67 132.5V134.5C68.6569 134.5 70 133.157 70 131.5H68ZM67 124.5C67.5523 124.5 68 124.948 68 125.5H70C70 123.843 68.6569 122.5 67 122.5V124.5ZM61 122.5C59.3431 122.5 58 123.843 58 125.5H60C60 124.948 60.4477 124.5 61 124.5V122.5Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="73" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="6" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("floating_pop_up_button"),
|
||||
subtitle: t("floating_button_trigger_modal"),
|
||||
type: "floating-popup",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="6" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("pop_up_element_click"),
|
||||
subtitle: t("open_dialog_with_element_click"),
|
||||
type: "element-click",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" y="0.50293" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" fill="#F9FAFB" />
|
||||
<g filter="url(#filter0_i_3223_14162)">
|
||||
<rect opacity="0.8" x="40" y="99" width="24" height="24" rx="2" fill="#E5E7EB" />
|
||||
<rect x="40" y="127" width="48" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="139" width="82" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="151" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="159" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
</g>
|
||||
<rect x="152" y="48" width="2" height="169" rx="2" fill="#E5E7EB" />
|
||||
|
||||
<rect opacity="0.8" x="176" y="84" width="80" height="8" rx="2" fill="#E5E7EB" />
|
||||
<rect x="176" y="96" width="48" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="109" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="201" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="135" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="187.3" y="133.4" width="9" height="9" rx="1.5" stroke="#0D121D" />
|
||||
<rect x="201" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="171" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" stroke="#101010" />
|
||||
<rect x="24" y="241.503" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
})();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: "HTML",
|
||||
href: "embedTabName=embed-code",
|
||||
icon: FiCode,
|
||||
icon: Code,
|
||||
type: "code",
|
||||
Component: forwardRef<
|
||||
HTMLTextAreaElement | HTMLIFrameElement | null,
|
||||
@@ -506,7 +508,7 @@ const tabs = [
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<small className="flex py-4 text-gray-500">
|
||||
<small className="text-subtle flex py-4">
|
||||
{t("place_where_cal_widget_appear", { appName: APP_NAME })}
|
||||
</small>
|
||||
</div>
|
||||
@@ -514,7 +516,7 @@ const tabs = [
|
||||
data-testid="embed-code"
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
||||
name="embed-code"
|
||||
className="h-[calc(100%-50px)] font-mono"
|
||||
className="text-default bg-default selection:bg-subtle h-[calc(100%-50px)] font-mono"
|
||||
style={{ resize: "none", overflow: "auto" }}
|
||||
readOnly
|
||||
value={
|
||||
@@ -531,11 +533,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
||||
<!-- Cal ${embedType} embed code ends -->`
|
||||
}
|
||||
/>
|
||||
<p className="hidden text-sm text-gray-500">
|
||||
{t(
|
||||
"Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options."
|
||||
)}
|
||||
</p>
|
||||
<p className="text-subtle hidden text-sm">{t("need_help_embedding")}</p>
|
||||
</>
|
||||
);
|
||||
}),
|
||||
@@ -543,7 +541,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
||||
{
|
||||
name: "React",
|
||||
href: "embedTabName=embed-react",
|
||||
icon: FiCode,
|
||||
icon: Code,
|
||||
type: "code",
|
||||
Component: forwardRef<
|
||||
HTMLTextAreaElement | HTMLIFrameElement | null,
|
||||
@@ -558,12 +556,12 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<small className="flex py-4 text-gray-500">{t("create_update_react_component")}</small>
|
||||
<small className="text-subtle flex py-4">{t("create_update_react_component")}</small>
|
||||
<TextArea
|
||||
data-testid="embed-react"
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
||||
name="embed-react"
|
||||
className="h-[calc(100%-50px)] font-mono"
|
||||
className="text-default bg-default selection:bg-subtle h-[calc(100%-50px)] font-mono"
|
||||
readOnly
|
||||
style={{ resize: "none", overflow: "auto" }}
|
||||
value={`/* First make sure that you have installed the package */
|
||||
@@ -583,7 +581,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, prev
|
||||
{
|
||||
name: "Preview",
|
||||
href: "embedTabName=embed-preview",
|
||||
icon: FiTrello,
|
||||
icon: Trello,
|
||||
type: "iframe",
|
||||
Component: forwardRef<
|
||||
HTMLIFrameElement | HTMLTextAreaElement | null,
|
||||
@@ -619,7 +617,7 @@ Cal("init", {origin:"${WEBAPP_URL}"});
|
||||
const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme; label: string }, false>) => {
|
||||
return (
|
||||
<components.Control {...props}>
|
||||
<FiSun className="ml-2 h-4 w-4 text-gray-500" />
|
||||
<Sun className="text-subtle mr-2 h-4 w-4" />
|
||||
{children}
|
||||
</components.Control>
|
||||
);
|
||||
@@ -629,19 +627,19 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DialogContent type="creation" size="lg">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
<DialogContent className="rounded-lg p-10" type="creation" size="lg">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-cal text-emphasis mb-2 text-2xl font-bold leading-none" id="modal-title">
|
||||
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site", { appName: APP_NAME })}</p>
|
||||
<p className="text-subtle text-sm">{t("choose_ways_put_cal_site", { appName: APP_NAME })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
{embeds.map((embed, index) => (
|
||||
<div className="items-start space-y-2 md:flex md:space-y-0">
|
||||
{embeds(t).map((embed, index) => (
|
||||
<button
|
||||
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"
|
||||
className="hover:bg-subtle bg-muted w-full rounded-md border border-transparent p-6 text-left hover:rounded-md ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0 lg:w-1/3"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
@@ -649,11 +647,11 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
embedType: embed.type,
|
||||
});
|
||||
}}>
|
||||
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
||||
<div className="bg-default order-none box-border flex-none rounded-md border border-solid dark:bg-transparent dark:invert">
|
||||
{embed.illustration}
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-gray-900">{embed.title}</div>
|
||||
<p className="text-sm text-gray-500">{embed.subtitle}</p>
|
||||
<div className="text-emphasis mt-4 font-semibold">{embed.title}</div>
|
||||
<p className="text-subtle mt-2 text-sm">{embed.subtitle}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -669,8 +667,11 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
embedUrl: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const dialogContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const s = (href: string) => {
|
||||
const searchParams = new URLSearchParams(router.asPath.split("?")[1] || "");
|
||||
const [a, b] = href.split("=");
|
||||
@@ -686,7 +687,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
});
|
||||
|
||||
const refOfEmbedCodesRefs = useRef(embedCodeRefs);
|
||||
const embed = embeds.find((embed) => embed.type === embedType);
|
||||
const embed = embeds(t).find((embed) => embed.type === embedType);
|
||||
|
||||
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true);
|
||||
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true);
|
||||
@@ -792,7 +793,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
}
|
||||
|
||||
const ThemeOptions = [
|
||||
{ value: Theme.auto, label: "Auto Theme" },
|
||||
{ value: Theme.auto, label: "Auto" },
|
||||
{ value: Theme.dark, label: "Dark Theme" },
|
||||
{ value: Theme.light, label: "Light Theme" },
|
||||
];
|
||||
@@ -800,76 +801,71 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
const FloatingPopupPositionOptions = [
|
||||
{
|
||||
value: "bottom-right",
|
||||
label: "Bottom Right",
|
||||
label: "Bottom right",
|
||||
},
|
||||
{
|
||||
value: "bottom-left",
|
||||
label: "Bottom Left",
|
||||
label: "Bottom left",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent size="xl" className="p-0.5" type="creation">
|
||||
<DialogContent
|
||||
ref={dialogContentRef}
|
||||
className="rounded-lg p-0.5 sm:max-w-[80rem]"
|
||||
enableOverflow
|
||||
type="creation">
|
||||
<div className="flex">
|
||||
<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">
|
||||
<div className="bg-muted flex w-1/3 flex-col p-8">
|
||||
<h3
|
||||
className="text-emphasis mb-2.5 flex items-center text-xl font-semibold leading-5"
|
||||
id="modal-title">
|
||||
<button
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
removeQueryParams(router, ["embedType", "embedTabName"]);
|
||||
}}>
|
||||
<FiArrowLeft className="mr-4 w-4" />
|
||||
<ArrowLeft className="mr-4 w-4" />
|
||||
</button>
|
||||
{embed.title}
|
||||
</h3>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<h4 className="text-subtle mb-6 text-sm font-normal">{embed.subtitle}</h4>
|
||||
<div className="flex flex-col">
|
||||
<div className={classNames("font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full items-center text-base font-medium text-gray-900">
|
||||
<div>
|
||||
{embedType === "inline"
|
||||
? "Inline Embed Customization"
|
||||
: embedType === "floating-popup"
|
||||
? "Floating Popup Customization"
|
||||
: "Element Click Customization"}
|
||||
</div>
|
||||
<FiChevronRight
|
||||
className={`${
|
||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-gray-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="text-sm">
|
||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||
<div className={classNames(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
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
const width = e.target.value || "100%";
|
||||
<div className="text-default mb-[9px] text-sm">Window sizing</div>
|
||||
<div className="justify-left mb-6 flex items-center !font-normal ">
|
||||
<div className="mr-[9px]">
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
className="focus:ring-offset-0"
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
const width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<>W</>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<>W</>}
|
||||
/>
|
||||
<span className="p-2">×</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
className="focus:ring-offset-0"
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
@@ -891,10 +887,10 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
"items-center justify-between",
|
||||
embedType === "floating-popup" ? "text-emphasis" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2 text-sm">Button Text</div>
|
||||
<div className="mb-2 text-sm">Button text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
@@ -909,14 +905,16 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
defaultValue={t("book_my_cal")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-start",
|
||||
embedType === "floating-popup" ? "space-x-2 rtl:space-x-reverse" : "hidden"
|
||||
embedType === "floating-popup"
|
||||
? "text-emphasis space-x-2 rtl:space-x-reverse"
|
||||
: "hidden"
|
||||
)}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
@@ -932,14 +930,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">Display Calendar Icon Button</div>
|
||||
<div className="text-default my-2 text-sm">Display calendar icon</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
embedType === "floating-popup" ? "text-emphasis" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2">Position of Button</div>
|
||||
<div className="mb-2">Position of button</div>
|
||||
<Select
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
@@ -956,83 +954,69 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
options={FloatingPopupPositionOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex flex-col xl:flex-row xl:justify-between">
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div className="whitespace-nowrap">Button color</div>
|
||||
<div className="mt-2 w-40 xl:mt-0 xl:w-full">
|
||||
<ColorPicker
|
||||
className="w-[130px]"
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: 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 className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div className="whitespace-nowrap">Text color</div>
|
||||
<div className="mt-2 mb-6 w-40 xl:mt-0 xl:w-full">
|
||||
<ColorPicker
|
||||
className="w-[130px]"
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
<div className="mt-4 font-medium">
|
||||
<div className="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>
|
||||
<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="text-sm">
|
||||
<Label className="mb-6">
|
||||
<div className="mb-2">Theme</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
@@ -1048,6 +1032,20 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
options={ThemeOptions}
|
||||
/>
|
||||
</Label>
|
||||
<div className="mb-6 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-default text-sm">{t("hide_eventtype_details")}</div>
|
||||
</div>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
// { name: "lightColor", title: "Light Color" },
|
||||
@@ -1056,10 +1054,12 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
// { 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>
|
||||
<Label key={palette.name} className="mb-6">
|
||||
<div className="mb-2">{palette.title}</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useViewerI18n() {
|
||||
@@ -22,16 +22,18 @@ export function useViewerI18n() {
|
||||
*/
|
||||
const I18nLanguageHandler = (): null => {
|
||||
const { i18n } = useTranslation("common");
|
||||
const locale = useViewerI18n().data?.locale || "en";
|
||||
const locale = useViewerI18n().data?.locale || i18n.language;
|
||||
|
||||
useEffect(() => {
|
||||
if (locale && i18n.language && i18n.language !== locale) {
|
||||
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
|
||||
// bail early when i18n = {}
|
||||
if (isEmpty(i18n)) return;
|
||||
// if locale is ready and the i18n.language does != locale - changeLanguage
|
||||
if (locale && i18n.language !== locale) {
|
||||
i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
const dir = getDirFromLang(locale);
|
||||
// set dir="rtl|ltr"
|
||||
document.dir = i18n.dir();
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
document.documentElement.setAttribute("dir", dir);
|
||||
}, [locale, i18n]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
||||
|
||||
import type { Area } from "@lib/cropImage";
|
||||
import { getCroppedImg } from "@lib/cropImage";
|
||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||
|
||||
import Slider from "@components/Slider";
|
||||
|
||||
type ImageUploaderProps = {
|
||||
id: string;
|
||||
buttonMsg: string;
|
||||
handleAvatarChange: (imageSrc: string) => void;
|
||||
imageSrc?: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
interface FileEvent<T = Element> extends FormEvent<T> {
|
||||
target: EventTarget & T;
|
||||
}
|
||||
|
||||
// This is separate to prevent loading the component until file upload
|
||||
function CropContainer({
|
||||
onCropComplete,
|
||||
imageSrc,
|
||||
}: {
|
||||
imageSrc: string;
|
||||
onCropComplete: (croppedAreaPixels: Area) => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
const handleZoomSliderChange = (value: number) => {
|
||||
value < 1 ? setZoom(1) : setZoom(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crop-container h-40 max-h-40 w-40 rounded-full">
|
||||
<div className="relative h-40 w-40 rounded-full">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={(croppedArea, croppedAreaPixels) => onCropComplete(croppedAreaPixels)}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
label={t("slide_zoom_drag_instructions")}
|
||||
changeHandler={handleZoomSliderChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/ui/v2/core/ImageUploader.tsx` */
|
||||
export default function ImageUploader({
|
||||
target,
|
||||
id,
|
||||
buttonMsg,
|
||||
handleAvatarChange,
|
||||
...props
|
||||
}: ImageUploaderProps) {
|
||||
const { t } = useLocale();
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const [{ result }, setFile] = useFileReader({
|
||||
method: "readAsDataURL",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (props.imageSrc) setImageSrc(props.imageSrc);
|
||||
}, [props.imageSrc]);
|
||||
|
||||
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) {
|
||||
return;
|
||||
}
|
||||
setFile(e.target.files[0]);
|
||||
};
|
||||
|
||||
const showCroppedImage = useCallback(
|
||||
async (croppedAreaPixels: Area | null) => {
|
||||
try {
|
||||
if (!croppedAreaPixels) return;
|
||||
const croppedImage = await getCroppedImg(
|
||||
result as string /* result is always string when using readAsDataUrl */,
|
||||
croppedAreaPixels
|
||||
);
|
||||
setImageSrc(croppedImage);
|
||||
handleAvatarChange(croppedImage);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[result, handleAvatarChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={
|
||||
(opened) => !opened && setFile(null) // unset file on close
|
||||
}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Button color="secondary" type="button" className="py-1 text-xs">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 className="font-cal text-lg leading-6 text-gray-900" id="modal-title">
|
||||
{t("upload_target", { target })}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="cropper mt-6 flex flex-col items-center justify-center p-8">
|
||||
{!result && (
|
||||
<div className="flex h-20 max-h-20 w-20 items-center justify-start rounded-full bg-gray-50">
|
||||
{!imageSrc && (
|
||||
<p className="w-full text-center text-sm text-white sm:text-xs">
|
||||
{t("no_target", { target })}
|
||||
</p>
|
||||
)}
|
||||
{imageSrc && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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-gray-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
|
||||
<input
|
||||
onInput={onInputFile}
|
||||
type="file"
|
||||
name={id}
|
||||
placeholder={t("upload_image")}
|
||||
className="pointer-events-none absolute mt-4 opacity-0"
|
||||
accept="image/*"
|
||||
/>
|
||||
{t("choose_a_file")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</DialogClose>
|
||||
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import noop from "lodash/noop";
|
||||
import { noop } from "lodash";
|
||||
import type { LinkProps } from "next/link";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { FC, MouseEventHandler } from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { PermissionContainer } from "@calcom/features/auth/PermissionContainer";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
@@ -60,7 +60,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
}
|
||||
: noop;
|
||||
|
||||
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||
const Component = tab.adminRequired ? PermissionContainer : Fragment;
|
||||
const className = tab.className || "";
|
||||
return (
|
||||
<Component key={tab.name}>
|
||||
@@ -69,8 +69,8 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-gray-900 text-gray-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
? "text-emphasis border-gray-900"
|
||||
: "hover:border-default hover:text-default text-subtle border-transparent",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
@@ -78,7 +78,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-gray-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
isCurrent ? "text-emphasis" : "group-hover:text-subtle text-muted",
|
||||
"-ml-0.5 hidden h-4 w-4 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
@@ -91,7 +91,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import Script from "next/script";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
import AppProviders from "@lib/app-providers";
|
||||
import { seoConfig } from "@lib/config/next-seo.config";
|
||||
|
||||
import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
|
||||
export interface CalPageWrapper {
|
||||
(props?: any): JSX.Element;
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
preload: true,
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
function PageWrapper(props: AppProps) {
|
||||
const { Component, pageProps, err, router } = props;
|
||||
let pageStatus = "200";
|
||||
|
||||
if (router.pathname === "/404") {
|
||||
pageStatus = "404";
|
||||
} else if (router.pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
|
||||
const providerProps = {
|
||||
...props,
|
||||
pageProps: {
|
||||
...props.pageProps,
|
||||
nonce,
|
||||
},
|
||||
};
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
||||
<I18nLanguageHandler />
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--font-inter: ${interFont.style.fontFamily};
|
||||
--font-cal: ${calFont.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{getLayout(
|
||||
Component.requiresLicense ? (
|
||||
<LicenseRequired>
|
||||
<Component {...pageProps} err={err} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<Component {...pageProps} err={err} />
|
||||
),
|
||||
router
|
||||
)}
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageWrapper;
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ErrorBoundary } from "@calcom/ui";
|
||||
import { FiCreditCard, FiKey, FiLock, FiTerminal, FiUser, FiUsers } from "@calcom/ui/components/icon";
|
||||
import { CreditCard, Key, Lock, Terminal, User, Users } from "@calcom/ui/components/icon";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
@@ -11,32 +11,32 @@ const tabs = [
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/my-account/profile",
|
||||
icon: FiUser,
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
name: "teams",
|
||||
href: "/settings/teams",
|
||||
icon: FiUsers,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: "security",
|
||||
href: "/settings/security",
|
||||
icon: FiKey,
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
name: "developer",
|
||||
href: "/settings/developer",
|
||||
icon: FiTerminal,
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/billing",
|
||||
icon: FiCreditCard,
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
href: "/settings/admin",
|
||||
icon: FiLock,
|
||||
icon: Lock,
|
||||
adminRequired: true,
|
||||
},
|
||||
];
|
||||
@@ -46,7 +46,7 @@ export default function SettingsShell({
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell {...rest}>
|
||||
<Shell {...rest} hideHeadingOnMobile>
|
||||
<div className="sm:mx-auto">
|
||||
<NavTabs tabs={tabs} />
|
||||
</div>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import React from "react";
|
||||
|
||||
const Slider = ({
|
||||
value,
|
||||
label,
|
||||
changeHandler,
|
||||
...props
|
||||
}: Omit<SliderPrimitive.SliderProps, "value"> & {
|
||||
value: number;
|
||||
label: string;
|
||||
changeHandler: (value: number) => void;
|
||||
}) => (
|
||||
<SliderPrimitive.Root
|
||||
className="slider mt-2"
|
||||
value={[value]}
|
||||
aria-label={label}
|
||||
onValueChange={(value: number[]) => changeHandler(value[0] ?? value)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="slider-track">
|
||||
<SliderPrimitive.Range className="slider-range" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="slider-thumb" />
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
|
||||
export default Slider;
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { OptionProps } from "react-select";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button, Select } from "@calcom/ui";
|
||||
import { FiPlus } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
} from "@calcom/ui";
|
||||
import { Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -13,31 +17,6 @@ interface AdditionalCalendarSelectorProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App["type"] }>) => {
|
||||
const { t } = useLocale();
|
||||
const { data } = optionProps;
|
||||
return data.slug !== "add-new" ? (
|
||||
<InstallAppButton
|
||||
type={data.type}
|
||||
render={(installProps) => {
|
||||
return (
|
||||
<Button {...installProps} className="flex w-full align-top" color="minimal">
|
||||
{data.image && (
|
||||
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
|
||||
)}
|
||||
<p className="text-left">{`${t("add")} ${data.label}`}</p>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
|
||||
<FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
|
||||
<p>{t("install_new_calendar_app")}</p>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||
@@ -59,51 +38,39 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
type: "new_other",
|
||||
});
|
||||
return (
|
||||
<Select
|
||||
name="additionalCalendar"
|
||||
placeholder={
|
||||
<Button StartIcon={FiPlus} color="secondary">
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={Plus} color="secondary" {...(isLoading && { loading: isLoading })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
components={{ Option: ImageOption }}
|
||||
styles={{
|
||||
menu: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
"@media only screen and (max-width: 640px)": {
|
||||
left: "0",
|
||||
},
|
||||
width: "max-content",
|
||||
right: "0",
|
||||
}),
|
||||
indicatorSeparator: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
display: "none",
|
||||
}),
|
||||
control: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
padding: "0",
|
||||
border: "0",
|
||||
borderRadius: "6px",
|
||||
minHeight: "auto",
|
||||
}),
|
||||
dropdownIndicator: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
display: "none",
|
||||
}),
|
||||
valueContainer: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
padding: "0",
|
||||
}),
|
||||
placeholder: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
margin: "0",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{options.map((data) => (
|
||||
<DropdownMenuItem key={data.slug} className="focus:outline-none">
|
||||
{data.slug === "add-new" ? (
|
||||
<DropdownItem StartIcon={Plus} color="minimal" href="/apps/categories/calendar">
|
||||
{t("install_new_calendar_app")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={data.type}
|
||||
render={(installProps) => {
|
||||
const props = { ...installProps } as any;
|
||||
return (
|
||||
<DropdownItem {...props} color="minimal" type="button">
|
||||
<span className="flex items-center gap-x-2">
|
||||
{data.image && <img className="h-5 w-5" src={data.image} alt={data.label} />}
|
||||
{`${t("add")} ${data.label}`}
|
||||
</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { InstallAppButton, AppDependencyComponent } 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/LicenseRequired";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
@@ -13,16 +13,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App as AppType } from "@calcom/types/App";
|
||||
import { Button, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
|
||||
import {
|
||||
FiBookOpen,
|
||||
FiCheck,
|
||||
FiExternalLink,
|
||||
FiFile,
|
||||
FiFlag,
|
||||
FiMail,
|
||||
FiPlus,
|
||||
FiShield,
|
||||
} from "@calcom/ui/components/icon";
|
||||
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Plus, Shield } from "@calcom/ui/components/icon";
|
||||
|
||||
/* These app slugs all require Google Cal to be installed */
|
||||
|
||||
const Component = ({
|
||||
name,
|
||||
@@ -43,12 +36,13 @@ const Component = ({
|
||||
tos,
|
||||
privacy,
|
||||
isProOnly,
|
||||
images,
|
||||
teamsPlanRequired,
|
||||
descriptionItems,
|
||||
isTemplate,
|
||||
dependencies,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t } = useLocale();
|
||||
const hasImages = images && images.length > 0;
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useLocale();
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
@@ -76,23 +70,40 @@ const Component = ({
|
||||
}
|
||||
);
|
||||
|
||||
const dependencyData = trpc.viewer.appsRouter.queryForDependencies.useQuery(dependencies, {
|
||||
enabled: !!dependencies,
|
||||
});
|
||||
|
||||
const disableInstall =
|
||||
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
|
||||
|
||||
// const disableInstall = requiresGCal && !gCalInstalled.data;
|
||||
|
||||
// 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 (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasImages && (
|
||||
<div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
{images ? (
|
||||
images.map((img) => (
|
||||
<img
|
||||
key={img}
|
||||
src={img}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
))
|
||||
{hasDescriptionItems && (
|
||||
<div className="align-center bg-subtle mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
{descriptionItems ? (
|
||||
descriptionItems.map((descriptionItem, index) =>
|
||||
typeof descriptionItem === "object" ? (
|
||||
<div
|
||||
key={`iframe-${index}`}
|
||||
className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
|
||||
<iframe allowFullScreen {...descriptionItem.iframe} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={descriptionItem}
|
||||
src={descriptionItem}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<SkeletonText />
|
||||
)}
|
||||
@@ -101,18 +112,22 @@ const Component = ({
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
|
||||
hasImages && "lg:ml-8"
|
||||
hasDescriptionItems && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex pt-4">
|
||||
<header>
|
||||
<div className="mb-4 flex items-center">
|
||||
<img className="min-h-16 min-w-16 h-16 w-16" src={logo} alt={name} />
|
||||
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "min-h-16 min-w-16 h-16 w-16")}
|
||||
src={logo}
|
||||
alt={name}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis ml-4 text-3xl">{name}</h1>
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-gray-600">
|
||||
<h2 className="text-default text-sm font-medium">
|
||||
<Link
|
||||
href={`categories/${categories[0]}`}
|
||||
className="rounded-md bg-gray-100 p-1 text-xs capitalize text-gray-800">
|
||||
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
|
||||
{categories[0]}
|
||||
</Link>{" "}
|
||||
• {t("published_by", { author })}
|
||||
@@ -128,7 +143,7 @@ const Component = ({
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={FiCheck} color="secondary" disabled>
|
||||
<Button StartIcon={Check} color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("default")}
|
||||
@@ -137,6 +152,8 @@ const Component = ({
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
@@ -149,7 +166,7 @@ const Component = ({
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
StartIcon={FiPlus}
|
||||
StartIcon={Plus}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
@@ -169,13 +186,15 @@ const Component = ({
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
router.replace("/apps/installed");
|
||||
appCredentials.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
@@ -203,6 +222,16 @@ const Component = ({
|
||||
) : (
|
||||
<SkeletonButton className="h-10 w-24" />
|
||||
)}
|
||||
|
||||
{dependencies &&
|
||||
(!dependencyData.isLoading ? (
|
||||
<div className="mt-6">
|
||||
<AppDependencyComponent appName={name} dependencyData={dependencyData.data} />
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonButton className="mt-6 h-20 grow" />
|
||||
))}
|
||||
|
||||
{price !== 0 && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
@@ -210,14 +239,18 @@ const Component = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="prose-sm prose mt-8">{body}</div>
|
||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||
<span>
|
||||
{price === 0 ? (
|
||||
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
|
||||
{body}
|
||||
</div>
|
||||
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
||||
<span className="text-default">
|
||||
{teamsPlanRequired ? (
|
||||
t("teams_plan_required")
|
||||
) : price === 0 ? (
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
@@ -227,16 +260,16 @@ const Component = ({
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h4 className="mt-8 mb-2 font-semibold text-gray-900 ">{t("learn_more")}</h4>
|
||||
<h4 className="text-emphasis mt-8 mb-2 font-semibold ">{t("learn_more")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-normal text-black no-underline hover:underline"
|
||||
className="text-emphasis text-sm font-normal no-underline hover:underline"
|
||||
href={docs}>
|
||||
<FiBookOpen className="mr-1 -mt-1 inline h-4 w-4 text-gray-500" />
|
||||
<BookOpen className="text-subtle mr-1 -mt-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -246,9 +279,9 @@ const Component = ({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={website}>
|
||||
<FiExternalLink className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
<ExternalLink className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -258,9 +291,9 @@ const Component = ({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<FiMail className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
<Mail className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
@@ -271,9 +304,9 @@ const Component = ({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={tos}>
|
||||
<FiFile className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
<File className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -283,20 +316,20 @@ const Component = ({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<FiShield className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
<Shield className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-8" />
|
||||
<span className="leading-1 block text-xs text-gray-500">
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<span className="leading-1 text-subtle block text-xs">
|
||||
{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}`}>
|
||||
<FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||
<Flag className="inline h-3 w-3" /> {t("report_app")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,11 +363,14 @@ export default function App(props: {
|
||||
privacy?: string;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
isProOnly: AppType["isProOnly"];
|
||||
images?: string[];
|
||||
teamsPlanRequired: AppType["teamsPlanRequired"];
|
||||
descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
|
||||
isTemplate?: boolean;
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
}) {
|
||||
return (
|
||||
<Shell smallHeading isPublic heading={<ShellHeading />} backPath="/apps" withoutSeo>
|
||||
<Shell smallHeading isPublic hideHeadingOnMobile heading={<ShellHeading />} backPath="/apps" withoutSeo>
|
||||
<HeadSeo
|
||||
title={props.name}
|
||||
description={props.description}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
Button,
|
||||
EmptyScreen,
|
||||
List,
|
||||
showToast,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
Switch,
|
||||
ShellSubHeading,
|
||||
} from "@calcom/ui";
|
||||
import { FiArrowLeft, FiCalendar, FiPlus } from "@calcom/ui/components/icon";
|
||||
import { Calendar, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -31,83 +29,6 @@ type Props = {
|
||||
destinationCalendarId?: string;
|
||||
};
|
||||
|
||||
function CalendarSwitch(props: {
|
||||
type: string;
|
||||
externalId: string;
|
||||
title: string;
|
||||
defaultSelected: boolean;
|
||||
destination?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
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.viewer.integrations.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="flex py-1">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
label={props.title}
|
||||
defaultChecked={props.defaultSelected}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
{!!props.destination && (
|
||||
<span className="ml-4 inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm font-normal text-gray-800">
|
||||
<FiArrowLeft className="h-4 w-4" />
|
||||
{t("adding_events_to")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||
@@ -177,22 +98,23 @@ function ConnectedCalendarsList(props: Props) {
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-gray-300" }}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="border-subtle border-t">
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="px-4 pt-4 text-sm text-gray-500">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 p-4">
|
||||
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-4 px-5 py-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
isChecked={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
/>
|
||||
))}
|
||||
@@ -218,7 +140,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-gray-300" }}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -264,7 +186,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
{!!data.connectedCalendars.length || !!installedCalendars.data?.items.length ? (
|
||||
<>
|
||||
{heading && (
|
||||
<div className="flex flex-col gap-6 rounded-md border border-gray-200 p-7">
|
||||
<div className="border-default flex flex-col gap-6 rounded-md border p-7">
|
||||
<ShellSubHeading
|
||||
title={t("calendar")}
|
||||
subtitle={t("installed_app_calendar_description")}
|
||||
@@ -279,18 +201,18 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="bg-muted border-subtle flex justify-between rounded-md border p-4">
|
||||
<div className="flex w-full flex-col items-start gap-4 md:flex-row md:items-center">
|
||||
<div className="relative rounded-md border border-gray-200 bg-white p-1.5">
|
||||
<FiCalendar className="h-8 w-8" strokeWidth="1" />
|
||||
<FiPlus
|
||||
className="absolute left-4 top-1/2 ml-0.5 mt-[1px] h-2 w-2 text-black"
|
||||
<div className="bg-default border-subtle relative rounded-md border p-1.5">
|
||||
<Calendar className="text-default h-8 w-8" strokeWidth="1" />
|
||||
<Plus
|
||||
className="text-emphasis absolute left-4 top-1/2 ml-0.5 mt-[1px] h-2 w-2"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:w-6/12">
|
||||
<h1 className="text-sm font-semibold">{t("create_events_on")}</h1>
|
||||
<p className="text-sm font-normal">{t("set_calendar")}</p>
|
||||
<h1 className="text-emphasis text-sm font-semibold">{t("create_events_on")}</h1>
|
||||
<p className="text-default text-sm font-normal">{t("set_calendar")}</p>
|
||||
</div>
|
||||
<div className="justify-end md:w-6/12">
|
||||
<DestinationCalendarSelector
|
||||
@@ -322,7 +244,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={FiCalendar}
|
||||
Icon={Calendar}
|
||||
headline={t("no_category_apps", {
|
||||
category: t("calendar").toLowerCase(),
|
||||
})}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { DestinationCalendar } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
interface Props {
|
||||
onChange: (value: { externalId: string; integration: string }) => void;
|
||||
isLoading?: boolean;
|
||||
hidePlaceholder?: boolean;
|
||||
/** The external Id of the connected calendar */
|
||||
value: string | undefined;
|
||||
destinationCalendar?: DestinationCalendar | null;
|
||||
}
|
||||
|
||||
const DestinationCalendarSelector = ({
|
||||
onChange,
|
||||
isLoading,
|
||||
value,
|
||||
hidePlaceholder,
|
||||
destinationCalendar,
|
||||
}: Props): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// Extra styles to show prefixed text in react-select
|
||||
const content = (hidePlaceholder = false) => {
|
||||
if (!hidePlaceholder) {
|
||||
return {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
":before": {
|
||||
content: `'${t("select_destination_calendar")}:'`,
|
||||
display: "block",
|
||||
marginRight: 8,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const selected = query.data?.connectedCalendars
|
||||
.map((connected) => connected.calendars ?? [])
|
||||
.flat()
|
||||
.find((cal) => cal.externalId === value);
|
||||
|
||||
if (selected) {
|
||||
setSelectedOption({
|
||||
value: `${selected.integration}:${selected.externalId}`,
|
||||
label: `${selected.name} (${selected.externalId})` || "",
|
||||
});
|
||||
}
|
||||
}, [query.data?.connectedCalendars, value]);
|
||||
|
||||
if (!query.data?.connectedCalendars.length) {
|
||||
return null;
|
||||
}
|
||||
const options =
|
||||
query.data.connectedCalendars.map((selectedCalendar) => ({
|
||||
key: selectedCalendar.credentialId,
|
||||
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
|
||||
options: (selectedCalendar.calendars ?? [])
|
||||
.filter((cal) => cal.readOnly === false)
|
||||
.map((cal) => ({
|
||||
label: `${cal.name} (${cal.externalId})` || "",
|
||||
value: `${cal.integration}:${cal.externalId}`,
|
||||
})),
|
||||
})) ?? [];
|
||||
const defaultCalendarSelectedString = destinationCalendar?.externalId
|
||||
? `(${
|
||||
destinationCalendar.externalId.length > 15
|
||||
? destinationCalendar.externalId.substring(0, 15) + "..."
|
||||
: destinationCalendar.externalId
|
||||
})`
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
className="relative flex items-center"
|
||||
title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||
<Select
|
||||
name="primarySelectedCalendar"
|
||||
placeholder={
|
||||
!hidePlaceholder ? (
|
||||
`${t("select_destination_calendar")}`
|
||||
) : (
|
||||
<span className="whitespace-nowrap">
|
||||
{t("default_calendar_selected")} {defaultCalendarSelectedString}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
options={options}
|
||||
styles={{
|
||||
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
option: (defaultStyles, state) => ({
|
||||
...defaultStyles,
|
||||
backgroundColor: state.isSelected
|
||||
? state.isFocused
|
||||
? "var(--brand-color)"
|
||||
: "var(--brand-color)"
|
||||
: state.isFocused
|
||||
? "var(--brand-color-dark-mode)"
|
||||
: "var(--brand-text-color)",
|
||||
}),
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "6px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
width: "100%",
|
||||
},
|
||||
};
|
||||
},
|
||||
}}
|
||||
isSearchable={false}
|
||||
className={classNames(
|
||||
"block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 text-sm",
|
||||
!hidePlaceholder && "font-medium"
|
||||
)}
|
||||
onChange={(option) => {
|
||||
setSelectedOption(option);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Split only the first `:`, since Apple uses the full URL as externalId */
|
||||
const [integration, externalId] = option.value.split(/:(.+)/);
|
||||
|
||||
onChange({
|
||||
integration,
|
||||
externalId,
|
||||
});
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
value={selectedOption}
|
||||
components={{
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DestinationCalendarSelector;
|
||||
@@ -6,7 +6,7 @@ import React from "react";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
import { FiAlertCircle } from "@calcom/ui/components/icon";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
|
||||
type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -18,19 +18,19 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const isAdmin = session.data?.user.role === "ADMIN";
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
|
||||
return (
|
||||
<Shell {...rest} actions={actions?.("block")}>
|
||||
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
<EmptyScreen
|
||||
Icon={FiAlertCircle}
|
||||
Icon={AlertCircle}
|
||||
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") : ""}
|
||||
description={isAdmin ? "You can enable apps in the settings" : ""}
|
||||
buttonText={isAdmin ? t("apps_settings") : ""}
|
||||
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -6,38 +6,38 @@ import type { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
|
||||
import { FiBarChart, FiCalendar, FiCreditCard, FiGrid, FiShare2, FiVideo } from "@calcom/ui/components/icon";
|
||||
import { BarChart, Calendar, CreditCard, Grid, Share2, Video } from "@calcom/ui/components/icon";
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "calendar",
|
||||
href: "/apps/installed/calendar",
|
||||
icon: FiCalendar,
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
name: "conferencing",
|
||||
href: "/apps/installed/conferencing",
|
||||
icon: FiVideo,
|
||||
icon: Video,
|
||||
},
|
||||
{
|
||||
name: "payment",
|
||||
href: "/apps/installed/payment",
|
||||
icon: FiCreditCard,
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "automation",
|
||||
href: "/apps/installed/automation",
|
||||
icon: FiShare2,
|
||||
icon: Share2,
|
||||
},
|
||||
{
|
||||
name: "analytics",
|
||||
href: "/apps/installed/analytics",
|
||||
icon: FiBarChart,
|
||||
icon: BarChart,
|
||||
},
|
||||
{
|
||||
name: "other",
|
||||
href: "/apps/installed/other",
|
||||
icon: FiGrid,
|
||||
icon: Grid,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function InstalledAppsLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell {...rest}>
|
||||
<Shell {...rest} hideHeadingOnMobile>
|
||||
<AppCategoryNavigation baseURL="/apps/installed" containerClassname="min-w-0 w-full">
|
||||
{children}
|
||||
</AppCategoryNavigation>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function TwoFactor({ center = true }) {
|
||||
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||
<Label className="mt-4"> {t("2fa_code")}</Label>
|
||||
|
||||
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
|
||||
<p className="text-subtle mb-4 text-sm">{t("2fa_enabled_instructions")}</p>
|
||||
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
||||
<div className="flex flex-row justify-between">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserPermissionRole } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ComponentProps } from "react";
|
||||
@@ -6,6 +5,7 @@ import React, { useEffect } from "react";
|
||||
|
||||
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import type Shell from "@calcom/features/shell/Shell";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { ErrorBoundary } from "@calcom/ui";
|
||||
|
||||
export default function AdminLayout({
|
||||
@@ -26,7 +26,7 @@ export default function AdminLayout({
|
||||
const isAppsPage = router.asPath.startsWith("/settings/admin/apps");
|
||||
return (
|
||||
<SettingsLayout {...rest}>
|
||||
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200">
|
||||
<div className="divide-subtle mx-auto flex max-w-4xl flex-row divide-y">
|
||||
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
import { Button, SkeletonText } from "@calcom/ui";
|
||||
import { FiMoreHorizontal } from "@calcom/ui/components/icon";
|
||||
import { MoreHorizontal } from "@calcom/ui/components/icon";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="animate-pulse divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
@@ -20,8 +20,8 @@ export default SkeletonLoader;
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li>
|
||||
<div className="flex items-center justify-between py-5 hover:bg-neutral-50 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
|
||||
<div className="items-between flex w-full flex-col justify-center hover:bg-neutral-50 sm:px-6">
|
||||
<div className="flex items-center justify-between py-5 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
|
||||
<div className="items-between flex w-full flex-col justify-center sm:px-6">
|
||||
<SkeletonText className="my-1 h-4 w-32" />
|
||||
<SkeletonText className="my-1 h-2 w-24" />
|
||||
<SkeletonText className="my-1 h-2 w-40" />
|
||||
@@ -31,7 +31,7 @@ function SkeletonItem() {
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon={FiMoreHorizontal}
|
||||
StartIcon={MoreHorizontal}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@ export const SelectSkeletonLoader = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<li
|
||||
className={classNames(
|
||||
"group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3",
|
||||
"border-subtle group flex w-full items-center justify-between rounded-sm border px-[10px] py-3",
|
||||
className
|
||||
)}>
|
||||
<div className="flex-grow truncate text-sm">
|
||||
|
||||
@@ -4,58 +4,65 @@ import { getEventLocationType, locationKeyToString } from "@calcom/app-store/loc
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import { FiLink } from "@calcom/ui/components/icon";
|
||||
import { Link } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { Props } from "./pages/AvailabilityPage";
|
||||
|
||||
const excludeNullValues = (value: unknown) => !!value;
|
||||
|
||||
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return locations.length ? (
|
||||
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
|
||||
{locations.map((location, index) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
const renderLocations = locations.map((location, index) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
if (eventLocationType.variable === "hostDefault") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translateAbleKeys = [
|
||||
"attendee_in_person",
|
||||
"in_person",
|
||||
"attendee_phone_number",
|
||||
"link_meeting",
|
||||
"organizer_phone_number",
|
||||
];
|
||||
const translateAbleKeys = [
|
||||
"attendee_in_person",
|
||||
"in_person",
|
||||
"attendee_phone_number",
|
||||
"link_meeting",
|
||||
"organizer_phone_number",
|
||||
];
|
||||
|
||||
const locationKey = z.string().default("").parse(locationKeyToString(location));
|
||||
const locationKey = z.string().default("").parse(locationKeyToString(location));
|
||||
const translatedLocation = location.type.startsWith("integrations:")
|
||||
? eventLocationType.label
|
||||
: translateAbleKeys.includes(locationKey)
|
||||
? t(locationKey)
|
||||
: locationKey;
|
||||
|
||||
const translatedLocation = location.type.startsWith("integrations:")
|
||||
? eventLocationType.label
|
||||
: translateAbleKeys.includes(locationKey)
|
||||
? t(locationKey)
|
||||
: locationKey;
|
||||
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Link className="text-default ml-[2px] h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.startsWith("/app-store") ? "dark:invert-[.65]" : ""
|
||||
)}
|
||||
<Tooltip content={translatedLocation}>
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={translatedLocation}>
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const filteredLocations = renderLocations.filter(excludeNullValues) as JSX.Element[];
|
||||
return filteredLocations.length ? (
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm">
|
||||
{filteredLocations}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
import { SkeletonContainer, SkeletonText, ToggleGroup } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
@@ -24,9 +25,11 @@ type AvailableTimesProps = {
|
||||
eventTypeSlug: string;
|
||||
date?: Dayjs;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
bookingAttendees?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
ethSignature?: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
@@ -39,8 +42,11 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
timeFormat,
|
||||
onTimeFormatChange,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
ethSignature,
|
||||
duration,
|
||||
}) => {
|
||||
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
|
||||
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -61,16 +67,25 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const reserveSlot = (slot: Slot) => {
|
||||
reserveSlotMutation.mutate({
|
||||
slotUtcStartDate: slot.time,
|
||||
eventTypeId,
|
||||
slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(),
|
||||
bookingUid: slot.bookingUid,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={slotPickerRef}>
|
||||
{!!date ? (
|
||||
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col px-4 text-center sm:mt-0 sm:p-5 md:-mb-5 md:min-w-[200px] lg:min-w-[300px]">
|
||||
<div className="mb-6 flex items-center text-left text-base">
|
||||
<div className="mt-8 flex h-full w-full flex-col rounded-md px-4 text-center sm:mt-0 sm:p-4 md:-mb-5 md:min-w-[200px] md:p-4 lg:min-w-[300px]">
|
||||
<div className="mb-4 flex items-center text-left text-base">
|
||||
<div className="mr-4">
|
||||
<span className="text-bookingdarker dark:text-darkgray-800 font-semibold text-gray-900">
|
||||
<span className="text-emphasis font-semibold">
|
||||
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||
</span>
|
||||
<span className="text-bookinglight font-medium">
|
||||
<span className="text-subtle">
|
||||
, {date.toDate().toLocaleString(i18n.language, { month: "short" })} {date.format(" D ")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -85,12 +100,14 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={ref} className="flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||
<div
|
||||
ref={ref}
|
||||
className="scroll-bar scrollbar-track-w-20 relative -mb-4 flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
pathname: string;
|
||||
query: Record<string, string | number | string[] | undefined>;
|
||||
query: Record<string, string | number | string[] | undefined | TimeFormat>;
|
||||
};
|
||||
const bookingUrl: BookingURL = {
|
||||
pathname: router.pathname.endsWith("/embed") ? "../book" : "book",
|
||||
@@ -99,6 +116,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
date: dayjs.utc(slot.time).tz(timeZone()).format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
timeFormat,
|
||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||
...(ethSignature ? { ethSignature } : {}),
|
||||
@@ -114,28 +132,38 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
bookingUrl.query.bookingUid = slot.bookingUid;
|
||||
}
|
||||
|
||||
let slotFull, notEnoughSeats;
|
||||
|
||||
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
|
||||
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||
{seatsPerTimeSlot && slot.attendees && (slotFull || notEnoughSeats) ? (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-primary-500 dark:bg-darkgray-200 dark:text-darkgray-900 mb-2 block rounded-sm border bg-white py-2 font-medium opacity-25 dark:border-transparent ",
|
||||
"text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}>
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
|
||||
{notEnoughSeats ? (
|
||||
<p className="text-sm">{t("not_enough_seats")}</p>
|
||||
) : slots ? (
|
||||
<p className="text-sm">{t("booking_full")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={bookingUrl}
|
||||
prefetch={false}
|
||||
className={classNames(
|
||||
"hover:bg-brand hover:border-brand hover:text-brandcontrast dark:hover:text-darkmodebrandcontrast",
|
||||
"dark:bg-darkgray-200 dark:hover:bg-darkmodebrand 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",
|
||||
" bg-default dark:bg-muted border-default hover:bg-subtle hover:border-brand-default text-emphasis mb-2 block rounded-md border py-2 text-sm font-medium",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}
|
||||
onClick={() => reserveSlot(slot)}
|
||||
data-testid="time">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && (
|
||||
@@ -159,7 +187,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
|
||||
{!isLoading && !slots.length && (
|
||||
<div className="-mt-4 flex h-full w-full flex-col content-center items-center justify-center">
|
||||
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
|
||||
<h1 className="text-emphasis my-6 text-xl">{t("all_booked_today")}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Badge } from "@calcom/ui";
|
||||
import { FiCheckSquare, FiClock } from "@calcom/ui/components/icon";
|
||||
import { CheckSquare, Clock } from "@calcom/ui/components/icon";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
@@ -46,14 +46,16 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { date: bookingDate } = useRouterQuery("date");
|
||||
const { t } = useLocale();
|
||||
const { duration = eventType.length.toString(), setQuery: setDuration } = useRouterQuery("duration");
|
||||
const { duration, setQuery: setDuration } = useRouterQuery("duration");
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
eventType.metadata?.multipleDuration &&
|
||||
!eventType.metadata?.multipleDuration?.includes(Number(duration))
|
||||
!duration ||
|
||||
isNaN(Number(duration)) ||
|
||||
(eventType.metadata?.multipleDuration &&
|
||||
!eventType.metadata?.multipleDuration.includes(Number(duration)))
|
||||
) {
|
||||
setDuration(eventType.length.toString());
|
||||
setDuration(eventType.length);
|
||||
}
|
||||
}, [duration, setDuration, eventType.length, eventType.metadata?.multipleDuration]);
|
||||
|
||||
@@ -81,32 +83,27 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
size="sm"
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
|
||||
<h2 className="text-default mt-1 mb-2 break-words text-sm font-medium ">{profile.name}</h2>
|
||||
<h1 className="font-cal text-emphasis mb-6 break-words text-2xl font-semibold leading-none">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-4 text-sm font-medium text-gray-600">
|
||||
<div className=" text-default flex flex-col space-y-4 text-sm font-medium">
|
||||
{eventType?.description && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
"scroll-bar scrollbar-track-w-20 -mx-5 flex max-h-[180px] overflow-y-scroll px-5 ",
|
||||
isBookingPage && "text-default text-sm font-medium"
|
||||
)}>
|
||||
<div className="scroll-bar scrollbar-track-w-20 max-h-[200px] max-w-full flex-shrink break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
{/* TODO: Fix colors when token is introdcued to DS */}
|
||||
<div className="max-w-full flex-shrink break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requiresConfirmation && (
|
||||
<div
|
||||
className={classNames(
|
||||
"items-top flex",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div className={classNames("items-top flex", isBookingPage && "text-default text-sm font-medium")}>
|
||||
<div>
|
||||
<FiCheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
<CheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
</div>
|
||||
{requiresConfirmationText}
|
||||
</div>
|
||||
@@ -117,12 +114,12 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap text-sm font-medium",
|
||||
isBookingPage && "dark:text-darkgray-600 text-gray-600",
|
||||
isBookingPage && "text-default",
|
||||
!eventType.metadata?.multipleDuration && "items-center"
|
||||
)}>
|
||||
<FiClock
|
||||
<Clock
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "mt-[2px]"
|
||||
)}
|
||||
/>
|
||||
@@ -133,12 +130,11 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
<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"
|
||||
? "bg-emphasis border-emphasis text-emphasis "
|
||||
: "bg-subtle text-default border-transparent ",
|
||||
"cursor-pointer border"
|
||||
)}
|
||||
onClick={() => {
|
||||
setDuration(dur);
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { i18n } from "next-i18next";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { FiCreditCard } from "@calcom/ui/components/icon";
|
||||
import { CreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
const BookingDescriptionPayment = (props: { eventType: Parameters<typeof getPaymentAppData>[0] }) => {
|
||||
const BookingDescriptionPayment = (props: {
|
||||
eventType: Parameters<typeof getPaymentAppData>[0];
|
||||
t: TFunction;
|
||||
i18n: typeof i18n;
|
||||
}) => {
|
||||
const paymentAppData = getPaymentAppData(props.eventType);
|
||||
if (!paymentAppData || paymentAppData.price <= 0) return null;
|
||||
|
||||
const params = {
|
||||
amount: paymentAppData.price / 100.0,
|
||||
formatParams: { amount: { currency: paymentAppData.currency } },
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
<CreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>{props.t("no_show_fee_amount", params)}</>
|
||||
) : (
|
||||
<>
|
||||
{/* If undefined this will default to the browser locale */}
|
||||
{new Intl.NumberFormat(i18n?.language, {
|
||||
style: "currency",
|
||||
currency: paymentAppData.currency,
|
||||
}).format(paymentAppData.price / 100)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { ActionType } from "@calcom/ui";
|
||||
@@ -26,10 +29,11 @@ import {
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
} from "@calcom/ui";
|
||||
import { FiCheck, FiClock, FiMapPin, FiRefreshCcw, FiSend, FiSlash, FiX } from "@calcom/ui/components/icon";
|
||||
import { Check, Clock, MapPin, RefreshCcw, Send, Ban, X, CreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
@@ -46,12 +50,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
@@ -78,6 +87,8 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const paymentAppData = getPaymentAppData(booking.eventType);
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
@@ -94,6 +105,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
if (!booking.seatsReferences[0]) {
|
||||
return undefined;
|
||||
}
|
||||
return booking.seatsReferences[0].referenceUid;
|
||||
};
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
@@ -101,19 +119,23 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
onClick: () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
},
|
||||
icon: FiSlash,
|
||||
icon: Ban,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
},
|
||||
icon: FiCheck,
|
||||
disabled: mutation.isLoading,
|
||||
color: "primary",
|
||||
},
|
||||
// For bookings with payment, only confirm if the booking is paid for
|
||||
...((isPending && !booking?.eventType?.price) || (!!booking?.eventType?.price && booking.paid)
|
||||
? [
|
||||
{
|
||||
id: "confirm",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
},
|
||||
icon: Check,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
@@ -135,8 +157,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}`,
|
||||
icon: FiX,
|
||||
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
|
||||
`,
|
||||
icon: X,
|
||||
},
|
||||
{
|
||||
id: "edit_booking",
|
||||
@@ -144,13 +167,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
actions: [
|
||||
{
|
||||
id: "reschedule",
|
||||
icon: FiClock,
|
||||
icon: Clock,
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
href: `/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
icon: FiSend,
|
||||
icon: Send,
|
||||
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
|
||||
label: t("send_reschedule_request"),
|
||||
onClick: () => {
|
||||
@@ -163,12 +188,24 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
onClick: () => {
|
||||
setIsOpenLocationDialog(true);
|
||||
},
|
||||
icon: FiMapPin,
|
||||
icon: MapPin,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const chargeCardActions: ActionType[] = [
|
||||
{
|
||||
id: "charge_card",
|
||||
label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"),
|
||||
disabled: cardCharged,
|
||||
onClick: () => {
|
||||
setChargeCardDialogIsOpen(true);
|
||||
},
|
||||
icon: CreditCard,
|
||||
},
|
||||
];
|
||||
|
||||
if (isTabRecurring && isRecurring) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
@@ -179,14 +216,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||
<FiSend className="-mt-[1px] w-4 rotate-45" />
|
||||
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
|
||||
</div>
|
||||
<Badge startIcon={Send} size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
@@ -221,10 +259,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const title = booking.title;
|
||||
// To be used after we run query on legacy bookings
|
||||
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
|
||||
|
||||
const showRecordingsButtons =
|
||||
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||
|
||||
const title = decodeURIComponent(booking.title);
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -238,6 +280,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{showRecordingsButtons && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
@@ -255,7 +306,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
@@ -276,13 +327,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr className="group flex flex-col hover:bg-gray-50 sm:flex-row">
|
||||
<tr className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||
<td
|
||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||
onClick={onClickTableData}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
@@ -303,13 +354,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||
{t("paid")}
|
||||
{booking.paid && !booking.payment[0] ? (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("error_collecting_card")}
|
||||
</Badge>
|
||||
)}
|
||||
) : booking.paid ? (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
<div className="text-muted mt-2 text-sm">
|
||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||
</div>
|
||||
)}
|
||||
@@ -319,8 +374,8 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pt-4 pb-2 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="pr-2 text-sm text-gray-500">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
@@ -349,7 +404,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</Badge>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-sm text-gray-400 sm:hidden">
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||
</div>
|
||||
)}
|
||||
@@ -359,21 +414,21 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-sm font-medium leading-6 text-gray-900 md:max-w-full",
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
<span> </span>
|
||||
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Badge className="hidden ltr:ml-2 ltr:mr-2 rtl:ml-2 sm:inline-flex" variant="orange">
|
||||
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
|
||||
<Badge className="ms-2 me-2 hidden sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 truncate text-sm text-gray-600"
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
@@ -386,18 +441,18 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||
{isConfirmed && <TableActions actions={bookedActions} />}
|
||||
{isRejected && <div className="text-sm text-gray-500">{t("rejected")}</div>}
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
@@ -407,6 +462,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
{booking.status === "ACCEPTED" && booking.paid && booking.payment[0]?.paymentOption === "HOLD" && (
|
||||
<div className="ml-2">
|
||||
<TableActions actions={chargeCardActions} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
@@ -422,14 +482,17 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const now = new Date();
|
||||
const recurringCount = recurringDates.filter((date) => {
|
||||
const recurringCount = recurringDates.filter((recurringDate) => {
|
||||
return (
|
||||
date >= now &&
|
||||
recurringDate >= now &&
|
||||
!booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
|
||||
.map((date) => date.toDateString())
|
||||
.includes(date.toDateString())
|
||||
.includes(recurringDate.toDateString())
|
||||
);
|
||||
}).length;
|
||||
|
||||
@@ -452,14 +515,14 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
||||
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
||||
{formatTime(aDate, user?.timeFormat, user?.timeZone)}
|
||||
{" - "}
|
||||
{dayjs(aDate).format("D MMMM YYYY")}
|
||||
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
|
||||
</p>
|
||||
);
|
||||
})}>
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<FiRefreshCcw
|
||||
<div className="text-default">
|
||||
<RefreshCcw
|
||||
strokeWidth="3"
|
||||
className="float-left mr-1 mt-1.5 inline-block h-3 w-3 text-gray-400"
|
||||
className="text-muted float-left mr-1 mt-1.5 inline-block h-3 w-3"
|
||||
/>
|
||||
<p className="mt-1 pl-5 text-xs">
|
||||
{booking.status === BookingStatus.ACCEPTED
|
||||
@@ -532,13 +595,13 @@ const DisplayAttendees = ({
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
<div className="text-emphasis text-sm">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} />
|
||||
{attendees.length > 1 && (
|
||||
<>
|
||||
<div className="inline-block text-sm text-gray-900"> {t("and")} </div>
|
||||
<div className="text-emphasis inline-block text-sm"> {t("and")} </div>
|
||||
{attendees.length > 2 ? (
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee) => (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button, TextArea } from "@calcom/ui";
|
||||
import { FiX } from "@calcom/ui/components/icon";
|
||||
import { X } from "@calcom/ui/components/icon";
|
||||
|
||||
type Props = {
|
||||
booking: {
|
||||
@@ -23,27 +22,34 @@ type Props = {
|
||||
setIsCancellationMode: (value: boolean) => void;
|
||||
theme: string | null;
|
||||
allRemainingBookings: boolean;
|
||||
seatReferenceUid?: string;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, allRemainingBookings } = props;
|
||||
const { booking, allRemainingBookings, seatReferenceUid } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
useTheme(props.theme);
|
||||
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mt-8">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<FiX className="h-6 w-6 text-red-600" />
|
||||
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<X className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -51,12 +57,13 @@ export default function CancelBooking(props: Props) {
|
||||
)}
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||
<label className="text-default font-medium">{t("cancellation_reason")}</label>
|
||||
<TextArea
|
||||
ref={cancelBookingRef}
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="dark:bg-darkgray-100 dark:border-darkgray-400 mt-2 mb-4 w-full dark:text-white "
|
||||
className="mt-2 mb-4 w-full "
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
@@ -68,21 +75,20 @@ export default function CancelBooking(props: Props) {
|
||||
{t("nevermind")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex justify-center"
|
||||
data-testid="cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const payload = {
|
||||
id: booking?.id,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
};
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
uid: booking?.uid,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
// @NOTE: very important this shouldn't cancel with number ID use uid instead
|
||||
seatReferenceUid,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="animate-pulse divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:overflow-hidden">
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
|
||||
@@ -78,6 +78,7 @@ export const SlotPicker = ({
|
||||
recurringEventCount,
|
||||
users,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
weekStart = 0,
|
||||
ethSignature,
|
||||
}: {
|
||||
@@ -89,6 +90,7 @@ export const SlotPicker = ({
|
||||
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||
timeZone?: string;
|
||||
seatsPerTimeSlot?: number;
|
||||
bookingAttendees?: number;
|
||||
recurringEventCount?: number;
|
||||
users: string[];
|
||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
@@ -131,10 +133,7 @@ export const SlotPicker = ({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
startTime:
|
||||
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
|
||||
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
|
||||
: browsingDate?.startOf("month"),
|
||||
startTime: dayjs().startOf("day"),
|
||||
endTime: browsingDate?.endOf("month"),
|
||||
timeZone,
|
||||
duration,
|
||||
@@ -164,8 +163,8 @@ export const SlotPicker = ({
|
||||
<DatePicker
|
||||
isLoading={isLoading}
|
||||
className={classNames(
|
||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
||||
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-4 lg:min-w-[455px]",
|
||||
selectedDate ? " border-subtle sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||
)}
|
||||
includedDates={Object.keys(monthSlots).filter((k) => monthSlots[k].length > 0)}
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
@@ -192,8 +191,10 @@ export const SlotPicker = ({
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
bookingAttendees={bookingAttendees}
|
||||
recurringCount={recurringEventCount}
|
||||
ethSignature={ethSignature}
|
||||
duration={parseInt(duration)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import type { ITimezoneOption } from "@calcom/ui";
|
||||
import { TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { timeZone } from "../../lib/clock";
|
||||
|
||||
type Props = {
|
||||
@@ -15,8 +12,6 @@ type Props = {
|
||||
|
||||
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const query = useMeQuery();
|
||||
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(timeZone());
|
||||
@@ -28,59 +23,17 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||
}
|
||||
}, [selectedTimeZone, onSelectTimeZone]);
|
||||
|
||||
const customStyles = {
|
||||
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
|
||||
padding: "10px 12px !important",
|
||||
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
|
||||
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
|
||||
...(userTheme === "dark" && {
|
||||
color: state.isSelected || state.isFocused ? "white" : "#80868B",
|
||||
}),
|
||||
}),
|
||||
control: () => ({
|
||||
display: "flex",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent !important",
|
||||
minWidth: "5rem",
|
||||
height: "24px",
|
||||
minHeight: "24px !important",
|
||||
boxShadow: "none !important",
|
||||
}),
|
||||
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||
...provided,
|
||||
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||
...(userTheme === "dark" && {
|
||||
color: "#a5a5a5 !important",
|
||||
}),
|
||||
}),
|
||||
menu: (provided: object) => ({
|
||||
...provided,
|
||||
minWidth: "20rem",
|
||||
margin: "8px 0 0 -20px",
|
||||
}),
|
||||
valueContainer: (provided: object) => ({
|
||||
...provided,
|
||||
padding: "0 0 0 6px",
|
||||
}),
|
||||
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||
...provided,
|
||||
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
|
||||
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||
marginLeft: 4,
|
||||
padding: 0,
|
||||
...(userTheme === "dark" && {
|
||||
color: "#80868B",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
return !!selectedTimeZone ? (
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
classNames={{
|
||||
singleValue: () => "text-default",
|
||||
dropdownIndicator: () => "text-default",
|
||||
menu: () => "!w-64 max-w-[90vw] shadow-dropdown bg-default border-subtle border rounded-md mt-1",
|
||||
}}
|
||||
variant="minimal"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="flex h-6 text-sm font-medium"
|
||||
styles={customStyles}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FiGlobe } from "@calcom/ui/components/icon";
|
||||
import { Globe } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
|
||||
@@ -17,8 +17,8 @@ export function TimezoneDropdown({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
|
||||
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
|
||||
<Globe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
@@ -14,17 +13,17 @@ import {
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { FiCreditCard, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { HeadSeo, NumberInput, useCalcomTheme } from "@calcom/ui";
|
||||
import { CreditCard, User, RefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
|
||||
@@ -37,7 +36,7 @@ import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
const PoweredByCal = dynamic(() => import("@components/ui/PoweredByCal"));
|
||||
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
|
||||
|
||||
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
|
||||
/*const SlotPicker = dynamic(() => import("../SlotPicker").then((mod) => mod.SlotPicker), {
|
||||
@@ -52,17 +51,30 @@ const dateQuerySchema = z.object({
|
||||
rescheduleUid: z.string().optional().default(""),
|
||||
date: z.string().optional().default(""),
|
||||
timeZone: z.string().optional().default(""),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor: string; darkBrandColor: string }) => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
};
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const query = dateQuerySchema.parse(router.query);
|
||||
const { rescheduleUid } = query;
|
||||
useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
useBrandColors({
|
||||
brandColor: profile.brandColor,
|
||||
darkBrandColor: profile.darkBrandColor,
|
||||
});
|
||||
const { t, i18n } = useLocale();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
@@ -91,8 +103,9 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
/*
|
||||
const telemetry = useTelemetry();
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
@@ -100,7 +113,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
);
|
||||
}
|
||||
}, [telemetry]);
|
||||
}, [telemetry]); */
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
// get dynamic user list here
|
||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||
@@ -110,6 +123,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
[timeZone]
|
||||
);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
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.
|
||||
@@ -124,6 +138,15 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
: undefined,
|
||||
];
|
||||
|
||||
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
|
||||
{
|
||||
seatReferenceUid: rescheduleUid,
|
||||
},
|
||||
{
|
||||
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
|
||||
<HeadSeo
|
||||
@@ -143,52 +166,68 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
nofollow: eventType.hidden,
|
||||
noindex: eventType.hidden,
|
||||
}}
|
||||
isBrandingHidden={restProps.isBrandingHidden}
|
||||
/>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
<main
|
||||
className={classNames(
|
||||
"flex flex-col md:mx-4",
|
||||
shouldAlignCentrally ? "items-center" : "items-start",
|
||||
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||
!isEmbed && classNames("bg-subtle dark:bg-default mx-auto my-0 ease-in-out md:my-24")
|
||||
)}>
|
||||
<div>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent
|
||||
? ""
|
||||
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||
"border-bookinglightest md:rounded-md md:border",
|
||||
isBackgroundTransparent ? "" : "bg-default dark:bg-muted pb-4 md:pb-0",
|
||||
"border-booker md:border-booker-width md:rounded-md",
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="md:flex">
|
||||
{showEventTypeDetails && (
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
" border-subtle flex flex-col p-5 sm:border-r",
|
||||
"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.seatsPerTimeSlot && bookingAttendees && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap items-center text-sm font-medium",
|
||||
" text-default",
|
||||
"ltr:mr-[10px] rtl:ml-[10px]"
|
||||
)}>
|
||||
<User
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
"mt-[2px]"
|
||||
)}
|
||||
/>{" "}
|
||||
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
|
||||
</div>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
<RefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
<NumberInput
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
min="1"
|
||||
max={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"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
isFullWidth={false}
|
||||
className="me-2 inline w-16"
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
@@ -199,14 +238,22 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
)}
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
<CreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>
|
||||
{t("no_show_fee_amount", {
|
||||
amount: paymentAppData.price / 100.0,
|
||||
formatParams: { amount: { currency: paymentAppData.currency } },
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: paymentAppData.currency,
|
||||
}).format(paymentAppData.price / 100)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
@@ -215,12 +262,12 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||
className="mt-4 mb-3 text-default"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-darkgray-600">
|
||||
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
||||
<p className="text-subtle line-through ">
|
||||
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-subtle" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -247,13 +294,14 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
bookingAttendees={bookingAttendees || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
ethSignature={gateState.rainbowToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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 />}
|
||||
{!restProps.isBrandingHidden && !isEmbed && <PoweredBy />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { createBooking, createRecurringBooking } from "@calcom/features/bookings/lib";
|
||||
import {
|
||||
getBookingFieldsWithSystemFields,
|
||||
SystemField,
|
||||
@@ -29,24 +29,26 @@ import {
|
||||
import getBookingResponsesSchema, {
|
||||
getBookingResponsesPartialSchema,
|
||||
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilder";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { APP_NAME, MINUTES_TO_BOOK } from "@calcom/lib/constants";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { parseDate, parseRecurringDates } from "@calcom/lib/parse-dates";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { Button, Form, Tooltip } from "@calcom/ui";
|
||||
import { FiAlertTriangle, FiCalendar, FiRefreshCw, FiUser } from "@calcom/ui/components/icon";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, Form, Tooltip, useCalcomTheme } from "@calcom/ui";
|
||||
import { AlertTriangle, Calendar, RefreshCw, User } from "@calcom/ui/components/icon";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
|
||||
import type { Gate, GateState } from "@components/Gates";
|
||||
import Gates from "@components/Gates";
|
||||
@@ -56,11 +58,21 @@ import type { BookPageProps } from "../../../pages/[user]/book";
|
||||
import type { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import type { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
const BookingDescriptionPayment = dynamic(
|
||||
() => import("@components/booking/BookingDescriptionPayment")
|
||||
) as unknown as typeof import("@components/booking/BookingDescriptionPayment").default;
|
||||
|
||||
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor?: string; darkBrandColor?: string }) => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
};
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
|
||||
const BookingFields = ({
|
||||
fields,
|
||||
@@ -76,6 +88,7 @@ const BookingFields = ({
|
||||
const { t } = useLocale();
|
||||
const { watch, setValue } = useFormContext();
|
||||
const locationResponse = watch("responses.location");
|
||||
const currentView = rescheduleUid ? "reschedule" : "";
|
||||
|
||||
return (
|
||||
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
|
||||
@@ -85,12 +98,16 @@ const BookingFields = ({
|
||||
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
||||
let readOnly =
|
||||
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
||||
|
||||
let noLabel = false;
|
||||
let hidden = !!field.hidden;
|
||||
const fieldViews = field.views;
|
||||
|
||||
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.rescheduleReason) {
|
||||
if (!rescheduleUid) {
|
||||
return null;
|
||||
}
|
||||
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
||||
readOnly = false;
|
||||
}
|
||||
@@ -113,7 +130,10 @@ const BookingFields = ({
|
||||
}
|
||||
|
||||
// We don't show `notes` field during reschedule
|
||||
if (field.name === SystemField.Enum.notes && !!rescheduleUid) {
|
||||
if (
|
||||
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
|
||||
!!rescheduleUid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -124,26 +144,14 @@ const BookingFields = ({
|
||||
}
|
||||
const optionsInputs = field.optionsInputs;
|
||||
|
||||
const options = locations.map((location) => {
|
||||
const eventLocation = getEventLocationType(location.type);
|
||||
const locationString = locationKeyToString(location);
|
||||
|
||||
if (typeof locationString !== "string" || !eventLocation) {
|
||||
// It's possible that location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
const type = eventLocation.type;
|
||||
const optionInput = optionsInputs[type as keyof typeof optionsInputs];
|
||||
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
|
||||
const options = getLocationOptionsForSelect(locations, t);
|
||||
options.forEach((option) => {
|
||||
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
|
||||
if (optionInput) {
|
||||
optionInput.placeholder = t(eventLocation?.attendeeInputPlaceholder || "");
|
||||
optionInput.placeholder = option.inputPlaceholder;
|
||||
}
|
||||
|
||||
return {
|
||||
label: t(locationString),
|
||||
value: type,
|
||||
};
|
||||
});
|
||||
|
||||
field.options = options.filter(
|
||||
(location): location is NonNullable<(typeof options)[number]> => !!location
|
||||
);
|
||||
@@ -175,9 +183,26 @@ const BookingFields = ({
|
||||
);
|
||||
};
|
||||
|
||||
const routerQuerySchema = z
|
||||
.object({
|
||||
timeFormat: z.nativeEnum(TimeFormat),
|
||||
rescheduleUid: z.string().optional(),
|
||||
date: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((date) => {
|
||||
if (date === undefined) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const BookingPage = ({
|
||||
eventType,
|
||||
booking,
|
||||
currentSlotBooking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
@@ -185,8 +210,11 @@ const BookingPage = ({
|
||||
hashedLink,
|
||||
...restProps
|
||||
}: BookingPageProps) => {
|
||||
const removeSelectedSlotMarkMutation = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation();
|
||||
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
|
||||
const { t, i18n } = useLocale();
|
||||
const { duration: queryDuration } = useRouterQuery("duration");
|
||||
const { date: queryDate } = useRouterQuery("date");
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
@@ -202,6 +230,17 @@ const BookingPage = ({
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
const reserveSlot = () => {
|
||||
if (queryDuration) {
|
||||
reserveSlotMutation.mutate({
|
||||
eventTypeId: eventType.id,
|
||||
slotUtcStartDate: dayjs(queryDate).utc().format(),
|
||||
slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(),
|
||||
bookingUid: currentSlotBooking?.uid,
|
||||
});
|
||||
}
|
||||
};
|
||||
// Define duration now that we support multiple duration eventTypes
|
||||
let duration = eventType.length;
|
||||
if (
|
||||
@@ -214,24 +253,30 @@ const BookingPage = ({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
/* if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
telemetryEventTypes.embedView,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
);
|
||||
}
|
||||
} */
|
||||
reserveSlot();
|
||||
const interval = setInterval(reserveSlot, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
removeSelectedSlotMarkMutation.mutate();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { uid, paymentUid } = responseData;
|
||||
const { uid } = responseData;
|
||||
|
||||
if (paymentUid) {
|
||||
if ("paymentUid" in responseData && !!responseData.paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
paymentUid,
|
||||
paymentUid: responseData.paymentUid,
|
||||
date,
|
||||
name: bookingForm.getValues("responses.name"),
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
@@ -240,14 +285,19 @@ const BookingPage = ({
|
||||
);
|
||||
}
|
||||
|
||||
return router.push({
|
||||
pathname: `/booking/${uid}`,
|
||||
query: {
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||
},
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
|
||||
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||
};
|
||||
|
||||
return bookingSuccessRedirect({
|
||||
router,
|
||||
successRedirectUrl: eventType.successRedirectUrl,
|
||||
query,
|
||||
bookingUid: uid,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -255,24 +305,37 @@ const BookingPage = ({
|
||||
const recurringMutation = useMutation(createRecurringBooking, {
|
||||
onSuccess: async (responseData = []) => {
|
||||
const { uid } = responseData[0] || {};
|
||||
|
||||
return router.push({
|
||||
pathname: `/booking/${uid}`,
|
||||
query: {
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
formerTime: booking?.startTime.toString(),
|
||||
},
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
formerTime: booking?.startTime.toString(),
|
||||
};
|
||||
return bookingSuccessRedirect({
|
||||
router,
|
||||
successRedirectUrl: eventType.successRedirectUrl,
|
||||
query,
|
||||
bookingUid: uid,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduleUid = router.query.rescheduleUid as string;
|
||||
const {
|
||||
data: { timeFormat, rescheduleUid, date },
|
||||
} = useTypedQuery(routerQuerySchema);
|
||||
|
||||
useTheme(profile.theme);
|
||||
const date = asStringOrNull(router.query.date);
|
||||
useBrandColors({
|
||||
brandColor: profile.brandColor,
|
||||
darkBrandColor: profile.darkBrandColor,
|
||||
});
|
||||
|
||||
const querySchema = getBookingResponsesPartialSchema({
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
eventType: {
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
},
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
|
||||
const parsedQuery = querySchema.parse({
|
||||
@@ -352,7 +415,8 @@ const BookingPage = ({
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
responses: getBookingResponsesSchema({
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -377,12 +441,13 @@ const BookingPage = ({
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
selectedTimeFormat: timeFormat,
|
||||
},
|
||||
i18n
|
||||
i18n.language
|
||||
);
|
||||
}
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
const bookEvent = (bookingValues: BookingFormValues) => {
|
||||
telemetry.event(
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
{ isTeamBooking: document.URL.includes("team/") }
|
||||
@@ -405,9 +470,9 @@ const BookingPage = ({
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(duration, "minute").format(),
|
||||
...bookingValues,
|
||||
start: dayjs(recurringDate).utc().format(),
|
||||
end: dayjs(recurringDate).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
@@ -425,20 +490,21 @@ const BookingPage = ({
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
start: dayjs(date).tz(timeZone()).format(),
|
||||
end: dayjs(date).tz(timeZone()).add(duration, "minute").format(),
|
||||
...bookingValues,
|
||||
start: dayjs(date).utc().format(),
|
||||
end: dayjs(date).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
bookingUid: router.query.bookingUid as string,
|
||||
bookingUid: (router.query.bookingUid as string) || booking?.uid,
|
||||
user: router.query.user,
|
||||
metadata,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
ethSignature: gateState.rainbowToken,
|
||||
seatReferenceUid: router.query.seatReferenceUid as string,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -472,7 +538,6 @@ const BookingPage = ({
|
||||
<link rel="icon" href="/favico.ico" />
|
||||
</Head>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
@@ -482,17 +547,17 @@ const BookingPage = ({
|
||||
<div
|
||||
className={classNames(
|
||||
"main",
|
||||
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 bg-white dark:border",
|
||||
"dark:border-darkgray-300 rounded-md sm:border"
|
||||
isBackgroundTransparent ? "" : "bg-default dark:bg-muted",
|
||||
"border-booker sm:border-booker-width rounded-md"
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
{showEventTypeDetails && (
|
||||
<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">
|
||||
<div className="sm:border-subtle text-default flex flex-col px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
<BookingDescriptionPayment eventType={eventType} />
|
||||
<BookingDescriptionPayment eventType={eventType} t={t} i18n={i18n} />
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||
<FiRefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<div className="text-default items-start text-sm font-medium">
|
||||
<RefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
@@ -503,11 +568,11 @@ const BookingPage = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<FiCalendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<Calendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<div className="text-sm font-medium">
|
||||
{isClientTimezoneAvailable &&
|
||||
(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||
`${parseDate(date, i18n)}`}
|
||||
`${parseDate(date, i18n.language, { selectedTimeFormat: timeFormat })}`}
|
||||
{isClientTimezoneAvailable &&
|
||||
!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
@@ -520,7 +585,7 @@ const BookingPage = ({
|
||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className="dark:text-darkgray-600 text-sm">
|
||||
<p className=" text-sm">
|
||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
@@ -534,34 +599,40 @@ const BookingPage = ({
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<Calendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{isClientTimezoneAvailable &&
|
||||
typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
parseDate(dayjs(booking.startTime), i18n.language, {
|
||||
selectedTimeFormat: timeFormat,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<FiUser
|
||||
<User
|
||||
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
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{booking
|
||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||
{currentSlotBooking
|
||||
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
@@ -599,13 +670,14 @@ const BookingPage = ({
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
{(mutation.isError || recurringMutation.isError) && (
|
||||
{mutation.isError || recurringMutation.isError ? (
|
||||
<ErrorMessage error={mutation.error || recurringMutation.error} />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Toaster position="bottom-right" />
|
||||
</Gates>
|
||||
);
|
||||
};
|
||||
@@ -615,17 +687,28 @@ export default BookingPage;
|
||||
function ErrorMessage({ error }: { error: unknown }) {
|
||||
const { t } = useLocale();
|
||||
const { query: { rescheduleUid } = {} } = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div data-testid="booking-fail" className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||
<div data-testid="booking-fail" className="mt-2 border-l-4 border-blue-400 bg-blue-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<FiAlertTriangle className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
<AlertTriangle className="h-5 w-5 text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
<div className="ms-3">
|
||||
<p className="text-sm text-blue-700">
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||
{error instanceof HttpError || error instanceof Error ? t(error.message) : "Unknown error"}
|
||||
{error instanceof HttpError || error instanceof Error ? (
|
||||
<>
|
||||
{t("can_you_try_again")}{" "}
|
||||
<span className="cursor-pointer underline" onClick={() => router.back()}>
|
||||
{t("go_back")}
|
||||
</span>
|
||||
.
|
||||
</> /* t(error.message) */
|
||||
) : (
|
||||
"Unknown error"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { CreditCard, AlertTriangle } from "@calcom/ui/components/icon";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
bookingId: number;
|
||||
paymentAmount: number;
|
||||
paymentCurrency: string;
|
||||
}
|
||||
|
||||
export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
|
||||
const [chargeError, setChargeError] = useState(false);
|
||||
const chargeCardMutation = trpc.viewer.payments.chargeCard.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.bookings.invalidate();
|
||||
setIsOpenDialog(false);
|
||||
showToast("Charge successful", "success");
|
||||
},
|
||||
onError: () => {
|
||||
setChargeError(true);
|
||||
},
|
||||
});
|
||||
|
||||
const currencyStringParams = {
|
||||
amount: props.paymentAmount / 100.0,
|
||||
formatParams: { amount: { currency: props.paymentCurrency } },
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<CreditCard className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("charge_card")} />
|
||||
<p>{t("charge_card_dialog_body", currencyStringParams)}</p>
|
||||
|
||||
{chargeError && (
|
||||
<div className="mt-4 flex text-red-500">
|
||||
<AlertTriangle className="mr-2 h-5 w-5 " aria-hidden="true" />
|
||||
<p className="text-sm">{t("error_charging_card")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={chargeCardMutation.isLoading || chargeError}
|
||||
onClick={() =>
|
||||
chargeCardMutation.mutate({
|
||||
bookingId,
|
||||
})
|
||||
}>
|
||||
{t("charge_attendee", currencyStringParams)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Input } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui";
|
||||
import { FiMapPin } from "@calcom/ui/components/icon";
|
||||
import { MapPin } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -32,6 +33,7 @@ interface ISetLocationDialog {
|
||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||
selection?: LocationOption;
|
||||
booking?: BookingItem;
|
||||
isTeamEvent?: boolean;
|
||||
defaultValues?: LocationObject[];
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
@@ -52,7 +54,7 @@ const LocationInput = (props: {
|
||||
const { control } = useFormContext() as typeof locationFormMethods;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
return (
|
||||
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
<Input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
return (
|
||||
@@ -73,6 +75,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
saveLocation,
|
||||
selection,
|
||||
booking,
|
||||
isTeamEvent,
|
||||
setShowLocationModal,
|
||||
isOpenDialog,
|
||||
defaultValues,
|
||||
@@ -161,7 +164,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="locationInput" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="locationInput" className="text-default block text-sm font-medium">
|
||||
{t(eventLocationType.messageForOrganizer || "")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -171,7 +174,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
id="locationInput"
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
defaultValue={
|
||||
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
|
||||
@@ -180,7 +182,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
<ErrorMessage
|
||||
errors={locationFormMethods.formState.errors}
|
||||
name={eventLocationType.variable}
|
||||
className="mt-1 text-sm text-red-500"
|
||||
className="text-error mt-1 text-sm"
|
||||
as="p"
|
||||
/>
|
||||
</div>
|
||||
@@ -206,7 +208,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <p className="text-sm">{getMessageForOrganizer(selectedLocation, t)}</p>;
|
||||
return <p className="text-default text-sm">{getMessageForOrganizer(selectedLocation, t)}</p>;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -214,16 +216,16 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
<Dialog open={isOpenDialog} onOpenChange={(open) => setShowLocationModal(open)}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<FiMapPin className="text-primary-600 h-6 w-6" />
|
||||
<div className="bg-subtle 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">
|
||||
<MapPin className="text-emphasis h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<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-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||
{t("edit_location")}
|
||||
</h3>
|
||||
{!booking && (
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-default text-sm">
|
||||
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||
Can't find the right video app? Visit our
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||
@@ -238,8 +240,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
|
||||
{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">
|
||||
<p className="text-emphasis mt-6 mb-2 ml-1 text-sm font-bold">{t("current_location")}:</p>
|
||||
<p className="text-emphasis mb-2 ml-1 text-sm">
|
||||
{getHumanReadableLocationValue(booking.location, t)}
|
||||
</p>
|
||||
</>
|
||||
@@ -290,7 +292,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
query={locationsQuery}
|
||||
success={({ data }) => {
|
||||
if (!data.length) return null;
|
||||
const locationOptions = [...data];
|
||||
const locationOptions = [...data].filter((option) => {
|
||||
return !isTeamEvent ? option.label !== "Conferencing" : true;
|
||||
});
|
||||
if (booking) {
|
||||
locationOptions.map((location) =>
|
||||
location.options.filter((l) => !["phone", "attendeeInPerson"].includes(l.value))
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
showToast,
|
||||
TextArea,
|
||||
} from "@calcom/ui";
|
||||
import { FiClock } from "@calcom/ui/components/icon";
|
||||
import { Clock } from "@calcom/ui/components/icon";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
@@ -44,14 +44,14 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<FiClock className="m-auto h-6 w-6" />
|
||||
<Clock className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("send_reschedule_request")} />
|
||||
<p className="text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||
<p className="text-subtle text-sm">{t("reschedule_modal_description")}</p>
|
||||
<p className="text-emphasis mt-6 mb-2 text-sm font-bold">
|
||||
{t("reason_for_reschedule_request")}
|
||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</p>
|
||||
<TextArea
|
||||
data-testid="reschedule_reason"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
type Props = {
|
||||
statusCode?: number | null;
|
||||
@@ -29,15 +29,15 @@ const ErrorDebugPanel: React.FC<{ error: Props["error"]; children?: never }> = (
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden bg-white shadow sm:rounded-lg">
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||
<dl className="sm:divide-y sm:divide-gray-200">
|
||||
<div className="bg-default overflow-hidden shadow sm:rounded-lg">
|
||||
<div className="border-subtle border-t px-4 py-5 sm:p-0">
|
||||
<dl className="sm:divide-subtle sm:divide-y">
|
||||
{debugMap.map(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
return (
|
||||
<div key={key} className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6">
|
||||
<dt className="text-sm font-bold text-black">{key}</dt>
|
||||
<dd className="mt-1 text-sm text-black sm:col-span-2 sm:mt-0">{value}</dd>
|
||||
<dt className="text-emphasis text-sm font-bold">{key}</dt>
|
||||
<dd className="text-emphasis mt-1 text-sm sm:col-span-2 sm:mt-0">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,11 +53,11 @@ export const ErrorPage: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-white px-4">
|
||||
<div className="bg-default min-h-screen px-4">
|
||||
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-black">{statusCode}</p>
|
||||
<h1 className="mt-2 text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl">
|
||||
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">{statusCode}</p>
|
||||
<h1 className="text-emphasis mt-2 text-4xl font-extrabold tracking-tight sm:text-5xl">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { OptionProps, SingleValueProps } from "react-select";
|
||||
import { components } from "react-select";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
||||
import { FiExternalLink, FiGlobe } from "@calcom/ui/components/icon";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
|
||||
type AvailabilityOption = {
|
||||
label: string;
|
||||
value: number;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
const { label, isDefault } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||
const { label, isDefault } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.SingleValue {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.SingleValue>
|
||||
);
|
||||
};
|
||||
|
||||
const AvailabilitySelect = ({
|
||||
className = "",
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
name: string;
|
||||
value: number;
|
||||
onBlur: () => void;
|
||||
onChange: (value: AvailabilityOption | null) => void;
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isLoading) {
|
||||
return <SelectSkeletonLoader />;
|
||||
}
|
||||
|
||||
const schedules = data?.schedules || [];
|
||||
|
||||
const options = schedules.map((schedule) => ({
|
||||
value: schedule.id,
|
||||
label: schedule.name,
|
||||
isDefault: schedule.isDefault,
|
||||
}));
|
||||
|
||||
const value = options.find((option) =>
|
||||
props.value
|
||||
? option.value === props.value
|
||||
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
onChange={props.onChange}
|
||||
className={classNames("block w-full min-w-0 flex-1 rounded-sm text-sm", className)}
|
||||
value={value}
|
||||
components={{ Option, SingleValue }}
|
||||
isMulti={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const format = (date: Date, hour12: boolean) =>
|
||||
Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "numeric", hour12 }).format(
|
||||
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
|
||||
);
|
||||
|
||||
const EventTypeScheduleDetails = () => {
|
||||
const { data: loggedInUser } = useMeQuery();
|
||||
const timeFormat = loggedInUser?.timeFormat;
|
||||
const { t, i18n } = useLocale();
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
|
||||
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
|
||||
);
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded border px-6 pb-4">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32",
|
||||
!isAvailable && "text-gray-500 opacity-50"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<hr />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
{!!schedule?.id && (
|
||||
<Button
|
||||
href={`/availability/${schedule.id}`}
|
||||
disabled={isLoading}
|
||||
color="minimal"
|
||||
EndIcon={FiExternalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeSchedule = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="availability" className="mb-2 block text-sm font-medium leading-none text-gray-700">
|
||||
{t("availability")}
|
||||
</label>
|
||||
<Controller
|
||||
name="schedule"
|
||||
render={({ field }) => (
|
||||
<AvailabilitySelect
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<EventTypeScheduleDetails />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UseCommonScheduleSettingsToggle = () => {
|
||||
const { t } = useLocale();
|
||||
const { resetField, setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<Controller
|
||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
checked={!value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(!checked);
|
||||
if (checked) {
|
||||
resetField("schedule");
|
||||
} else {
|
||||
setValue("schedule", null);
|
||||
}
|
||||
}}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
<EventTypeSchedule />
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
return isTeamEvent ? <UseCommonScheduleSettingsToggle /> : <EventTypeSchedule />;
|
||||
};
|
||||
@@ -52,40 +52,41 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
type="text"
|
||||
placeholder={placeHolder_}
|
||||
{...register("customEventName", {
|
||||
validate: (value) => validateCustomEventName(value, t("invalid_event_name_variables")),
|
||||
validate: (value) =>
|
||||
validateCustomEventName(value, t("invalid_event_name_variables"), event.bookingFields),
|
||||
})}
|
||||
className="mb-0"
|
||||
/>
|
||||
<div className="pt-6 text-sm">
|
||||
<div className="mb-6 rounded-md bg-gray-100 p-2">
|
||||
<h1 className="mb-2 ml-1 font-medium text-gray-900">{t("available_variables")}</h1>
|
||||
<div className="bg-subtle mb-6 rounded-md p-2">
|
||||
<h1 className="text-emphasis mb-2 ml-1 font-medium">{t("available_variables")}</h1>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Event type title}`}</p>
|
||||
<p className="text-gray-900">{t("event_name_info")}</p>
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Event type title}`}</p>
|
||||
<p className="text-emphasis">{t("event_name_info")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Organiser}`}</p>
|
||||
<p className="text-gray-900">{t("your_full_name")}</p>
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser}`}</p>
|
||||
<p className="text-emphasis">{t("your_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Scheduler}`}</p>
|
||||
<p className="text-gray-900">{t("scheduler_full_name")}</p>
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler}`}</p>
|
||||
<p className="text-emphasis">{t("scheduler_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Location}`}</p>
|
||||
<p className="text-gray-900">{t("location_info")}</p>
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
|
||||
<p className="text-emphasis">{t("location_info")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||
<div
|
||||
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center"
|
||||
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center dark:invert"
|
||||
style={{
|
||||
backgroundImage: "url(/calendar-preview.svg)",
|
||||
}}>
|
||||
<div className="m-auto flex items-center justify-center self-stretch">
|
||||
<div className="mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black bg-gray-100 text-[12px] leading-3">
|
||||
<p className="overflow-hidden text-ellipsis p-1.5 font-medium text-gray-900">{previewText}</p>
|
||||
<p className="ml-1.5 text-[10px] font-normal text-gray-600">8 - 10 AM</p>
|
||||
<div className="bg-subtle mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black text-[12px] leading-3">
|
||||
<p className="text-emphasis overflow-hidden text-ellipsis p-1.5 font-medium">{previewText}</p>
|
||||
<p className="text-default ml-1.5 text-[10px] font-normal">8 - 10 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,21 @@ import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge, Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip } from "@calcom/ui";
|
||||
import { FiEdit, FiCopy } from "@calcom/ui/components/icon";
|
||||
import { Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip, Alert } from "@calcom/ui";
|
||||
import { Edit, Copy } from "@calcom/ui/components/icon";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
@@ -36,17 +44,28 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
|
||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
||||
|
||||
eventType.bookingFields.forEach(({ name }) => {
|
||||
bookingFields[name] = name + " input";
|
||||
});
|
||||
|
||||
const eventNameObject: EventNameObjectType = {
|
||||
attendeeName: t("scheduler"),
|
||||
eventType: eventType.title,
|
||||
eventName: eventType.eventName,
|
||||
host: eventType.users[0]?.name || "Nameless",
|
||||
bookingFields: bookingFields,
|
||||
t,
|
||||
};
|
||||
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
const noShowFeeEnabled = eventType.metadata.apps?.stripe?.paymentOption === "HOLD";
|
||||
|
||||
useEffect(() => {
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||
@@ -68,11 +87,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
);
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const eventNamePlaceholder = getEventName({
|
||||
...eventNameObject,
|
||||
eventName: formMethods.watch("eventName"),
|
||||
});
|
||||
|
||||
const successRedirectUrlLocked = shouldLockDisableProps("successRedirectUrl");
|
||||
const seatsLocked = shouldLockDisableProps("seatsPerTimeSlotEnabled");
|
||||
|
||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||
|
||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||
@@ -90,7 +117,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
className="text-sm text-gray-600 hover:text-gray-900">
|
||||
className="hover:text-emphasis text-default text-sm">
|
||||
{t("add_another_calendar")}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -109,44 +136,51 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{t("select_which_cal")}</p>
|
||||
<p className="text-default text-sm">{t("select_which_cal")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
type="button"
|
||||
StartIcon={FiEdit}
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
className="hover:stroke-3 min-w-fit px-0 hover:bg-transparent hover:text-black"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
/>
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 !py-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
seatsEnabled={seatsEnabled}
|
||||
metadata={eventType.metadata}
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
control={formMethods.control}
|
||||
@@ -154,13 +188,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("disable_notes")}
|
||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||
description={t("disable_notes_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
@@ -168,48 +203,43 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
<>
|
||||
<SettingsToggle
|
||||
title={t("redirect_success_booking")}
|
||||
{...successRedirectUrlLocked}
|
||||
description={t("redirect_url_description")}
|
||||
checked={redirectUrlVisible}
|
||||
onCheckedChange={(e) => {
|
||||
setRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||
<div className="lg:-ml-2">
|
||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
||||
<div className="lg:-ml-2 lg:-mb-2">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label={t("redirect_success_booking")}
|
||||
labelSrOnly
|
||||
disabled={successRedirectUrlLocked.disabled}
|
||||
placeholder={t("external_redirect_url")}
|
||||
required={redirectUrlVisible}
|
||||
type="text"
|
||||
defaultValue={eventType.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
|
||||
className={classNames(
|
||||
"p-1 text-sm text-orange-600",
|
||||
formMethods.getValues("successRedirectUrl") ? "block" : "hidden"
|
||||
)}>
|
||||
{t("redirect_url_warning")}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<SettingsToggle
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("private_link")}
|
||||
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
onCheckedChange={(e) => {
|
||||
@@ -231,6 +261,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 !py-0 hover:bg-transparent"
|
||||
aria-label="copy link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (eventType.hashedLink) {
|
||||
@@ -238,77 +272,127 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}
|
||||
className="hover:stroke-3 hover:bg-transparent hover:text-black"
|
||||
type="button">
|
||||
<FiCopy />
|
||||
}}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("offer_seats")}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
toggleGuests(true);
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
description={t("show_attendees")}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
<>
|
||||
<SettingsToggle
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
{...seatsLocked}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
disabled={noShowFeeEnabled}
|
||||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
toggleGuests(true);
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
description={t("show_attendees")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
title={t("disable_attendees_confirmation_emails")}
|
||||
description={t("disable_attendees_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("metadata.disableStandardEmails.confirmation.attendee", e);
|
||||
onChange(e);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.host"
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
title={t("disable_host_confirmation_emails")}
|
||||
description={t("disable_host_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("metadata.disableStandardEmails.confirmation.host", e);
|
||||
onChange(e);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showEventNameTip && (
|
||||
<CustomEventTypeModal
|
||||
close={closeEventNameTip}
|
||||
setValue={setEventName}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
defaultValue={formMethods.getValues("eventName") || eventType.eventName || ""}
|
||||
placeHolder={eventNamePlaceholder}
|
||||
event={eventNameObject}
|
||||
/>
|
||||
|
||||
@@ -5,10 +5,11 @@ import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppConte
|
||||
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
|
||||
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, EmptyScreen } from "@calcom/ui";
|
||||
import { FiGrid } from "@calcom/ui/components/icon";
|
||||
import { Button, EmptyScreen, Alert } from "@calcom/ui";
|
||||
import { Grid, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||
EventTypeAppCardComponentProps["eventType"];
|
||||
@@ -55,19 +56,39 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="before:border-0">
|
||||
{!installedApps?.length && isManagedEventType && (
|
||||
<Alert
|
||||
severity="neutral"
|
||||
className="mb-2"
|
||||
title={t("locked_for_members")}
|
||||
message={t("locked_apps_description")}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !installedApps?.length ? (
|
||||
<EmptyScreen
|
||||
Icon={FiGrid}
|
||||
Icon={Grid}
|
||||
headline={t("empty_installed_apps_headline")}
|
||||
description={t("empty_installed_apps_description")}
|
||||
buttonRaw={
|
||||
<Button target="_blank" color="secondary" href="/apps">
|
||||
{t("empty_installed_apps_button")}{" "}
|
||||
</Button>
|
||||
isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon={Lock} color="secondary" disabled>
|
||||
{t("locked_by_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button target="_blank" color="secondary" href="/apps">
|
||||
{t("empty_installed_apps_button")}{" "}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@@ -82,22 +103,24 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{!isLoading && notInstalledApps?.length ? (
|
||||
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">Available Apps</h2>
|
||||
) : null}
|
||||
<div className="before:border-0">
|
||||
{notInstalledApps?.map((app) => (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
/>
|
||||
))}
|
||||
{!shouldLockDisableProps("apps").disabled && (
|
||||
<div>
|
||||
{!isLoading && notInstalledApps?.length ? (
|
||||
<h2 className="text-emphasis my-2 text-lg font-semibold">{t("available_apps")}</h2>
|
||||
) : null}
|
||||
<div className="before:border-0">
|
||||
{notInstalledApps?.map((app) => (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { OptionProps, SingleValueProps } from "react-select";
|
||||
import { components } from "react-select";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
||||
import { ExternalLink, Globe } from "@calcom/ui/components/icon";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
|
||||
export type AvailabilityOption = {
|
||||
label: string;
|
||||
value: number;
|
||||
isDefault: boolean;
|
||||
isManaged?: boolean;
|
||||
};
|
||||
|
||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
const { label, isDefault, isManaged = false } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
{isManaged && (
|
||||
<Badge variant="gray" className="ml-2">
|
||||
{t("managed")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||
const { label, isDefault, isManaged = false } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.SingleValue {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
{isManaged && (
|
||||
<Badge variant="gray" className="ml-2">
|
||||
{t("managed")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.SingleValue>
|
||||
);
|
||||
};
|
||||
|
||||
const format = (date: Date, hour12: boolean) =>
|
||||
Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hourCycle: hour12 ? "h12" : "h24",
|
||||
}).format(new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss")));
|
||||
|
||||
const EventTypeScheduleDetails = ({
|
||||
isManagedEventType,
|
||||
selectedScheduleValue,
|
||||
}: {
|
||||
isManagedEventType: boolean;
|
||||
selectedScheduleValue: AvailabilityOption | undefined;
|
||||
}) => {
|
||||
const { data: loggedInUser } = useMeQuery();
|
||||
const timeFormat = loggedInUser?.timeFormat;
|
||||
const { t, i18n } = useLocale();
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{
|
||||
scheduleId: scheduleId || loggedInUser?.defaultScheduleId || selectedScheduleValue?.value || undefined,
|
||||
isManagedEventType,
|
||||
},
|
||||
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId || !!selectedScheduleValue }
|
||||
);
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
|
||||
return (
|
||||
<div className="border-default space-y-4 rounded border px-6 pb-4">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<hr className="border-subtle" />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
{!!schedule?.id && !schedule.isManaged && (
|
||||
<Button
|
||||
href={`/availability/${schedule.id}`}
|
||||
disabled={isLoading}
|
||||
color="minimal"
|
||||
EndIcon={ExternalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const { shouldLockIndicator, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
const watchSchedule = watch("schedule");
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
||||
|
||||
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
|
||||
onSuccess: ({ schedules }) => {
|
||||
const options = schedules.map((schedule) => ({
|
||||
value: schedule.id,
|
||||
label: schedule.name,
|
||||
isDefault: schedule.isDefault,
|
||||
isManaged: false,
|
||||
}));
|
||||
|
||||
// We are showing a managed event for a team admin, so adding the option to let members choose their schedule
|
||||
if (isManagedEventType) {
|
||||
options.push({
|
||||
value: 0,
|
||||
label: t("members_default_schedule"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
// We are showing a managed event for a member and team owner selected their own schedule, so adding
|
||||
// the managed schedule option
|
||||
if (
|
||||
isChildrenManagedEventType &&
|
||||
watchSchedule &&
|
||||
!schedules.find((schedule) => schedule.id === watchSchedule)
|
||||
) {
|
||||
options.push({
|
||||
value: watchSchedule,
|
||||
label: eventType.scheduleName ?? t("default_schedule_name"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(options);
|
||||
|
||||
const scheduleId = formMethods.getValues("schedule");
|
||||
const value = options.find((option) =>
|
||||
scheduleId
|
||||
? option.value === scheduleId
|
||||
: isManagedEventType
|
||||
? option.value === 0
|
||||
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
||||
);
|
||||
|
||||
formMethods.setValue("availability", value);
|
||||
},
|
||||
});
|
||||
|
||||
const availabilityValue = formMethods.watch("availability");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||
{t("availability")}
|
||||
{shouldLockIndicator("availability")}
|
||||
</label>
|
||||
{isLoading && <SelectSkeletonLoader />}
|
||||
{!isLoading && (
|
||||
<Controller
|
||||
name="schedule"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
if (selected?.value) formMethods.setValue("availability", selected);
|
||||
}}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
value={availabilityValue}
|
||||
components={{ Option, SingleValue }}
|
||||
isMulti={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{availabilityValue?.value !== 0 ? (
|
||||
<EventTypeScheduleDetails
|
||||
selectedScheduleValue={availabilityValue}
|
||||
isManagedEventType={isManagedEventType || isChildrenManagedEventType}
|
||||
/>
|
||||
) : (
|
||||
isManagedEventType && (
|
||||
<p className="!mt-2 ml-1 text-sm text-gray-600">{t("members_default_schedule_description")}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const { resetField, setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<Controller
|
||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
checked={!value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(!checked);
|
||||
if (checked) {
|
||||
resetField("schedule");
|
||||
} else {
|
||||
setValue("schedule", null);
|
||||
}
|
||||
}}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
<EventTypeSchedule eventType={eventType} />
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventAvailabilityTab = ({
|
||||
eventType,
|
||||
isTeamEvent,
|
||||
}: {
|
||||
eventType: EventTypeSetup;
|
||||
isTeamEvent: boolean;
|
||||
}) => {
|
||||
return isTeamEvent && eventType.schedulingType !== SchedulingType.MANAGED ? (
|
||||
<UseCommonScheduleSettingsToggle eventType={eventType} />
|
||||
) : (
|
||||
<EventTypeSchedule eventType={eventType} />
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { Key } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { PeriodType } from "@calcom/prisma/client";
|
||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
import { FiPlus, FiTrash } from "@calcom/ui/components/icon";
|
||||
import type { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
import { Plus, Trash } from "@calcom/ui/components/icon";
|
||||
|
||||
const MinimumBookingNoticeInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
@@ -67,6 +70,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||
<div className="w-1/2 md:w-full">
|
||||
<InputField
|
||||
required
|
||||
disabled={passThroughProps.disabled}
|
||||
defaultValue={minimumBookingNoticeDisplayValues.value}
|
||||
onChange={(e) =>
|
||||
setMinimumBookingNoticeDisplayValues({
|
||||
@@ -77,12 +81,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||
</div>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={passThroughProps.disabled}
|
||||
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
|
||||
@@ -102,7 +108,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||
});
|
||||
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const PERIOD_TYPES = [
|
||||
@@ -134,12 +140,43 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
defaultValue: periodType?.type,
|
||||
});
|
||||
|
||||
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
|
||||
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
|
||||
const periodTypeLocked = shouldLockDisableProps("periodType");
|
||||
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
|
||||
|
||||
const optionsPeriod = [
|
||||
{ value: 1, label: t("calendar_days") },
|
||||
{ value: 0, label: t("business_days") },
|
||||
];
|
||||
|
||||
// offsetStart toggle is client-side only, opened by default if offsetStart is set
|
||||
const offsetStartValue = useWatch({
|
||||
control: formMethods.control,
|
||||
name: "offsetStart",
|
||||
});
|
||||
const [offsetToggle, setOffsetToggle] = useState(() => offsetStartValue > 0);
|
||||
|
||||
// Preview how the offset will affect start times
|
||||
const offsetOriginalTime = new Date();
|
||||
offsetOriginalTime.setHours(9, 0, 0, 0);
|
||||
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4 lg:space-y-8">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
{t("before_event")}
|
||||
{shouldLockIndicator("bookingLimits")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="beforeBufferTime"
|
||||
control={formMethods.control}
|
||||
@@ -158,6 +195,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
@@ -171,7 +209,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
|
||||
<Label htmlFor="afterBufferTime">
|
||||
{t("after_event")}
|
||||
{shouldLockIndicator("bookingLimits")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="afterBufferTime"
|
||||
control={formMethods.control}
|
||||
@@ -190,6 +231,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
@@ -205,11 +247,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
||||
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||
<Label htmlFor="minimumBookingNotice">
|
||||
{t("minimum_booking_notice")}
|
||||
{shouldLockIndicator("minimumBookingNotice")}
|
||||
</Label>
|
||||
<MinimumBookingNoticeInput
|
||||
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
|
||||
{...formMethods.register("minimumBookingNotice")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
||||
<Label htmlFor="slotInterval">
|
||||
{t("slot_interval")}
|
||||
{shouldLockIndicator("slotInterval")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="slotInterval"
|
||||
control={formMethods.control}
|
||||
@@ -227,6 +278,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("slotInterval").disabled}
|
||||
onChange={(val) => {
|
||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||
}}
|
||||
@@ -242,13 +294,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
@@ -260,11 +313,45 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}>
|
||||
<BookingLimits />
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="durationLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="periodType"
|
||||
control={formMethods.control}
|
||||
@@ -272,49 +359,64 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
<SettingsToggle
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={value && value !== "UNLIMITED"}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.map((period) => {
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"mb-2 flex flex-wrap items-center text-sm",
|
||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
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>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex h-9">
|
||||
<Input
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="block w-16 rounded-md border-gray-300 py-3 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
<select
|
||||
id=""
|
||||
className="block h-9 w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
||||
{...formMethods.register("periodCountCalendarDays")}
|
||||
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
|
||||
<option value="1">{t("calendar_days")}</option>
|
||||
<option value="0">{t("business_days")}</option>
|
||||
</select>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
opt?.value.toString() as "0" | "1"
|
||||
);
|
||||
}}
|
||||
defaultValue={
|
||||
optionsPeriod.find(
|
||||
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
||||
) ?? optionsPeriod[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="inline-flex space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
|
||||
<div className="ms-2 me-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
@@ -323,6 +425,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
@@ -334,9 +437,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? (
|
||||
<span className="ltr:ml-2 ltr:mr-2 rtl:ml-2"> {period.suffix}</span>
|
||||
) : null}
|
||||
{period.suffix ? <span className="ms-2 me-2"> {period.suffix}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -344,128 +445,196 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<SettingsToggle
|
||||
title={t("offset_toggle")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
checked={offsetToggle}
|
||||
onCheckedChange={(active) => {
|
||||
setOffsetToggle(active);
|
||||
if (!active) {
|
||||
formMethods.setValue("offsetStart", 0);
|
||||
}
|
||||
}}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
defaultValue={eventType.offsetStart}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
|
||||
type BookingLimitsKey = keyof BookingLimit;
|
||||
const BookingLimits = () => {
|
||||
type IntervalLimitsKey = keyof IntervalLimit;
|
||||
|
||||
const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
|
||||
|
||||
const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
|
||||
value: key as keyof IntervalLimit,
|
||||
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
|
||||
}));
|
||||
|
||||
type IntervalLimitItemProps = {
|
||||
key: Key;
|
||||
limitKey: IntervalLimitsKey;
|
||||
step: number;
|
||||
value: number;
|
||||
textFieldSuffix?: string;
|
||||
disabled?: boolean;
|
||||
selectOptions: { value: keyof IntervalLimit; label: string }[];
|
||||
hasDeleteButton?: boolean;
|
||||
onDelete: (intervalLimitsKey: IntervalLimitsKey) => void;
|
||||
onLimitChange: (intervalLimitsKey: IntervalLimitsKey, limit: number) => void;
|
||||
onIntervalSelect: (interval: SingleValue<{ value: keyof IntervalLimit; label: string }>) => void;
|
||||
};
|
||||
|
||||
const IntervalLimitItem = ({
|
||||
limitKey,
|
||||
step,
|
||||
value,
|
||||
textFieldSuffix,
|
||||
selectOptions,
|
||||
hasDeleteButton,
|
||||
disabled,
|
||||
onDelete,
|
||||
onLimitChange,
|
||||
onIntervalSelect,
|
||||
}: IntervalLimitItemProps) => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||
className="mb-0 !h-auto"
|
||||
placeholder={`${value}`}
|
||||
disabled={disabled}
|
||||
min={step}
|
||||
step={step}
|
||||
defaultValue={value}
|
||||
addOnSuffix={textFieldSuffix}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
|
||||
/>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
isSearchable={false}
|
||||
isDisabled={disabled}
|
||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||
onChange={onIntervalSelect}
|
||||
/>
|
||||
{hasDeleteButton && !disabled && (
|
||||
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type IntervalLimitsManagerProps<K extends "durationLimits" | "bookingLimits"> = {
|
||||
propertyName: K;
|
||||
defaultLimit: number;
|
||||
step: number;
|
||||
textFieldSuffix?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||
propertyName,
|
||||
defaultLimit,
|
||||
step,
|
||||
textFieldSuffix,
|
||||
disabled,
|
||||
}: IntervalLimitsManagerProps<K>) => {
|
||||
const { watch, setValue, control } = useFormContext<FormValues>();
|
||||
const watchBookingLimits = watch("bookingLimits");
|
||||
const watchIntervalLimits = watch(propertyName);
|
||||
const { t } = useLocale();
|
||||
|
||||
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
const BOOKING_LIMIT_OPTIONS: {
|
||||
value: keyof BookingLimit;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "PER_DAY",
|
||||
label: "Per Day",
|
||||
},
|
||||
{
|
||||
value: "PER_WEEK",
|
||||
label: "Per Week",
|
||||
},
|
||||
{
|
||||
value: "PER_MONTH",
|
||||
label: "Per Month",
|
||||
},
|
||||
{
|
||||
value: "PER_YEAR",
|
||||
label: "Per Year",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
name={propertyName}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const currentBookingLimits = value;
|
||||
const currentIntervalLimits = value;
|
||||
|
||||
const addLimit = () => {
|
||||
if (!currentIntervalLimits || !watchIntervalLimits) return;
|
||||
const currentKeys = Object.keys(watchIntervalLimits);
|
||||
|
||||
const [rest] = Object.values(INTERVAL_LIMIT_OPTIONS).filter(
|
||||
(option) => !currentKeys.includes(option.value)
|
||||
);
|
||||
if (!rest || !currentKeys.length) return;
|
||||
//currentDurationLimits is always defined so can be casted
|
||||
// @ts-expect-error FIXME Fix these typings
|
||||
setValue(propertyName, {
|
||||
...watchIntervalLimits,
|
||||
[rest.value]: defaultLimit,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ul ref={animateRef}>
|
||||
{currentBookingLimits &&
|
||||
watchBookingLimits &&
|
||||
Object.entries(currentBookingLimits)
|
||||
.sort(([limitkeyA], [limitKeyB]) => {
|
||||
{currentIntervalLimits &&
|
||||
watchIntervalLimits &&
|
||||
Object.entries(currentIntervalLimits)
|
||||
.sort(([limitKeyA], [limitKeyB]) => {
|
||||
return (
|
||||
validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) -
|
||||
validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey)
|
||||
intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, bookingAmount]) => {
|
||||
const bookingLimitKey = key as BookingLimitsKey;
|
||||
.map(([key, value]) => {
|
||||
const limitKey = key as IntervalLimitsKey;
|
||||
return (
|
||||
<div
|
||||
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse"
|
||||
key={bookingLimitKey}>
|
||||
<Input
|
||||
id={`${bookingLimitKey}-limit`}
|
||||
type="number"
|
||||
className="mb-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||
placeholder="1"
|
||||
min={1}
|
||||
defaultValue={bookingAmount}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={BOOKING_LIMIT_OPTIONS.filter(
|
||||
(option) => !Object.keys(currentBookingLimits).includes(option.value)
|
||||
)}
|
||||
isSearchable={false}
|
||||
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
||||
onChange={(val) => {
|
||||
const current = currentBookingLimits;
|
||||
const currentValue = watchBookingLimits[bookingLimitKey];
|
||||
<IntervalLimitItem
|
||||
key={key}
|
||||
limitKey={limitKey}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
textFieldSuffix={textFieldSuffix}
|
||||
hasDeleteButton={Object.keys(currentIntervalLimits).length > 1}
|
||||
selectOptions={INTERVAL_LIMIT_OPTIONS.filter(
|
||||
(option) => !Object.keys(currentIntervalLimits).includes(option.value)
|
||||
)}
|
||||
onLimitChange={(intervalLimitKey, val) =>
|
||||
// @ts-expect-error FIXME Fix these typings
|
||||
setValue(`${propertyName}.${intervalLimitKey}`, val)
|
||||
}
|
||||
onDelete={(intervalLimitKey) => {
|
||||
const current = currentIntervalLimits;
|
||||
delete current[intervalLimitKey];
|
||||
onChange(current);
|
||||
}}
|
||||
onIntervalSelect={(interval) => {
|
||||
const current = currentIntervalLimits;
|
||||
const currentValue = watchIntervalLimits[limitKey];
|
||||
|
||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||
delete current[bookingLimitKey];
|
||||
const newData = {
|
||||
...current,
|
||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||
[val?.value as BookingLimitsKey]: currentValue,
|
||||
};
|
||||
onChange(newData);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
StartIcon={FiTrash}
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
const current = currentBookingLimits;
|
||||
delete current[key as BookingLimitsKey];
|
||||
onChange(current);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||
delete current[limitKey];
|
||||
const newData = {
|
||||
...current,
|
||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||
[interval?.value as IntervalLimitsKey]: currentValue,
|
||||
};
|
||||
onChange(newData);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={FiPlus}
|
||||
onClick={() => {
|
||||
if (!currentBookingLimits || !watchBookingLimits) return;
|
||||
const currentKeys = Object.keys(watchBookingLimits);
|
||||
|
||||
const rest = Object.values(BOOKING_LIMIT_OPTIONS).filter(
|
||||
(option) => !currentKeys.includes(option.value)
|
||||
);
|
||||
if (!rest || !currentKeys) return;
|
||||
//currentBookingLimits is always defined so can be casted
|
||||
|
||||
setValue("bookingLimits", {
|
||||
...watchBookingLimits,
|
||||
[rest[0].value]: 1,
|
||||
});
|
||||
}}>
|
||||
{currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && !disabled && (
|
||||
<Button color="minimal" StartIcon={Plus} onClick={addLimit}>
|
||||
{t("add_limit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "even
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<RecurringEventController paymentEnabled={requirePayment} recurringEvent={eventType.recurringEvent} />
|
||||
<RecurringEventController paymentEnabled={requirePayment} eventType={eventType} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,20 +4,32 @@ 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 { useEffect, useState } from "react";
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { Button, Editor, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
|
||||
import { FiEdit2, FiCheck, FiX, FiPlus } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Editor,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { Edit2, Check, X, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import type { SingleValueLocationOption, LocationOption } from "@components/ui/form/LocationSelect";
|
||||
@@ -35,14 +47,60 @@ const getLocationFromType = (
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultLocationValue = (options: EventTypeSetupProps["locationOptions"], type: string) => {
|
||||
for (const locationType of options) {
|
||||
for (const location of locationType.options) {
|
||||
if (location.value === type && location.disabled === false) {
|
||||
return location;
|
||||
}
|
||||
}
|
||||
}
|
||||
const getLocationInfo = (props: Pick<EventTypeSetupProps, "eventType" | "locationOptions">) => {
|
||||
const locationAvailable =
|
||||
props.eventType.locations &&
|
||||
props.eventType.locations.length > 0 &&
|
||||
props.locationOptions.some((op) =>
|
||||
op.options.find((opt) => opt.value === props.eventType.locations[0].type)
|
||||
);
|
||||
const locationDetails = props.eventType.locations &&
|
||||
props.eventType.locations.length > 0 &&
|
||||
!locationAvailable && {
|
||||
slug: props.eventType.locations[0].type
|
||||
.replace("integrations:", "")
|
||||
.replace(":", "-")
|
||||
.replace("_video", ""),
|
||||
name: props.eventType.locations[0].type
|
||||
.replace("integrations:", "")
|
||||
.replace(":", " ")
|
||||
.replace("_video", "")
|
||||
.split(" ")
|
||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||
.join(" "),
|
||||
};
|
||||
return { locationAvailable, locationDetails };
|
||||
};
|
||||
interface DescriptionEditorProps {
|
||||
description?: string | null;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
const DescriptionEditor = (props: DescriptionEditorProps) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [mounted, setIsMounted] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const { description } = props;
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted ? (
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("description") || description || "")}
|
||||
setText={(value: string) => formMethods.setValue("description", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
editable={props.editable}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonContainer>
|
||||
<SkeletonText className="block h-24 w-full" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventSetupTab = (
|
||||
@@ -53,12 +111,16 @@ export const EventSetupTab = (
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType, locationOptions, team, destinationCalendar } = props;
|
||||
const { eventType, team, destinationCalendar } = 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 locationOptions = props.locationOptions.filter((option) => {
|
||||
return !team ? option.label !== "Conferencing" : true;
|
||||
});
|
||||
|
||||
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 }),
|
||||
@@ -143,6 +205,13 @@ export const EventSetupTab = (
|
||||
resolver: zodResolver(locationFormSchema),
|
||||
});
|
||||
|
||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||
useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
const Locations = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -157,6 +226,12 @@ export const EventSetupTab = (
|
||||
return true;
|
||||
});
|
||||
|
||||
const defaultValue = isManagedEventType
|
||||
? locationOptions.find((op) => op.label === t("default"))?.options[0]
|
||||
: undefined;
|
||||
|
||||
const { locationDetails, locationAvailable } = getLocationInfo(props);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{validLocations.length === 0 && (
|
||||
@@ -164,6 +239,8 @@ export const EventSetupTab = (
|
||||
<LocationSelect
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultValue={defaultValue}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
@@ -199,15 +276,21 @@ export const EventSetupTab = (
|
||||
return (
|
||||
<li
|
||||
key={`${location.type}${index}`}
|
||||
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
|
||||
className="border-default text-default mb-2 h-9 rounded-md border py-1.5 px-2 hover:cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className="h-4 w-4"
|
||||
className={cx(
|
||||
"h-4 w-4",
|
||||
// invert all the icons except app icons
|
||||
eventLocationType.iconUrl &&
|
||||
!eventLocationType.iconUrl.startsWith("/app-store") &&
|
||||
"dark:invert"
|
||||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="line-clamp-1 text-sm ltr:ml-1 rtl:mr-1">{eventLabel}</span>
|
||||
<span className="line-clamp-1 ms-1 text-sm">{eventLabel}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
@@ -221,11 +304,11 @@ export const EventSetupTab = (
|
||||
openLocationModal(location.type);
|
||||
}}
|
||||
aria-label={t("edit")}
|
||||
className="mr-1 p-1 text-gray-500 hover:text-gray-900">
|
||||
<FiEdit2 className="h-4 w-4" />
|
||||
className="hover:text-emphasis text-subtle mr-1 p-1">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
|
||||
<FiX className="border-l-1 h-6 w-6 pl-1 text-gray-500 hover:text-gray-900 " />
|
||||
<X className="border-l-1 hover:text-emphasis text-subtle h-6 w-6 pl-1 " />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,8 +319,8 @@ export const EventSetupTab = (
|
||||
(location) =>
|
||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<FiCheck className="mt-0.5 mr-1.5 h-2 w-2.5" />
|
||||
<div className="text-default flex text-sm">
|
||||
<Check 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.
|
||||
@@ -251,11 +334,19 @@ export const EventSetupTab = (
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
{validLocations.length > 0 && (
|
||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||
<p className="pl-1 text-sm leading-none text-red-600">
|
||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
|
||||
{t("connect_now")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<li>
|
||||
<Button
|
||||
data-testid="add-location"
|
||||
StartIcon={FiPlus}
|
||||
StartIcon={Plus}
|
||||
color="minimal"
|
||||
onClick={() => setShowLocationModal(true)}>
|
||||
{t("add_location")}
|
||||
@@ -268,32 +359,43 @@ export const EventSetupTab = (
|
||||
);
|
||||
};
|
||||
|
||||
const lengthLockedProps = shouldLockDisableProps("length");
|
||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-8">
|
||||
<TextField
|
||||
required
|
||||
label={t("title")}
|
||||
{...shouldLockDisableProps("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
<div>
|
||||
<Label>{t("description")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("description") || eventType.description || "")}
|
||||
setText={(value: string) => formMethods.setValue("description", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
<Label>
|
||||
{t("description")}
|
||||
{shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor
|
||||
description={eventType?.description}
|
||||
editable={!descriptionLockedProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
{...shouldLockDisableProps("slug")}
|
||||
defaultValue={eventType.slug}
|
||||
addOnLeading={
|
||||
<>
|
||||
{CAL_URL?.replace(/^(https?:|)\/\//, "")}/
|
||||
{team ? "team/" + team.slug : eventType.users[0].username}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
? "team/" + team.slug
|
||||
: eventType.users[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
</>
|
||||
}
|
||||
{...formMethods.register("slug", {
|
||||
@@ -341,12 +443,14 @@ export const EventSetupTab = (
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
{shouldLockIndicator("length")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
@@ -362,32 +466,37 @@ export const EventSetupTab = (
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...lengthLockedProps}
|
||||
label={t("duration")}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
{!lengthLockedProps.disabled && (
|
||||
<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")}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller
|
||||
@@ -401,6 +510,7 @@ export const EventSetupTab = (
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isTeamEvent={!!team}
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { Options } from "react-select";
|
||||
|
||||
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
|
||||
interface IMemberToValue {
|
||||
interface IUserToValue {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
|
||||
const mapUserToValue = ({ id, name, username, email }: IUserToValue) => ({
|
||||
value: `${id || ""}`,
|
||||
label: `${name || ""}`,
|
||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
||||
email,
|
||||
});
|
||||
|
||||
export const mapMemberToChildrenOption = (
|
||||
member: EventTypeSetupProps["teamMembers"][number],
|
||||
slug: string
|
||||
) => {
|
||||
return {
|
||||
slug,
|
||||
hidden: false,
|
||||
created: false,
|
||||
owner: {
|
||||
id: member.id,
|
||||
name: member.name ?? "",
|
||||
email: member.email,
|
||||
username: member.username ?? "",
|
||||
membership: member.membership,
|
||||
eventTypeSlugs: member.eventTypes ?? [],
|
||||
},
|
||||
value: `${member.id ?? ""}`,
|
||||
label: member.name ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
|
||||
if (a.label < b.label) {
|
||||
return -1;
|
||||
@@ -35,65 +57,223 @@ const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof
|
||||
return 0;
|
||||
};
|
||||
|
||||
const FixedHosts = ({
|
||||
control,
|
||||
labelText,
|
||||
placeholder,
|
||||
const ChildrenEventTypesList = ({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
...rest
|
||||
}: {
|
||||
control: Control<FormValues>;
|
||||
labelText: string;
|
||||
placeholder: string;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
} & Partial<ComponentProps<typeof CheckedTeamSelect>>) => {
|
||||
value: ReturnType<typeof mapMemberToChildrenOption>[];
|
||||
onChange?: (options: ReturnType<typeof mapMemberToChildrenOption>[]) => void;
|
||||
options?: Options<ReturnType<typeof mapMemberToChildrenOption>>;
|
||||
} & Omit<Partial<ComponentProps<typeof ChildrenEventTypeSelect>>, "onChange" | "value">) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||
<div className="flex flex-col space-y-5">
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
<Label>{t("assign_to")}</Label>
|
||||
<ChildrenEventTypeSelect
|
||||
onChange={(options) => {
|
||||
onChange &&
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
...option,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
value={value}
|
||||
options={options.filter((opt) => !value.find((val) => val.owner.id.toString() === opt.value))}
|
||||
controlShouldRenderValue={false}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "teamMembers" | "team">) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
const CheckedHostField = ({
|
||||
labelText,
|
||||
placeholder,
|
||||
options = [],
|
||||
isFixed,
|
||||
value,
|
||||
onChange,
|
||||
...rest
|
||||
}: {
|
||||
labelText: string;
|
||||
placeholder: string;
|
||||
isFixed: boolean;
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-5 p-4">
|
||||
<div>
|
||||
<Label>{labelText}</Label>
|
||||
<CheckedTeamSelect
|
||||
isOptionDisabled={(option) => !!value.find((host) => host.userId.toString() === option.value)}
|
||||
onChange={(options) => {
|
||||
onChange &&
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
isFixed,
|
||||
userId: parseInt(option.value, 10),
|
||||
}))
|
||||
);
|
||||
}}
|
||||
value={(value || [])
|
||||
.filter(({ isFixed: _isFixed }) => isFixed === _isFixed)
|
||||
.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>
|
||||
);
|
||||
};
|
||||
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange: (hosts: { isFixed: boolean; userId: number }[]) => void;
|
||||
teamMembers: {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}[];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
isFixed={true}
|
||||
onChange={(changeValue) => {
|
||||
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
|
||||
}}
|
||||
value={value}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
/>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
onChange={(changeValue) => onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])}
|
||||
value={value}
|
||||
isFixed={false}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("round_robin_hosts")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChildrenEventTypes = ({
|
||||
childrenEventTypeOptions,
|
||||
}: {
|
||||
childrenEventTypeOptions: ReturnType<typeof mapMemberToChildrenOption>[];
|
||||
}) => {
|
||||
return (
|
||||
<Controller<FormValues>
|
||||
name="children"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ChildrenEventTypesList value={value} options={childrenEventTypeOptions} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Hosts = ({
|
||||
teamMembers,
|
||||
}: {
|
||||
teamMembers: {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}[];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
control,
|
||||
resetField,
|
||||
getValues,
|
||||
formState: { submitCount },
|
||||
} = useFormContext<FormValues>();
|
||||
const schedulingType = useWatch({
|
||||
control: formMethods.control,
|
||||
control,
|
||||
name: "schedulingType",
|
||||
});
|
||||
const initialValue = useRef<{
|
||||
hosts: FormValues["hosts"];
|
||||
schedulingType: SchedulingType | null;
|
||||
submitCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Handles init & out of date initial value after submission.
|
||||
if (!initialValue.current || initialValue.current?.submitCount !== submitCount) {
|
||||
initialValue.current = { hosts: getValues("hosts"), schedulingType, submitCount };
|
||||
return;
|
||||
}
|
||||
resetField("hosts", {
|
||||
defaultValue: initialValue.current.schedulingType === schedulingType ? initialValue.current.hosts : [],
|
||||
});
|
||||
}, [schedulingType, resetField, getValues, submitCount]);
|
||||
|
||||
return (
|
||||
<Controller<FormValues>
|
||||
name="hosts"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const schedulingTypeRender = {
|
||||
COLLECTIVE: (
|
||||
<CheckedHostField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isFixed={true}
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("team")}
|
||||
/>
|
||||
),
|
||||
ROUND_ROBIN: (
|
||||
<>
|
||||
<RoundRobinHosts teamMembers={teamMembers} onChange={onChange} value={value} />
|
||||
{/*<TextField
|
||||
required
|
||||
type="number"
|
||||
label={t("minimum_round_robin_hosts_count")}
|
||||
defaultValue={1}
|
||||
{...formMethods.register("minimumHostCount")}
|
||||
addOnSuffix={<>{t("hosts")}</>}
|
||||
/>*/}
|
||||
</>
|
||||
),
|
||||
MANAGED: <></>,
|
||||
};
|
||||
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTeamTab = ({
|
||||
team,
|
||||
teamMembers,
|
||||
eventType,
|
||||
}: Pick<EventTypeSetupProps, "teamMembers" | "team" | "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const schedulingTypeOptions: {
|
||||
value: SchedulingType;
|
||||
@@ -101,27 +281,29 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
// description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: SchedulingType.COLLECTIVE,
|
||||
value: "COLLECTIVE",
|
||||
label: t("collective"),
|
||||
// description: t("collective_description"),
|
||||
},
|
||||
{
|
||||
value: SchedulingType.ROUND_ROBIN,
|
||||
value: "ROUND_ROBIN",
|
||||
label: t("round_robin"),
|
||||
// description: t("round_robin_description"),
|
||||
},
|
||||
];
|
||||
|
||||
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||
const childrenEventTypeOptions = teamMembers.map((member) => {
|
||||
return mapMemberToChildrenOption(member, eventType.slug);
|
||||
});
|
||||
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||
return (
|
||||
<div>
|
||||
{team && (
|
||||
{team && !isManagedEventType && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<Label>{t("scheduling_type")}</Label>
|
||||
<Controller
|
||||
<Controller<FormValues>
|
||||
name="schedulingType"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
options={schedulingTypeOptions}
|
||||
@@ -134,77 +316,12 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{schedulingType === SchedulingType.COLLECTIVE && (
|
||||
<FixedHosts
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("team")}
|
||||
control={formMethods.control}
|
||||
/>
|
||||
)}
|
||||
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||
<>
|
||||
<FixedHosts
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
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={teamMembersOptions.sort(sortByLabel)}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
<Hosts teamMembers={teamMembersOptions} />
|
||||
</div>
|
||||
)}
|
||||
{team && isManagedEventType && (
|
||||
<ChildrenEventTypes childrenEventTypeOptions={childrenEventTypeOptions} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
import { Webhook as TbWebhook } from "lucide-react";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { TbWebhook } from "react-icons/tb";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import type { 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 type { Webhook } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||
import { FiPlus } from "@calcom/ui/components/icon";
|
||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||
import { Plus, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
export const EventTeamWebhooksTab = ({
|
||||
eventType,
|
||||
@@ -85,12 +86,20 @@ export const EventTeamWebhooksTab = ({
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="new_webhook"
|
||||
StartIcon={FiPlus}
|
||||
StartIcon={Plus}
|
||||
onClick={() => setCreateModalOpen(true)}>
|
||||
{t("new_webhook")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const webhookLockedStatus = shouldLockDisableProps("webhooks");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{team && webhooks && !isLoading && (
|
||||
@@ -98,15 +107,24 @@ export const EventTeamWebhooksTab = ({
|
||||
<div>
|
||||
<div>
|
||||
<>
|
||||
{isManagedEventType && (
|
||||
<Alert
|
||||
severity="neutral"
|
||||
className="mb-2"
|
||||
title={t("locked_for_members")}
|
||||
message={t("locked_webhooks_description")}
|
||||
/>
|
||||
)}
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="mb-8 rounded-md border">
|
||||
<div className="mb-2 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
@@ -122,7 +140,15 @@ export const EventTeamWebhooksTab = ({
|
||||
Icon={TbWebhook}
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("create_your_first_team_webhook_description", { appName: APP_NAME })}
|
||||
buttonRaw={<NewWebhookButton />}
|
||||
buttonRaw={
|
||||
isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon={Lock} color="secondary" disabled>
|
||||
{t("locked_by_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<NewWebhookButton />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -144,7 +170,7 @@ export const EventTeamWebhooksTab = ({
|
||||
</Dialog>
|
||||
{/* Edit webhook dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent title={t("edit_webhook")}>
|
||||
<DialogContent enableOverflow title={t("edit_webhook")}>
|
||||
<WebhookForm
|
||||
webhook={webhookToEdit}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export type EventTypeDescriptionSafeProps = {
|
||||
eventType: { description: string | null };
|
||||
eventType: { description: string | null; descriptionAsSafeHTML: string | null };
|
||||
};
|
||||
|
||||
export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => {
|
||||
const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true };
|
||||
// @ts-expect-error: @see packages/prisma/middleware/eventTypeDescriptionParseAndSanitize.ts
|
||||
if (eventType.description) props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML };
|
||||
if (eventType.description)
|
||||
props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML || "" };
|
||||
return <div {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Webhook as TbWebhook } from "lucide-react";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo, useState, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { TbWebhook } from "react-icons/tb";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
@@ -32,22 +35,23 @@ import {
|
||||
VerticalTabs,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
FiLink,
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiSliders,
|
||||
FiRepeat,
|
||||
FiGrid,
|
||||
FiZap,
|
||||
FiUsers,
|
||||
FiExternalLink,
|
||||
FiCode,
|
||||
FiTrash,
|
||||
FiMoreHorizontal,
|
||||
FiLoader,
|
||||
Link as LinkIcon,
|
||||
Calendar,
|
||||
Clock,
|
||||
Sliders,
|
||||
Repeat,
|
||||
Grid,
|
||||
Zap,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Code,
|
||||
Trash,
|
||||
MoreHorizontal,
|
||||
Loader,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -60,6 +64,7 @@ type Props = {
|
||||
enabledWorkflowsNumber: number;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
isUpdateMutationLoading?: boolean;
|
||||
availability?: AvailabilityOption;
|
||||
};
|
||||
|
||||
function getNavigation(props: {
|
||||
@@ -68,8 +73,10 @@ function getNavigation(props: {
|
||||
enabledAppsNumber: number;
|
||||
enabledWorkflowsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
availability: AvailabilityOption | undefined;
|
||||
}) {
|
||||
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props;
|
||||
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber, availability } =
|
||||
props;
|
||||
const duration =
|
||||
eventType.metadata?.multipleDuration?.map((duration) => ` ${duration}`) || eventType.length;
|
||||
|
||||
@@ -77,44 +84,38 @@ function getNavigation(props: {
|
||||
{
|
||||
name: "event_setup_tab_title",
|
||||
href: `/event-types/${eventType.id}?tabName=setup`,
|
||||
icon: FiLink,
|
||||
icon: LinkIcon,
|
||||
info: `${duration} ${t("minute_timeUnit")}`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "availability",
|
||||
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||
icon: FiCalendar,
|
||||
info: `default_schedule_name`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "event_limit_tab_title",
|
||||
href: `/event-types/${eventType.id}?tabName=limits`,
|
||||
icon: FiClock,
|
||||
icon: Clock,
|
||||
info: `event_limit_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "event_advanced_tab_title",
|
||||
href: `/event-types/${eventType.id}?tabName=advanced`,
|
||||
icon: FiSliders,
|
||||
icon: Sliders,
|
||||
info: `event_advanced_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: `/event-types/${eventType.id}?tabName=recurring`,
|
||||
icon: FiRepeat,
|
||||
icon: Repeat,
|
||||
info: `recurring_event_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "apps",
|
||||
href: `/event-types/${eventType.id}?tabName=apps`,
|
||||
icon: FiGrid,
|
||||
icon: Grid,
|
||||
//TODO: Handle proper translation with count handling
|
||||
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
|
||||
},
|
||||
{
|
||||
name: "workflows",
|
||||
href: `/event-types/${eventType.id}?tabName=workflows`,
|
||||
icon: FiZap,
|
||||
icon: Zap,
|
||||
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
||||
},
|
||||
];
|
||||
@@ -131,13 +132,17 @@ function EventTypeSingleLayout({
|
||||
enabledWorkflowsNumber,
|
||||
isUpdateMutationLoading,
|
||||
formMethods,
|
||||
availability,
|
||||
}: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
|
||||
const hasPermsToDelete =
|
||||
currentUserMembership?.role !== "MEMBER" ||
|
||||
!currentUserMembership ||
|
||||
eventType.schedulingType === SchedulingType.MANAGED;
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -157,6 +162,12 @@ function EventTypeSingleLayout({
|
||||
},
|
||||
});
|
||||
|
||||
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
// Define tab navigation here
|
||||
const EventTypeTabs = useMemo(() => {
|
||||
const navigation = getNavigation({
|
||||
@@ -165,15 +176,42 @@ function EventTypeSingleLayout({
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
availability,
|
||||
});
|
||||
navigation.splice(1, 0, {
|
||||
name: "availability",
|
||||
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||
icon: Calendar,
|
||||
info:
|
||||
isManagedEventType || isChildrenManagedEventType
|
||||
? eventType.schedule === null
|
||||
? "Member's default schedule"
|
||||
: isChildrenManagedEventType
|
||||
? `${
|
||||
eventType.scheduleName
|
||||
? `${eventType.scheduleName} - ${t("managed")}`
|
||||
: `default_schedule_name`
|
||||
}`
|
||||
: eventType.scheduleName ?? `default_schedule_name`
|
||||
: `default_schedule_name`,
|
||||
});
|
||||
// If there is a team put this navigation item within the tabs
|
||||
if (team) {
|
||||
navigation.splice(2, 0, {
|
||||
name: "assignment",
|
||||
href: `/event-types/${eventType.id}?tabName=team`,
|
||||
icon: FiUsers,
|
||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||
icon: Users,
|
||||
info: `${t(eventType.schedulingType?.toLowerCase() ?? "")}${
|
||||
isManagedEventType
|
||||
? ` - ${t("count_members", { count: formMethods.watch("children").length || 0 })}`
|
||||
: ""
|
||||
}`,
|
||||
});
|
||||
}
|
||||
if (isManagedEventType || isChildrenManagedEventType) {
|
||||
// Removing apps and workflows for manageg event types by admins v1
|
||||
navigation.splice(-2, 1);
|
||||
} else {
|
||||
navigation.push({
|
||||
name: "webhooks",
|
||||
href: `/event-types/${eventType.id}?tabName=webhooks`,
|
||||
@@ -182,13 +220,14 @@ function EventTypeSingleLayout({
|
||||
});
|
||||
}
|
||||
return navigation;
|
||||
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team, availability]);
|
||||
|
||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
}`;
|
||||
|
||||
const embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
|
||||
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
||||
|
||||
return (
|
||||
<Shell
|
||||
@@ -197,86 +236,96 @@ function EventTypeSingleLayout({
|
||||
heading={eventType.title}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="hidden items-center rounded-md px-2 sm:hover:bg-gray-100 lg:flex">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
|
||||
{t("hide_from_profile")}
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
checked={formMethods.watch("hidden")}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hidden", e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<VerticalDivider className="hidden lg:block" />
|
||||
{!eventType.metadata.managedEventConfig && (
|
||||
<>
|
||||
<div className="sm:hover:bg-muted hidden items-center rounded-md px-2 lg:flex">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
|
||||
{t("hide_from_profile")}
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
checked={formMethods.watch("hidden")}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hidden", e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<VerticalDivider className="hidden lg:block" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TODO: Figure out why combined isnt working - works in storybook */}
|
||||
<ButtonGroup combined containerProps={{ className: "border-gray-300 hidden lg:flex" }}>
|
||||
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
|
||||
<Tooltip content={t("preview")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="preview-button"
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
href={permalink}
|
||||
rel="noreferrer"
|
||||
StartIcon={FiExternalLink}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ButtonGroup combined containerProps={{ className: "border-default hidden lg:flex" }}>
|
||||
{!isManagedEventType && (
|
||||
<>
|
||||
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
|
||||
<Tooltip content={t("preview")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="preview-button"
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
href={permalink}
|
||||
rel="noreferrer"
|
||||
StartIcon={ExternalLink}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={FiLink}
|
||||
tooltip={t("copy_link")}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Link copied!", "success");
|
||||
}}
|
||||
/>
|
||||
<EmbedButton
|
||||
embedUrl={encodeURIComponent(embedLink)}
|
||||
StartIcon={FiCode}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={FiTrash}
|
||||
tooltip={t("delete")}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={LinkIcon}
|
||||
tooltip={t("copy_link")}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Link copied!", "success");
|
||||
}}
|
||||
/>
|
||||
<EmbedButton
|
||||
embedUrl={encodeURIComponent(embedLink)}
|
||||
StartIcon={Code}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isChildrenManagedEventType && (
|
||||
<Button
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon={Trash}
|
||||
tooltip={t("delete")}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
<VerticalDivider className="hidden lg:block" />
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="lg:hidden" StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
|
||||
<Button className="lg:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent style={{ minWidth: "200px" }}>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<DropdownMenuItem className="focus:ring-muted">
|
||||
<DropdownItem
|
||||
target="_blank"
|
||||
type="button"
|
||||
StartIcon={FiExternalLink}
|
||||
StartIcon={ExternalLink}
|
||||
href={permalink}
|
||||
rel="noreferrer">
|
||||
{t("preview")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<DropdownMenuItem className="focus:ring-muted">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={FiLink}
|
||||
StartIcon={LinkIcon}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Link copied!", "success");
|
||||
@@ -284,18 +333,18 @@ function EventTypeSingleLayout({
|
||||
{t("copy_link")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<DropdownMenuItem className="focus:ring-muted">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon={FiTrash}
|
||||
StartIcon={Trash}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex h-9 flex-row items-center justify-between py-2 px-4 sm:hover:bg-gray-100">
|
||||
<div className="sm:hover:bg-subtle flex h-9 flex-row items-center justify-between py-2 px-4">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
@@ -312,7 +361,7 @@ function EventTypeSingleLayout({
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
<div className="border-l-2 border-gray-300" />
|
||||
<div className="border-default border-l-2" />
|
||||
<Button
|
||||
className="ml-4 lg:ml-0"
|
||||
type="submit"
|
||||
@@ -323,7 +372,7 @@ function EventTypeSingleLayout({
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<Suspense fallback={<FiLoader />}>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-6">
|
||||
<div className="hidden xl:block">
|
||||
<VerticalTabs
|
||||
@@ -331,6 +380,8 @@ function EventTypeSingleLayout({
|
||||
tabs={EventTypeTabs}
|
||||
sticky
|
||||
linkProps={{ shallow: true }}
|
||||
itemClassname="items-start"
|
||||
iconClassName="md:mt-px"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
||||
@@ -339,7 +390,7 @@ function EventTypeSingleLayout({
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 rounded-md border-gray-200 bg-white sm:mx-0 xl:mt-0",
|
||||
"bg-default border-subtle mt-4 rounded-md sm:mx-0 xl:mt-0",
|
||||
disableBorder ? "border-0 " : "p-2 md:border md:p-6"
|
||||
)}>
|
||||
{children}
|
||||
@@ -351,14 +402,25 @@ function EventTypeSingleLayout({
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_event_type")}
|
||||
confirmBtnText={t("confirm_delete_event_type")}
|
||||
loadingText={t("confirm_delete_event_type")}
|
||||
title={t(`delete${isManagedEvent}_event_type`)}
|
||||
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||
loadingText={t(`confirm_delete_event_type`)}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteMutation.mutate({ id: eventType.id });
|
||||
}}>
|
||||
{t("delete_event_type_description")}
|
||||
<p className="mt-5">
|
||||
<Trans
|
||||
i18nKey={`delete${isManagedEvent}_event_type_description`}
|
||||
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
|
||||
<ul>
|
||||
<li>Members assigned to this event type will also have their event types deleted.</li>
|
||||
<li>
|
||||
Anyone who they've shared their link with will no longer be able to book using it.
|
||||
</li>
|
||||
</ul>
|
||||
</Trans>
|
||||
</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<EmbedDialog />
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Alert, Select, SettingsToggle } from "@calcom/ui";
|
||||
import { Alert, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
|
||||
type RecurringEventControllerProps = {
|
||||
recurringEvent: RecurringEvent | null;
|
||||
eventType: EventTypeSetup;
|
||||
paymentEnabled: boolean;
|
||||
};
|
||||
|
||||
export default function RecurringEventController({
|
||||
recurringEvent,
|
||||
eventType,
|
||||
paymentEnabled,
|
||||
}: RecurringEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(
|
||||
eventType.recurringEvent
|
||||
);
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||
@@ -28,6 +31,14 @@ export default function RecurringEventController({
|
||||
value: value.toString(),
|
||||
}));
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
const recurringLocked = shouldLockDisableProps("recurringEvent");
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||
@@ -37,6 +48,7 @@ export default function RecurringEventController({
|
||||
<>
|
||||
<SettingsToggle
|
||||
title={t("recurring_event")}
|
||||
{...recurringLocked}
|
||||
description={t("recurring_event_description")}
|
||||
checked={recurringEventState !== null}
|
||||
data-testid="recurring-event-check"
|
||||
@@ -45,7 +57,7 @@ export default function RecurringEventController({
|
||||
formMethods.setValue("recurringEvent", null);
|
||||
setRecurringEventState(null);
|
||||
} else {
|
||||
const newVal = recurringEvent || {
|
||||
const newVal = eventType.recurringEvent || {
|
||||
interval: 1,
|
||||
count: 12,
|
||||
freq: Frequency.WEEKLY,
|
||||
@@ -57,12 +69,13 @@ export default function RecurringEventController({
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-gray-900 ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<input
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block h-[36px] w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
@@ -77,7 +90,8 @@ export default function RecurringEventController({
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 block h-[36px] min-w-0 rounded-md text-sm"
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
@@ -89,13 +103,14 @@ export default function RecurringEventController({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="text-gray-900 ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<input
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block h-[36px] w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
defaultValue={recurringEventState.count}
|
||||
className="mb-0"
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
@@ -105,7 +120,7 @@ export default function RecurringEventController({
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="text-gray-900 ltr:mr-2 rtl:ml-2">
|
||||
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import type { UnitTypeLongPlural } from "dayjs";
|
||||
import { Trans } from "next-i18next";
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Input, Label, SettingsToggle } from "@calcom/ui";
|
||||
import { Input, SettingsToggle, RadioField, Select } from "@calcom/ui";
|
||||
|
||||
type RequiresConfirmationControllerProps = {
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
requiresConfirmation: boolean;
|
||||
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
|
||||
seatsEnabled: boolean;
|
||||
eventType: EventTypeSetup;
|
||||
};
|
||||
|
||||
export default function RequiresConfirmationController({
|
||||
metadata,
|
||||
eventType,
|
||||
requiresConfirmation,
|
||||
onRequiresConfirmation,
|
||||
seatsEnabled,
|
||||
@@ -37,6 +41,23 @@ export default function RequiresConfirmationController({
|
||||
}
|
||||
}, [requiresConfirmation]);
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const requiresConfirmationLockedProps = shouldLockDisableProps("requiresConfirmation");
|
||||
|
||||
const options = [
|
||||
{ label: t("minute_timeUnit"), value: "minutes" },
|
||||
{ label: t("hour_timeUnit"), value: "hours" },
|
||||
];
|
||||
|
||||
const defaultValue = options.find(
|
||||
(opt) =>
|
||||
opt.value === (metadata?.requiresConfirmationThreshold?.unit ?? defaultRequiresConfirmationSetup.unit)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className="w-full">
|
||||
@@ -46,10 +67,11 @@ export default function RequiresConfirmationController({
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("requires_confirmation")}
|
||||
disabled={seatsEnabled}
|
||||
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={requiresConfirmation}
|
||||
LockedIcon={requiresConfirmationLockedProps.LockedIcon}
|
||||
onCheckedChange={(val) => {
|
||||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
@@ -77,73 +99,82 @@ export default function RequiresConfirmationController({
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="-ml-2 flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
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
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
val
|
||||
);
|
||||
}}
|
||||
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
className="ml-2"
|
||||
onChange={(opt) => {
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural
|
||||
);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user