Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa336c195 | ||
|
|
0b53cd6ffc | ||
|
|
9ad4cba8cf | ||
|
|
9ff4d7e024 | ||
|
|
012af52f1b | ||
|
|
6e1c165b31 | ||
|
|
83d2c924ca | ||
|
|
ebc098186d | ||
|
|
c7288bb822 | ||
|
|
5587867598 | ||
|
|
62f6373102 | ||
|
|
61f29d4a86 | ||
|
|
93ab0cfd09 | ||
|
|
8f495f7b67 | ||
|
|
c9d5af6591 | ||
|
|
adffb00817 | ||
|
|
afe730e502 | ||
|
|
4cd43087e9 | ||
|
|
371fd48c15 | ||
|
|
657cf7e180 | ||
|
|
6b0ac96b38 | ||
|
|
2c30976e99 | ||
|
|
a7212660a4 | ||
|
|
5e4e98e002 | ||
|
|
7bbfde607a | ||
|
|
f179c5b818 | ||
|
|
03c27ebd62 | ||
|
|
db2886d0d4 | ||
|
|
5db762e987 | ||
|
|
f9cc71d120 | ||
|
|
6db129e3bf | ||
|
|
c6b1d99270 | ||
|
|
34cd41be67 | ||
|
|
8ca627040b | ||
|
|
13528ada2e | ||
|
|
3c7380c3a9 | ||
|
|
9d951a5802 | ||
|
|
b70550fc83 | ||
|
|
6a12ff7984 | ||
|
|
279d9766d8 | ||
|
|
85b61bed01 | ||
|
|
c2ca848f0a | ||
|
|
c890e8d06d | ||
|
|
7d6a6bf812 | ||
|
|
a4684042a6 | ||
|
|
56da9c7c5f | ||
|
|
7967c6e46b | ||
|
|
8d96b79a5b | ||
|
|
d0325c13d1 | ||
|
|
5ea08c237a | ||
|
|
b3f7e8e322 | ||
|
|
b7d4e3885e | ||
|
|
3c324eb776 | ||
|
|
c16e71aa01 | ||
|
|
5fa3f45fed | ||
|
|
450d0e7f05 | ||
|
|
b5745fd0e5 | ||
|
|
bac12d0cbd | ||
|
|
f17833552e | ||
|
|
15bdcaff88 | ||
|
|
29507557d6 | ||
|
|
30c1dd036d | ||
|
|
8f24b92666 | ||
|
|
7c99004225 | ||
|
|
8bce6055c7 | ||
|
|
049e433781 | ||
|
|
3ac8caecbf | ||
|
|
e394cfdcd8 | ||
|
|
be99035f49 | ||
|
|
6c8e624496 | ||
|
|
5369d4123e | ||
|
|
3a055018f6 | ||
|
|
6ac670b366 | ||
|
|
db35a7c68f | ||
|
|
a1d8fb0b46 | ||
|
|
7170fadff8 | ||
|
|
fa5e6b42de | ||
|
|
1f02a5c718 | ||
|
|
c55b17bf58 | ||
|
|
4975493090 | ||
|
|
785a014f3c | ||
|
|
dd941e62ef | ||
|
|
eec2d8ceed | ||
|
|
b546d9e33e | ||
|
|
201d17264c | ||
|
|
2cbf46a323 | ||
|
|
833791bc9a | ||
|
|
00c9df7602 | ||
|
|
669199e273 | ||
|
|
93e2246b18 | ||
|
|
f639405c20 | ||
|
|
240fc67d72 | ||
|
|
eef405ef41 | ||
|
|
1469b856a6 | ||
|
|
53bbbf793d | ||
|
|
a91f251b98 | ||
|
|
d95c3a1459 | ||
|
|
92bfb3e5b9 | ||
|
|
fbc4eb5457 | ||
|
|
4a74abcb34 | ||
|
|
09c8f55682 | ||
|
|
3f3ffa2ba8 | ||
|
|
654cfbb653 | ||
|
|
ad35d3d567 | ||
|
|
70a31874f0 | ||
|
|
fe8b0fc5f8 | ||
|
|
2983fb4c12 | ||
|
|
03cc4855a8 | ||
|
|
c82a9853d2 | ||
|
|
d732a984c2 | ||
|
|
826583f404 | ||
|
|
b6dd0d5804 | ||
|
|
df2c3c42bf | ||
|
|
7122a4a91d | ||
|
|
5be669a479 | ||
|
|
8629d92a64 | ||
|
|
840b6c498a | ||
|
|
7a8b9e5f32 | ||
|
|
fe50f04075 | ||
|
|
e1e369de9e | ||
|
|
9b2ad3f350 | ||
|
|
83dfafb8a1 | ||
|
|
2cd7ddcdbe | ||
|
|
2fa9ea4501 | ||
|
|
6fff27b5ad | ||
|
|
0794224288 | ||
|
|
e3e1de3c11 | ||
|
|
52759333fd | ||
|
|
e27a2bafcd | ||
|
|
87befdbdf4 | ||
|
|
2ba8f99302 | ||
|
|
e7b634419b | ||
|
|
de0cb867d1 | ||
|
|
02571aabe0 | ||
|
|
58b2a6425a | ||
|
|
2c2261f085 | ||
|
|
9a3624bb9a | ||
|
|
3449ad1a9f | ||
|
|
44f0893674 | ||
|
|
af6f33c709 | ||
|
|
942b8da124 | ||
|
|
87efab5b2b | ||
|
|
f16f9466cb | ||
|
|
ddd62d3729 | ||
|
|
862feb2bc4 | ||
|
|
79556f1d13 | ||
|
|
5ec4b7ad37 | ||
|
|
3605fca9a4 | ||
|
|
7874ff9c6d | ||
|
|
02e5ec37e7 | ||
|
|
fd863b2ae7 | ||
|
|
13b5618a71 | ||
|
|
643b46aa8f | ||
|
|
06b7744b30 | ||
|
|
7f53c4288d | ||
|
|
7be60b47f1 | ||
|
|
bc88fd0a04 | ||
|
|
60c989432e | ||
|
|
d1b791518e | ||
|
|
39f6ef8dcd | ||
|
|
777d260d78 | ||
|
|
7f6a9cf555 | ||
|
|
21fb0fc3a3 | ||
|
|
1e7eac4133 | ||
|
|
fef6f3baff | ||
|
|
6f354e0c7f | ||
|
|
40a18243e4 | ||
|
|
43f1e86322 | ||
|
|
2f5928be84 | ||
|
|
d7cd6e011e | ||
|
|
993bd18a73 | ||
|
|
dfc6373bb4 | ||
|
|
1b81e6d8ec | ||
|
|
591309f335 | ||
|
|
b339736a6f | ||
|
|
89ef6c3f13 | ||
|
|
67ca98018e | ||
|
|
7337c75b26 | ||
|
|
4dee7d8a80 | ||
|
|
b004701601 | ||
|
|
929c3a9a27 | ||
|
|
5ca914b32e | ||
|
|
d440458794 | ||
|
|
b3f8f9f51f | ||
|
|
2d28ca61a4 | ||
|
|
7599f2384e | ||
|
|
9e598c8855 | ||
|
|
7e75e50650 | ||
|
|
3ad160d0d8 | ||
|
|
314fef82a5 | ||
|
|
159c026533 | ||
|
|
b9b3fef202 | ||
|
|
ee1edc9c27 | ||
|
|
b38991fd25 | ||
|
|
3234e0f0a9 | ||
|
|
a9987bb795 | ||
|
|
d44c8f7912 | ||
|
|
2e79446667 | ||
|
|
16692aee16 | ||
|
|
67ea727686 | ||
|
|
f239922fb6 | ||
|
|
60e06e65c2 | ||
|
|
c4c20cf77d | ||
|
|
6253216484 | ||
|
|
c5a40f6c37 | ||
|
|
abe090881a | ||
|
|
e65116d9e3 | ||
|
|
f82404392f | ||
|
|
ddc686075b | ||
|
|
56e61c8d7c | ||
|
|
34cae662f3 | ||
|
|
5dcd439901 | ||
|
|
4eec01e2ad | ||
|
|
3ae35a3b5d | ||
|
|
673633a773 | ||
|
|
e9f3248fc0 | ||
|
|
6959eb2d27 | ||
|
|
6f8d0521f4 | ||
|
|
de375f0e77 | ||
|
|
833adaf7e9 | ||
|
|
3e777dfaeb | ||
|
|
8cfc91df95 | ||
|
|
ecea455b7f | ||
|
|
041ad9cd94 | ||
|
|
eb64904ba3 | ||
|
|
a3e05ff98c | ||
|
|
f335678a78 | ||
|
|
b5b48e7992 | ||
|
|
c5d2bac26f | ||
|
|
22460a460b | ||
|
|
7bef84a8d0 | ||
|
|
7e8dc0f848 | ||
|
|
74704bb699 | ||
|
|
cb66f96b63 | ||
|
|
0ebda5d483 | ||
|
|
5208400b24 | ||
|
|
1960046994 | ||
|
|
3c9cf81fb3 | ||
|
|
8d893d3ed1 | ||
|
|
d1cf91a1ea | ||
|
|
2362133b25 | ||
|
|
f9d05f7a0e | ||
|
|
dfe3e55e74 | ||
|
|
881de59bf5 | ||
|
|
e0a391d46e | ||
|
|
b044c6fb70 | ||
|
|
06482388bd | ||
|
|
5653282dea | ||
|
|
624a7a036d | ||
|
|
5c3d399ccb | ||
|
|
0a308eb13d | ||
|
|
e487f295da | ||
|
|
63983c9d60 | ||
|
|
2e61eb41b2 | ||
|
|
ad4bcfafce | ||
|
|
2faf7e6806 | ||
|
|
a2bb210ebd | ||
|
|
f3d49fe59e | ||
|
|
fa4ecf09fb | ||
|
|
78d340786f | ||
|
|
7abd8adf9c | ||
|
|
17b8ec65e4 | ||
|
|
c03ff0a543 | ||
|
|
8fa0d0f8bb | ||
|
|
567a7c8f12 | ||
|
|
514fca7a53 | ||
|
|
67a7c578b1 | ||
|
|
0df0b6a0f2 | ||
|
|
22d2bae46b | ||
|
|
1f1e364a30 | ||
|
|
65d8b2d45c | ||
|
|
b11398f551 | ||
|
|
8cd420173c | ||
|
|
d276763b11 | ||
|
|
0f0513b8b7 | ||
|
|
a9e519f643 | ||
|
|
8f86e0b164 | ||
|
|
ac3a4bcaab | ||
|
|
205c774ca3 | ||
|
|
abe5d53fc1 | ||
|
|
289cdefc27 | ||
|
|
1d96933f5f | ||
|
|
2e1cadb5da | ||
|
|
30e35fe068 | ||
|
|
7151c65f1a | ||
|
|
3a2224a4e4 | ||
|
|
33a4bb797d | ||
|
|
b8cd4c32ac | ||
|
|
bb4875443a | ||
|
|
d1cd83acea | ||
|
|
5a84adcd8a | ||
|
|
55d6f1ee35 | ||
|
|
e863b71dfa | ||
|
|
0f5550680a | ||
|
|
e77ddc3d2e | ||
|
|
c3b7b32af5 | ||
|
|
f4f953cca1 | ||
|
|
e2ef7216a8 | ||
|
|
18e0f13605 | ||
|
|
51b86b1d97 | ||
|
|
875e279def | ||
|
|
88db424a16 | ||
|
|
a8aeac22ee | ||
|
|
13f20dd7cf | ||
|
|
41c5ad45fd | ||
|
|
7fec9d64fa | ||
|
|
c95b8d0048 | ||
|
|
f65edba982 | ||
|
|
94c3bc7f19 | ||
|
|
bfd27f0d94 | ||
|
|
67b96d990c | ||
|
|
ba04533de3 | ||
|
|
547b40913f | ||
|
|
15d5333cf6 | ||
|
|
ad101e0a31 | ||
|
|
12d66cb9df | ||
|
|
3b321e5d3c | ||
|
|
d0ac4d41ed | ||
|
|
983c970069 | ||
|
|
3189f8b77a | ||
|
|
079016052d | ||
|
|
f0a5b7734f | ||
|
|
03e787ad5a | ||
|
|
dd09854932 | ||
|
|
b9428301b6 | ||
|
|
2344a4da13 | ||
|
|
4652878e6c | ||
|
|
4efc2914af | ||
|
|
9866e12a84 | ||
|
|
8998e41104 | ||
|
|
0a64715d84 | ||
|
|
879619e075 | ||
|
|
16cab18163 | ||
|
|
456ba9db04 | ||
|
|
1c89afc7ad | ||
|
|
16836d0cfb | ||
|
|
2c65e39482 | ||
|
|
65dc7c5b65 | ||
|
|
eb02efb25a | ||
|
|
864a873c57 | ||
|
|
fcba3bec03 | ||
|
|
4569a96e78 | ||
|
|
debbd980d9 | ||
|
|
d5e40faeb5 | ||
|
|
10edc1f4ac | ||
|
|
42c49a8263 | ||
|
|
7b0d5811d6 | ||
|
|
9d1e6f23db | ||
|
|
b4f3680876 | ||
|
|
7536473c9d | ||
|
|
fd8e4ef890 | ||
|
|
c8a16ce43b | ||
|
|
887bd66a33 | ||
|
|
eeb9ff662e | ||
|
|
262df1a1b2 | ||
|
|
3919d87486 | ||
|
|
6f203d14e3 | ||
|
|
456a4ba581 | ||
|
|
60d6c7be51 | ||
|
|
0f273c5efc | ||
|
|
33a45a8af5 | ||
|
|
6179b0cc56 | ||
|
|
9ded50b5fc | ||
|
|
7c34be272f | ||
|
|
6145c7ad90 | ||
|
|
5225fc4317 | ||
|
|
514cff7b3c | ||
|
|
eb6e092a21 | ||
|
|
7d2c0ab405 | ||
|
+22 |
01631e808f | ||
|
|
3af6b3b6b3 | ||
|
|
bba9b755bc | ||
|
|
ef523bda51 | ||
|
|
0761e8664a | ||
|
|
6b0cffa79a | ||
|
|
843daad481 | ||
|
|
375517486c | ||
|
|
bc74014040 | ||
|
|
3035412cdc | ||
|
|
f7cec8d71f | ||
|
|
b22c562719 | ||
|
|
e493b16466 | ||
|
|
d1f117f17a | ||
|
|
af5bc9d530 | ||
|
|
d94267cdc1 | ||
|
|
a15fe78ddb | ||
|
|
e270be1b87 | ||
|
|
8fe969d3b8 | ||
|
|
9fced41cd0 | ||
|
|
b3e9a38231 | ||
|
|
c144c365de | ||
|
|
00e728e968 | ||
|
|
be92cc3563 | ||
|
|
99c6b92bfe | ||
|
|
ce0fb6c09e | ||
|
|
dfd6bc1523 | ||
|
|
a542930b87 | ||
|
|
46752bd0d8 | ||
|
|
0458203281 | ||
|
|
7b01bae829 | ||
|
|
b869e70a7f | ||
|
|
411497575a | ||
|
|
e1bf425c1c | ||
|
|
445cdd7cbf | ||
|
|
d6678f9b3a | ||
|
|
01025fa34c | ||
|
|
b973f971a7 | ||
|
|
2f832800fc | ||
|
|
9df4867fca | ||
|
|
6940c8d6cc | ||
|
|
24a3bfdf78 | ||
|
|
9710c6faa6 | ||
|
|
a0a4ff6ce6 | ||
|
|
c67338bb3f | ||
|
|
727c6f723e | ||
|
|
40c81ee405 | ||
|
|
7c3090bc23 | ||
|
|
1bd6d03352 | ||
|
|
058550ba3d | ||
|
|
6c248bb9f0 | ||
|
|
29b09ce85d | ||
|
|
ba4d2425cf | ||
|
|
49b361ab9c | ||
|
|
1bf009f5f5 | ||
|
|
e16eee68b0 | ||
|
|
07aa2e88a1 | ||
|
|
dc934ed8ca | ||
|
|
29bb6711bb | ||
|
|
94966b1643 | ||
|
|
7548233a96 | ||
|
|
9fba3f3767 | ||
|
|
db784f8333 | ||
|
|
a48a164e71 | ||
|
|
eacfdac016 | ||
|
|
323524b77c | ||
|
|
c8d6c0dbdd | ||
|
|
cc0ea19420 | ||
|
|
a551aa0089 | ||
|
|
0ed0891d97 | ||
|
|
f0a36f8194 | ||
|
|
23576038aa | ||
|
|
0c44d9e0a6 | ||
|
|
67770bf878 | ||
|
+21 |
81d33b51bc | ||
|
|
162c6c10a8 | ||
|
|
7d7a1c5bbc | ||
|
|
53a7b1cc37 | ||
|
|
f9f4a5aeb9 | ||
|
|
066fce9d73 | ||
|
|
3a1d8b8183 | ||
|
|
38f23986ad | ||
|
|
27422c351c | ||
|
|
c5ad74f61c | ||
|
|
0eff265682 | ||
|
|
2560b72596 | ||
|
|
7d7c61a1ab | ||
|
|
c92283f03d | ||
|
|
ba9be4e69d | ||
|
|
b334586c3b | ||
|
|
a38c76e279 | ||
|
|
6973d26153 | ||
|
|
e62af90619 | ||
|
|
e72cceff81 | ||
|
|
1c296fb2d5 | ||
|
|
1de50bd093 | ||
|
+3 |
4b6c701c48 | ||
|
|
463aff0702 | ||
|
|
c1cc93c164 | ||
|
|
6148e89425 | ||
|
|
a7f7b29bae | ||
|
|
afafd45223 | ||
|
|
31aff64e42 | ||
|
|
952a149604 | ||
|
|
3aa7387ff5 | ||
|
|
f8007a4f01 | ||
|
|
1fb637d29d | ||
|
|
80954aa866 | ||
|
|
821105dccb | ||
|
|
5a124cec72 | ||
|
|
86a4037088 | ||
|
|
faa2805ccc | ||
|
|
ca3e309ad9 | ||
|
|
fc59338057 | ||
|
|
2d56096d8f | ||
|
|
8545bfef67 | ||
|
|
8a9b7ca78c | ||
|
|
c910b41043 | ||
|
|
d51aade461 | ||
|
|
fa837a5d94 | ||
|
|
1b59dacd64 | ||
|
|
adf817766e | ||
|
|
98d7d7a11c | ||
|
|
4fa380d7ed | ||
|
|
39031123e1 | ||
|
|
b718718bd6 | ||
|
|
0153bc583a | ||
|
|
fbbda20792 | ||
|
|
ef8018d2f7 | ||
|
|
46cd74b0b8 | ||
|
|
db01f7be19 | ||
|
|
25c550de3a | ||
|
|
683e8c4490 | ||
|
|
a0296b8ccb | ||
|
|
6771960e9e | ||
|
|
1c4d2fae56 | ||
|
|
3b1dff3b83 | ||
|
|
84967e5cdf | ||
|
|
6b758eeffa | ||
|
|
8265c65e4c | ||
|
|
676268b62d | ||
|
|
9921c43c3f | ||
|
|
fc13a22162 | ||
|
|
6e2cb4f90d | ||
|
|
8b0c7f579a | ||
|
|
ad126efee8 | ||
|
|
4e97d3ed72 | ||
|
|
57dee30526 | ||
|
|
0bfdf2f8ef | ||
|
|
81d917e27a | ||
|
|
3421af10a9 | ||
|
|
f75917d9c4 | ||
|
|
c05040795d | ||
|
|
7d1b13c88d | ||
|
|
f8785e78a5 | ||
|
|
9bd1246ada | ||
|
|
60c97c9fa9 | ||
|
|
9c52e195ea | ||
|
|
e4c5df7a2e | ||
|
|
2eae40eff2 | ||
|
|
9e662aa202 | ||
|
|
f2a6d00348 | ||
|
|
7fd149fd2e | ||
|
|
3c37c2e774 | ||
|
|
84f19bb4df | ||
|
|
27540b09ce | ||
|
|
549e38fb78 | ||
|
|
2655f816af | ||
|
|
016023fa50 | ||
|
|
2a53614723 | ||
|
|
212fd1bc14 | ||
|
|
93bd4ed79e | ||
|
|
2bb6f33112 | ||
|
|
68e275ab07 | ||
|
|
498759890f | ||
|
|
bf1f9b6a32 | ||
|
|
b69c1e46d1 | ||
|
|
dcd2862e72 | ||
|
|
7a08403636 | ||
|
|
574f9cb5d4 | ||
|
|
613e42ce70 | ||
|
|
43f479299a | ||
|
|
ecb3264b52 | ||
|
|
e89520d7d6 | ||
|
|
892bb90d9d | ||
|
|
a2ea08ba5f | ||
|
|
50632dd9dd | ||
|
|
06702aa48a | ||
|
|
f2988870d5 | ||
|
|
f0ea8d30ca | ||
|
|
c3909ccc70 | ||
|
|
ae15a7d739 | ||
|
|
01e88b3807 | ||
|
|
effb9d56d9 | ||
|
|
2a5a89fe50 | ||
|
|
2ce1e78053 | ||
|
|
3bbbc80511 | ||
|
|
19128fb08e | ||
|
|
0945bbe5cf | ||
|
|
ced6975fc8 | ||
|
|
fb436996c0 | ||
|
|
50f1fe544e | ||
|
|
746643bf8e | ||
|
|
65a69ef1e4 | ||
|
|
6483182ef6 | ||
|
|
784a91709c | ||
|
|
82a52e065f | ||
|
|
a1f6738cf1 | ||
|
|
a231945842 | ||
|
|
a507d5963c | ||
|
|
92806d5257 | ||
|
|
2d6d1cb444 | ||
|
|
ef68f4f4f8 | ||
|
|
9440df4445 | ||
|
|
4e0efb76cd | ||
|
|
18c28cc3fd | ||
|
|
d40e8caff9 | ||
|
|
3161cc4d45 | ||
|
|
ed808c3be6 | ||
|
|
4099a477d1 | ||
|
|
6542da7e30 | ||
|
|
8336611f54 | ||
|
|
819c6c96e8 | ||
|
|
e9ff358ac2 | ||
|
|
f79fd36c03 | ||
|
|
edd99cdeb2 | ||
|
|
9825754b32 | ||
|
|
6a18b40c97 | ||
|
|
d00f0bae1d | ||
|
|
fb042a36b6 | ||
|
|
6c27b04f83 | ||
|
|
9322b4ab4c | ||
|
|
5464d4c010 | ||
|
|
351622c4a2 | ||
|
|
44736ac461 | ||
|
|
a2da95b12b | ||
|
|
8ae5b68504 | ||
|
|
1a3c3af072 | ||
|
|
5dde542952 | ||
|
|
4922a13b68 | ||
|
|
a05860515e | ||
|
|
7399d6421e | ||
|
|
269dea70a1 | ||
|
|
46690fa72b | ||
|
|
8c9096b55b | ||
|
|
67cc3a6409 | ||
|
|
83ec6d69eb | ||
|
|
65a76b96c6 | ||
|
|
dd7f22e021 | ||
|
|
1a06d9906b | ||
|
|
6fb301970b | ||
|
|
3baf7060f7 | ||
|
|
0b82b85166 | ||
|
|
82dfd807c8 | ||
|
|
af9612968b | ||
|
|
70455f56a2 | ||
|
|
f839fd2bb4 | ||
|
|
1a79e0624c | ||
|
|
26e46ff06c | ||
|
|
71e67b50b2 | ||
|
|
174ed9f6d1 | ||
|
|
60146ed2c5 | ||
|
|
df7abdfc06 | ||
|
|
6ec2b20a23 | ||
|
|
09d0f68c4c | ||
|
|
d6b7311c66 | ||
|
|
f1a2239c97 | ||
|
|
977ad141ee | ||
|
|
06f88eb5a3 | ||
|
|
daf39a4095 | ||
|
|
0973d79c31 | ||
|
|
257481bad5 | ||
|
|
68e08f13a1 | ||
|
|
3234898892 | ||
|
|
82f7779a23 | ||
|
|
bd9b83540b | ||
|
|
4f0ee7b0b6 | ||
|
|
4437bfa840 | ||
|
|
b9e34c99ca | ||
|
|
71fa872d5c | ||
|
|
377857915f | ||
|
|
ec5020ca3d | ||
|
|
f1bed08c13 | ||
|
|
2cb663cd6a | ||
|
|
fa1ca5fba0 | ||
|
|
02b935bcde | ||
|
|
b8d5c53813 | ||
|
|
bb90fe0d4b | ||
|
|
ba283e3dc0 | ||
|
|
9ae8a48dcd | ||
|
|
774f707c9f | ||
|
|
a6417c5757 | ||
|
|
aebb610403 | ||
|
|
fdbfd759af | ||
|
|
0213f66eb6 | ||
|
|
1de385a410 | ||
|
|
6011b440a8 | ||
|
|
de4b3c186e | ||
|
|
4c8ff47ae7 | ||
|
|
54269ba0bf | ||
|
|
361579ba31 | ||
|
|
000785c29f | ||
|
|
2e6bc5e5b4 | ||
|
|
11f6972ec9 | ||
|
|
df801b4205 | ||
|
|
433f2bafd8 | ||
|
|
c5fc1a4648 | ||
|
|
33d486b160 | ||
|
|
7e57c192ee | ||
|
|
e4f7e26ad5 | ||
|
|
89b4acdfaf | ||
|
|
d856ef53a7 | ||
|
|
3256d601db | ||
|
|
938f4f2b4d | ||
|
|
6999ab09b6 | ||
|
|
c2d52bcfd2 | ||
|
|
d1c37f84aa | ||
|
|
83f9defc65 | ||
|
|
c4dbab2637 | ||
|
|
e76fafdccf | ||
|
|
73e3e4e226 | ||
|
|
6535d654d7 | ||
|
|
a224a46654 | ||
|
|
59c0784cd6 | ||
|
|
b544d5f781 | ||
|
|
ebf1373339 | ||
|
|
75a07f527e | ||
|
|
4b75bf7cce | ||
|
|
e260ba0e49 | ||
|
|
95dfb5b538 | ||
|
|
7d3f070e27 | ||
|
|
01fdbaa990 | ||
|
|
05acd26efe | ||
|
|
1421b9c0af | ||
|
|
6197ae25c6 | ||
|
|
de0883b14b | ||
|
|
acc6db901c | ||
|
|
7f463830bd | ||
|
|
6a27fb2959 | ||
|
|
21867c9cd4 | ||
|
|
276821e0b5 | ||
|
|
8028b1ddad | ||
|
|
5abbd818d3 | ||
|
|
43944a7d31 | ||
|
|
8bdc137917 | ||
|
|
02fb15228b | ||
|
|
59a1db9068 | ||
|
|
8e956893ca | ||
|
|
d960e03acf | ||
|
|
99666440cf | ||
|
|
f274c0bde3 | ||
|
|
d1082e55a4 | ||
|
|
af0d1980c6 | ||
|
|
a6183e0ccf | ||
|
|
eea40c69f7 | ||
|
|
13ae773868 | ||
|
|
6f0fcc9d1b | ||
|
|
7d98c0bb1c | ||
|
|
82d77dc10f | ||
|
|
ae1f35f515 | ||
|
|
66f3fd2e07 | ||
|
|
cf346f6aa3 | ||
|
|
34d3aac4b0 | ||
|
|
c22b6ca670 | ||
|
|
fa1b29a99f | ||
|
|
d61238c832 | ||
|
|
28b432058a | ||
|
|
4360ada3e4 | ||
|
|
5336bf3fe2 | ||
|
|
6d5db1cb3a | ||
|
|
9fffaa20a2 | ||
|
|
fd73a4ac92 | ||
|
|
29a6c70fc3 | ||
|
|
96f6c644bd | ||
|
|
7c12bb1e20 | ||
|
|
10e796f956 | ||
|
|
071077f2dc | ||
|
|
afe957674c | ||
|
|
307b098f83 | ||
|
|
95a793dd5a | ||
|
|
a0057911c1 | ||
|
|
93c75b5fef | ||
|
|
53d7e57142 | ||
|
|
2c4a891a89 | ||
|
|
8e0c7759be | ||
|
|
41dc01ea3c | ||
|
|
9c985edb6b | ||
|
|
69ef309cb5 | ||
|
|
f10bf38292 | ||
|
|
02f68b104b | ||
|
|
8bc5a75249 | ||
|
|
97e4cca252 | ||
|
|
18d41b52a2 | ||
|
|
26c0f82edf | ||
|
|
c12436afb0 | ||
|
|
fead885aa4 | ||
|
|
e680bb1548 | ||
|
|
6e82d38249 | ||
|
|
9f63299a1a | ||
|
|
702f31c935 | ||
|
|
08db282a07 | ||
|
|
080a394bb3 | ||
|
|
8fb429e073 | ||
|
|
00a3ff89e4 | ||
|
|
8f3b854559 | ||
|
|
05edb144b2 | ||
|
|
8c173c840b | ||
|
|
b540f44d6c | ||
|
|
7493093a1a | ||
|
|
cf68541520 | ||
|
|
b4ee4413cc | ||
|
|
f214830d0f | ||
|
|
c92070a5a2 | ||
|
|
102ca5403d | ||
|
|
7fd57b88dc | ||
|
|
5f57694148 | ||
|
|
73c97e85d4 | ||
|
|
ccde0c20ab | ||
|
|
d2d3c67144 | ||
|
|
6d5af81f68 | ||
|
|
2e9d4125ed | ||
|
|
56c32beebc | ||
|
|
faa67e0bb6 | ||
|
|
ffebe8e901 | ||
|
|
2cafe2d98e | ||
|
|
d03038d976 | ||
|
|
7e392da78a | ||
|
|
f8f3456b92 | ||
|
|
3b637eefaa | ||
|
|
46e1d28881 | ||
|
|
f23cc8b99f | ||
|
|
6843347dd7 |
No files matched your search
@@ -0,0 +1,90 @@
|
|||||||
|
# ********** INDEX **********
|
||||||
|
#
|
||||||
|
# - APP STORE
|
||||||
|
# - DAILY.CO VIDEO
|
||||||
|
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||||
|
# - HUBSPOT
|
||||||
|
# - OFFICE 365
|
||||||
|
# - SLACK
|
||||||
|
# - STRIPE
|
||||||
|
# - TANDEM
|
||||||
|
# - ZOOM
|
||||||
|
# - GIPHY
|
||||||
|
# - VITAL
|
||||||
|
|
||||||
|
# - APP STORE **********************************************************************************************
|
||||||
|
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||||
|
# - DAILY.CO VIDEO
|
||||||
|
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
|
||||||
|
GOOGLE_API_CREDENTIALS=
|
||||||
|
|
||||||
|
# To enable Login with Google you need to:
|
||||||
|
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||||
|
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||||
|
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||||
|
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||||
|
GOOGLE_LOGIN_ENABLED=false
|
||||||
|
|
||||||
|
# - HUBSPOT
|
||||||
|
# Used for the HubSpot integration
|
||||||
|
# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret
|
||||||
|
HUBSPOT_CLIENT_ID=""
|
||||||
|
HUBSPOT_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# - OFFICE 365
|
||||||
|
# Used for the Office 365 / Outlook.com Calendar / MS Teams integration
|
||||||
|
# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret
|
||||||
|
MS_GRAPH_CLIENT_ID=
|
||||||
|
MS_GRAPH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# - SLACK
|
||||||
|
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
|
||||||
|
SLACK_SIGNING_SECRET=
|
||||||
|
SLACK_CLIENT_ID=
|
||||||
|
SLACK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# - STRIPE
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||||
|
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||||
|
STRIPE_CLIENT_ID= # ca_...
|
||||||
|
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||||
|
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||||
|
|
||||||
|
# - TANDEM
|
||||||
|
# Used for the Tandem integration -- contact support@tandem.chat for API access.
|
||||||
|
TANDEM_CLIENT_ID=""
|
||||||
|
TANDEM_CLIENT_SECRET=""
|
||||||
|
TANDEM_BASE_URL="https://tandem.chat"
|
||||||
|
|
||||||
|
# - ZOOM
|
||||||
|
# Used for the Zoom integration
|
||||||
|
# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret
|
||||||
|
ZOOM_CLIENT_ID=
|
||||||
|
ZOOM_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# - GIPHY
|
||||||
|
# Used for the Giphy integration
|
||||||
|
# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key
|
||||||
|
GIPHY_API_KEY=
|
||||||
|
|
||||||
|
# - VITAL
|
||||||
|
# Used for the vital integration
|
||||||
|
# @see https://github.com/calcom/cal.com/#obtaining-vital-api-keys
|
||||||
|
VITAL_API_KEY=
|
||||||
|
VITAL_WEBHOOK_SECRET=
|
||||||
|
# "sandbox" | "prod" | "production" | "development"
|
||||||
|
VITAL_DEVELOPMENT_MODE="sandbox"
|
||||||
|
# "us" | "eu"
|
||||||
|
VITAL_REGION="us"
|
||||||
|
|
||||||
|
# - ZAPIER
|
||||||
|
# Used for the Zapier integration
|
||||||
|
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
|
||||||
|
ZAPIER_INVITE_LINK=""
|
||||||
|
# *********************************************************************************************************
|
||||||
+22
-62
@@ -5,14 +5,6 @@
|
|||||||
# - SHARED
|
# - SHARED
|
||||||
# - NEXTAUTH
|
# - NEXTAUTH
|
||||||
# - E-MAIL SETTINGS
|
# - E-MAIL SETTINGS
|
||||||
# - APP STORE
|
|
||||||
# - DAILY.CO VIDEO
|
|
||||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
|
||||||
# - OFFICE 365
|
|
||||||
# - SLACK
|
|
||||||
# - STRIPE
|
|
||||||
# - TANDEM
|
|
||||||
# - ZOOM
|
|
||||||
|
|
||||||
# - LICENSE *************************************************************************************************
|
# - LICENSE *************************************************************************************************
|
||||||
# Set this value to 'agree' to accept our license:
|
# Set this value to 'agree' to accept our license:
|
||||||
@@ -23,6 +15,9 @@
|
|||||||
# - You can not repackage or sell the codebase
|
# - You can not repackage or sell the codebase
|
||||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||||
|
# To enable enterprise-only features, fill your license key in here.
|
||||||
|
# @see https://console.cal.com
|
||||||
|
CALCOM_LICENSE_KEY=
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
# - DATABASE ************************************************************************************************
|
# - DATABASE ************************************************************************************************
|
||||||
@@ -33,6 +28,8 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
|
|||||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||||
|
NEXT_PUBLIC_CONSOLE_URL='http://localhost:3004'
|
||||||
|
NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js'
|
||||||
|
|
||||||
# To enable SAML login, set both these variables
|
# To enable SAML login, set both these variables
|
||||||
# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login
|
# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login
|
||||||
@@ -47,15 +44,18 @@ PGSSLMODE=
|
|||||||
|
|
||||||
# - NEXTAUTH
|
# - NEXTAUTH
|
||||||
# @see: https://github.com/calendso/calendso/issues/263
|
# @see: https://github.com/calendso/calendso/issues/263
|
||||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
# @see: https://next-auth.js.org/configuration/options#nextauth_url
|
||||||
|
# Required for Vercel hosting - set NEXTAUTH_URL to equal your NEXT_PUBLIC_WEBAPP_URL
|
||||||
# NEXTAUTH_URL='http://localhost:3000'
|
# NEXTAUTH_URL='http://localhost:3000'
|
||||||
NEXTAUTH_URL=
|
NEXTAUTH_URL=
|
||||||
JWT_SECRET='secret'
|
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||||
|
# You can use: `openssl rand -base64 32` to generate one
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
# Used for cross-domain cookie authentication
|
# Used for cross-domain cookie authentication
|
||||||
NEXTAUTH_COOKIE_DOMAIN=.example.com
|
NEXTAUTH_COOKIE_DOMAIN=.example.com
|
||||||
|
|
||||||
# Remove this var if you don't want Cal to collect anonymous usage
|
# Set this to '1' if you don't want Cal to collect anonymous usage
|
||||||
NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r
|
CALCOM_TELEMETRY_DISABLED=
|
||||||
|
|
||||||
# ApiKey for cronjobs
|
# ApiKey for cronjobs
|
||||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||||
@@ -74,15 +74,24 @@ NEXT_PUBLIC_ZENDESK_KEY=
|
|||||||
# Help Scout Config
|
# Help Scout Config
|
||||||
NEXT_PUBLIC_HELPSCOUT_KEY=
|
NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||||
|
|
||||||
|
# Inbox to send user feedback
|
||||||
|
SEND_FEEDBACK_EMAIL=
|
||||||
|
|
||||||
# This is used so we can bypass emails in auth flows for E2E testing
|
# This is used so we can bypass emails in auth flows for E2E testing
|
||||||
# Set it to "1" if you need to run E2E tests locally
|
# Set it to "1" if you need to run E2E tests locally
|
||||||
NEXT_PUBLIC_IS_E2E=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
|
|
||||||
# Used for internal billing system
|
# Used for internal billing system
|
||||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT=
|
|
||||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
STRIPE_PRO_PLAN_PRODUCT_ID=
|
||||||
|
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
|
||||||
|
STRIPE_FREE_PLAN_PRODUCT_ID=
|
||||||
|
|
||||||
|
# Use for internal Public API Keys and optional
|
||||||
|
API_KEY_PREFIX=cal_
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
# - E-MAIL SETTINGS *****************************************************************************************
|
# - E-MAIL SETTINGS *****************************************************************************************
|
||||||
@@ -107,52 +116,3 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
|||||||
## @see https://support.google.com/accounts/answer/185833
|
## @see https://support.google.com/accounts/answer/185833
|
||||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||||
# **********************************************************************************************************
|
# **********************************************************************************************************
|
||||||
|
|
||||||
# - APP STORE **********************************************************************************************
|
|
||||||
# - DAILY.CO VIDEO
|
|
||||||
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
|
|
||||||
GOOGLE_API_CREDENTIALS='{}'
|
|
||||||
# To enable Login with Google you need to:
|
|
||||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
|
||||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
|
||||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
|
||||||
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
|
||||||
GOOGLE_LOGIN_ENABLED=false
|
|
||||||
|
|
||||||
# - OFFICE 365
|
|
||||||
# Used for the Office 365 / Outlook.com Calendar / MS Teams integration
|
|
||||||
# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret
|
|
||||||
MS_GRAPH_CLIENT_ID=
|
|
||||||
MS_GRAPH_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# - SLACK
|
|
||||||
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
|
|
||||||
SLACK_SIGNING_SECRET=
|
|
||||||
SLACK_CLIENT_ID=
|
|
||||||
SLACK_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# - STRIPE
|
|
||||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
|
||||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
|
||||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
|
||||||
STRIPE_CLIENT_ID= # ca_...
|
|
||||||
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
|
||||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
|
||||||
|
|
||||||
# - TANDEM
|
|
||||||
# Used for the Tandem integration -- contact support@tandem.chat to for API access.
|
|
||||||
TANDEM_CLIENT_ID=""
|
|
||||||
TANDEM_CLIENT_SECRET=""
|
|
||||||
TANDEM_BASE_URL="https://tandem.chat"
|
|
||||||
|
|
||||||
# - ZOOM
|
|
||||||
# Used for the Zoom integration
|
|
||||||
# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret
|
|
||||||
ZOOM_CLIENT_ID=
|
|
||||||
ZOOM_CLIENT_SECRET=
|
|
||||||
# *********************************************************************************************************
|
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
Fixes # (issue)
|
Fixes # (issue)
|
||||||
|
|
||||||
|
<!-- Please provide a loom video for visual changes to speed up reviews
|
||||||
|
Loom Video: https://www.loom.com/
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Environment**: Staging(main branch) / Production
|
||||||
|
|
||||||
## Type of change
|
## Type of change
|
||||||
|
|
||||||
<!-- Please delete options that are not relevant. -->
|
<!-- Please delete options that are not relevant. -->
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"♻️ autoupdate":
|
||||||
|
- apps/web/public/static/locales/**/*.json
|
||||||
|
|
||||||
|
automerge:
|
||||||
|
- apps/web/public/static/locales/**/*.json
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Add bugs to Bug Hunting
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- labeled
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- labeled
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
add-to-project:
|
||||||
|
name: Add issue to project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/add-to-project@v0.1.0
|
||||||
|
with:
|
||||||
|
project-url: https://github.com/orgs/calcom/projects/1
|
||||||
|
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
labeled: 🐛 bug, 🎨 needs design, 🙋🏻♂️help wanted, 🚨 urgent
|
||||||
@@ -3,24 +3,22 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- "**.tsx?"
|
||||||
jobs:
|
jobs:
|
||||||
types:
|
check-types:
|
||||||
name: Check types
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node: ["14.x"]
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Use Node ${{ matrix.node }}
|
- name: Use Node 16.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
node-version: 16.x
|
||||||
- run: yarn
|
cache: "yarn"
|
||||||
|
- run: yarn --frozen-lockfile
|
||||||
- run: yarn type-check
|
- run: yarn type-check
|
||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||||
- cron: "0,15,30,45 * * * *"
|
- cron: "0,15,30,45 * * * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron:
|
cron-bookingReminder:
|
||||||
env:
|
env:
|
||||||
APP_URL: ${{ secrets.APP_URL }}
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||||
- cron: "0,15,30,45 * * * *"
|
- cron: "0,15,30,45 * * * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron:
|
cron-downgradeUsers:
|
||||||
env:
|
env:
|
||||||
APP_URL: ${{ secrets.APP_URL }}
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@1.4.2
|
uses: crowdin/github-action@1.4.9
|
||||||
with:
|
with:
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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@v2
|
||||||
|
- 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,99 @@
|
|||||||
|
name: E2E Embed tests and booking flow(for non-embed as well)
|
||||||
|
on:
|
||||||
|
pull_request: # So we can test on forks
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
# Embed e2e - tests verify booking flow which is applicable to non-embed case also. So, don't ignore apps/web changes.
|
||||||
|
paths:
|
||||||
|
- apps/web/**
|
||||||
|
- packages/embeds/**
|
||||||
|
jobs:
|
||||||
|
e2e-embed:
|
||||||
|
timeout-minutes: 20
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||||
|
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET: secret
|
||||||
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
|
GOOGLE_LOGIN_ENABLED: true
|
||||||
|
# CRON_API_KEY: xxx
|
||||||
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||||
|
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||||
|
PAYMENT_FEE_FIXED: 10
|
||||||
|
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
SAML_ADMINS: pro@example.com
|
||||||
|
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||||
|
NEXT_PUBLIC_IS_E2E: 1
|
||||||
|
# EMAIL_FROM: e2e@cal.com
|
||||||
|
# EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||||
|
# EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||||
|
# EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||||
|
# EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||||
|
# MS_GRAPH_CLIENT_ID: xxx
|
||||||
|
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||||
|
# ZOOM_CLIENT_ID: xxx
|
||||||
|
# ZOOM_CLIENT_SECRET: xxx
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:12.1
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: calendso
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||||
|
fetch-depth: 2
|
||||||
|
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||||
|
- name: Use Node 16.x
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16.x
|
||||||
|
cache: "yarn"
|
||||||
|
|
||||||
|
- name: Cache playwright binaries
|
||||||
|
uses: actions/cache@v2
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Caches/ms-playwright
|
||||||
|
~/.cache/ms-playwright
|
||||||
|
${{ github.workspace }}/node_modules/playwright
|
||||||
|
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
restore-keys: cache-playwright-
|
||||||
|
- run: yarn --frozen-lockfile
|
||||||
|
- name: Install playwright deps
|
||||||
|
# if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: yarn playwright install --with-deps
|
||||||
|
- name: Run Tests
|
||||||
|
run: yarn turbo run embed-tests-update-snapshots:ci --scope=@calcom/embed-react --concurrency=1
|
||||||
|
|
||||||
|
- name: Upload embed-core results
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: test-results-core
|
||||||
|
path: packages/embeds/embed-core/playwright/results
|
||||||
|
|
||||||
|
- name: Upload embed-react results
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: test-results-react
|
||||||
|
path: packages/embeds/embed-react/playwright/results
|
||||||
+27
-37
@@ -1,33 +1,35 @@
|
|||||||
name: E2E test
|
name: E2E test
|
||||||
on:
|
on:
|
||||||
pull_request_target: # So we can test on forks
|
pull_request: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths-ignore:
|
paths:
|
||||||
- public/static/locales/**
|
- apps/web/**
|
||||||
|
- packages/**
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
e2e:
|
||||||
timeout-minutes: 15
|
timeout-minutes: 20
|
||||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node: ["14.x"]
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||||
JWT_SECRET: secret
|
NEXTAUTH_SECRET: secret
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
GOOGLE_LOGIN_ENABLED: true
|
GOOGLE_LOGIN_ENABLED: true
|
||||||
# CRON_API_KEY: xxx
|
# CRON_API_KEY: xxx
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||||
|
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||||
|
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||||
PAYMENT_FEE_FIXED: 10
|
PAYMENT_FEE_FIXED: 10
|
||||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
@@ -43,6 +45,8 @@ jobs:
|
|||||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||||
# ZOOM_CLIENT_ID: xxx
|
# ZOOM_CLIENT_ID: xxx
|
||||||
# ZOOM_CLIENT_SECRET: xxx
|
# ZOOM_CLIENT_SECRET: xxx
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:12.1
|
image: postgres:12.1
|
||||||
@@ -58,23 +62,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||||
- name: Use Node ${{ matrix.node }}
|
- name: Use Node 16.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
node-version: 16.x
|
||||||
# cache: "yarn"
|
cache: "yarn"
|
||||||
# cache-dependency-path: yarn.lock
|
|
||||||
|
|
||||||
- name: Turbo Cache
|
|
||||||
id: turbo-cache
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: .turbo
|
|
||||||
key: turbo-${{ github.job }}-${{ github.event.pull_request.head.ref }}-${{ github.event.pull_request.head.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
turbo-${{ github.job }}-${{ github.event.pull_request.head.ref }}-
|
|
||||||
- run: yarn
|
|
||||||
- name: Cache playwright binaries
|
- name: Cache playwright binaries
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v2
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
@@ -82,22 +76,18 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/Library/Caches/ms-playwright
|
~/Library/Caches/ms-playwright
|
||||||
~/.cache/ms-playwright
|
~/.cache/ms-playwright
|
||||||
**/node_modules/playwright
|
${{ github.workspace }}/node_modules/playwright
|
||||||
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
|
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
restore-keys: cache-playwright-
|
||||||
|
- run: yarn --frozen-lockfile
|
||||||
- name: Install playwright deps
|
- name: Install playwright deps
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
run: yarn playwright install --with-deps
|
run: yarn playwright install --with-deps
|
||||||
- run: yarn test-e2e
|
- run: yarn test-e2e
|
||||||
|
|
||||||
- name: Upload videos
|
- name: Upload test results
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: videos
|
name: test-results
|
||||||
path: |
|
path: test-results
|
||||||
test-results
|
|
||||||
playwright/screenshots
|
|
||||||
playwright/videos
|
|
||||||
playwright/results
|
|
||||||
playwright/reports
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: "Pull Request Labeler"
|
||||||
|
on:
|
||||||
|
- pull_request_target
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
labeler:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/labeler@v4
|
||||||
|
with:
|
||||||
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
+10
-12
@@ -1,15 +1,14 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- "**.tsx?"
|
||||||
|
- "**.jsx?"
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
strategy:
|
runs-on: ubuntu-latest
|
||||||
matrix:
|
|
||||||
node: ["14.x"]
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
@@ -18,16 +17,15 @@ jobs:
|
|||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Use Node.js 14.x
|
- name: Use Node 16.x
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
node-version: 16.x
|
||||||
# cache: "yarn"
|
cache: "yarn"
|
||||||
# cache-dependency-path: yarn.lock
|
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||||
run: yarn
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: yarn lint:report
|
run: yarn lint:report
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: "Meta Workflow: Require Conditional Status Checks"
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
required-checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Ensure All Conditional Checks Have Passed
|
||||||
|
uses: blend/require-conditional-status-checks@2022.02.04
|
||||||
|
with:
|
||||||
|
timeout: 15m
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
interval: 20s
|
||||||
|
# Question marks aren't working
|
||||||
|
# @see https://github.com/blend/require-conditional-status-checks/pull/11
|
||||||
|
checks-yaml: |
|
||||||
|
- job: lint
|
||||||
|
paths:
|
||||||
|
- /**/*.jsx
|
||||||
|
- /**/*.js
|
||||||
|
- /**/*.tsx
|
||||||
|
- /**/*.ts
|
||||||
|
- job: check-types
|
||||||
|
paths:
|
||||||
|
- /**/*.tsx
|
||||||
|
- /**/*.ts
|
||||||
|
- job: e2e
|
||||||
|
paths:
|
||||||
|
- /apps/web/**
|
||||||
|
- /packages/**
|
||||||
|
- job: e2e-embed
|
||||||
|
paths:
|
||||||
|
- /apps/web/**
|
||||||
|
- /packages/embeds/**
|
||||||
@@ -39,6 +39,7 @@ yarn-error.log*
|
|||||||
.env.production.local
|
.env.production.local
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.appStore.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -71,3 +72,7 @@ dist
|
|||||||
|
|
||||||
# Linting
|
# Linting
|
||||||
lint-results
|
lint-results
|
||||||
|
|
||||||
|
# Snaplet
|
||||||
|
.snaplet/snapshots
|
||||||
|
.snaplet/structure.d.ts
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
[submodule "apps/console"]
|
||||||
|
path = apps/console
|
||||||
|
url = https://github.com/calcom/console.git
|
||||||
|
branch = main
|
||||||
[submodule "apps/api"]
|
[submodule "apps/api"]
|
||||||
path = apps/api
|
path = apps/api
|
||||||
url = https://github.com/calcom/api.git
|
url = https://github.com/calcom/api.git
|
||||||
|
|||||||
@@ -2,3 +2,5 @@
|
|||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
yarn lint-staged
|
yarn lint-staged
|
||||||
|
|
||||||
|
yarn app-store:build && git add packages/app-store/*.generated.*
|
||||||
+1
-2
@@ -4,5 +4,4 @@ version = 1
|
|||||||
autoupdate_label = "♻️ autoupdate"
|
autoupdate_label = "♻️ autoupdate"
|
||||||
|
|
||||||
[approve]
|
[approve]
|
||||||
auto_approve_usernames = ["dependabot"]
|
auto_approve_usernames = ["dependabot", "github-actions"]
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "cl4u26bwz7962859ply7ibuo43t",
|
||||||
|
"databaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"version": "0.3.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// This transform config was generated by Snaplet.
|
||||||
|
// Snaplet found fields that may contain personally identifiable information (PII)
|
||||||
|
// and used that to populate this file.
|
||||||
|
import { copycat as c } from "@snaplet/copycat";
|
||||||
|
|
||||||
|
import type { Transform } from "./structure";
|
||||||
|
|
||||||
|
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
|
||||||
|
return !!x && typeof x === "object" && key in x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceKeyIfExists<T extends string>(x: object, key: T) {
|
||||||
|
if (hasStringProp(x, key)) {
|
||||||
|
return { ...x, [key]: c.uuid(x[key]) };
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug(x: string) {
|
||||||
|
return c.words(x, { max: 3 }).split(" ").join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSensitiveKeys(record: object) {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
...replaceKeyIfExists(record, "client_id"),
|
||||||
|
...replaceKeyIfExists(record, "client_secret"),
|
||||||
|
...replaceKeyIfExists(record, "public_key"),
|
||||||
|
...replaceKeyIfExists(record, "api_key"),
|
||||||
|
...replaceKeyIfExists(record, "signing_secret"),
|
||||||
|
...replaceKeyIfExists(record, "access_token"),
|
||||||
|
...replaceKeyIfExists(record, "refresh_token"),
|
||||||
|
...replaceKeyIfExists(record, "stripe_user_id"),
|
||||||
|
...replaceKeyIfExists(record, "stripe_publishable_key"),
|
||||||
|
...replaceKeyIfExists(record, "accessToken"),
|
||||||
|
...replaceKeyIfExists(record, "refreshToken"),
|
||||||
|
...replaceKeyIfExists(record, "bot_user_id"),
|
||||||
|
...replaceKeyIfExists(record, "app_id"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateUsername = (x: string) => `${c.firstName(x)}-${c.lastName(x)}${c.int(x, { min: 2, max: 99 })}`;
|
||||||
|
|
||||||
|
const config: Transform = () => ({
|
||||||
|
public: {
|
||||||
|
ApiKey: ({ row }) => ({
|
||||||
|
hashedKey: c.uuid(row.hashedKey),
|
||||||
|
}),
|
||||||
|
App: ({ row }) => ({
|
||||||
|
keys: replaceSensitiveKeys(row.keys),
|
||||||
|
}),
|
||||||
|
Attendee: ({ row }) => ({
|
||||||
|
email: c.email(row.email),
|
||||||
|
name: c.fullName(row.name),
|
||||||
|
timeZone: c.timezone(row.timeZone),
|
||||||
|
}),
|
||||||
|
Credential: ({ row }) => ({
|
||||||
|
key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key),
|
||||||
|
}),
|
||||||
|
EventType: ({ row }) => ({
|
||||||
|
slug: generateSlug(row.slug),
|
||||||
|
timeZone: c.timezone(row.timeZone),
|
||||||
|
eventName: c.words(row.eventName, { max: 3 }),
|
||||||
|
}),
|
||||||
|
ResetPasswordRequest: ({ row }) => ({
|
||||||
|
email: c.email(row.email),
|
||||||
|
}),
|
||||||
|
Schedule: ({ row }) => ({
|
||||||
|
timeZone: c.timezone(row.timeZone),
|
||||||
|
}),
|
||||||
|
Team: ({ row }) => ({
|
||||||
|
bio: c.sentence(row.bio),
|
||||||
|
name: c.words(row.name, { max: 2 }),
|
||||||
|
slug: generateSlug(row.slug),
|
||||||
|
}),
|
||||||
|
users: ({ row }) =>
|
||||||
|
row.role !== "ADMIN"
|
||||||
|
? {
|
||||||
|
bio: c.sentence(row.bio),
|
||||||
|
email: c.email(row.email),
|
||||||
|
name: c.fullName(row.name),
|
||||||
|
password: c.password(row.password),
|
||||||
|
timeZone: c.timezone(row.timeZone),
|
||||||
|
username: generateUsername(row.username),
|
||||||
|
}
|
||||||
|
: row,
|
||||||
|
VerificationToken: ({ row }) => ({
|
||||||
|
token: c.uuid(row.token),
|
||||||
|
}),
|
||||||
|
Account: ({ row }) => ({
|
||||||
|
access_token: c.uuid(row.access_token),
|
||||||
|
refresh_token: c.uuid(row.refresh_token),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
Vendored
+2
-1
@@ -7,6 +7,7 @@
|
|||||||
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
|
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
|
||||||
"ban.spellright", // Spell check for docs
|
"ban.spellright", // Spell check for docs
|
||||||
"stripe.vscode-stripe", // stripe VSCode extension
|
"stripe.vscode-stripe", // stripe VSCode extension
|
||||||
"Prisma.prisma" // syntax|format|completion for prisma
|
"Prisma.prisma", // syntax|format|completion for prisma
|
||||||
|
"rebornix.project-snippets" // Share useful snippets between collaborators
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Vendored
+78
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"presentation": {
|
||||||
|
"echo": false,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "dedicated",
|
||||||
|
"showReuseMessage": true
|
||||||
|
},
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Launch Cal.com Development terminals",
|
||||||
|
"dependsOn": [
|
||||||
|
"Web App(3000)",
|
||||||
|
"Website(3001)",
|
||||||
|
"Embed Core(3100)",
|
||||||
|
"Embed React(3101)",
|
||||||
|
"Prisma Studio(5555)",
|
||||||
|
"Maildev(587)"
|
||||||
|
],
|
||||||
|
// Mark as the default build task so cmd/ctrl+shift+b will create them
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
// Try start the task on folder open
|
||||||
|
"runOptions": {
|
||||||
|
"runOn": "folderOpen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The name that shows up in terminal tab
|
||||||
|
"label": "Web App(3000)",
|
||||||
|
// The task will launch a shell
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn dev",
|
||||||
|
// Set the shell type
|
||||||
|
// Mark as a background task to avoid the spinner animation on the terminal tab
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Website(3001)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd apps/website && yarn dev",
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Embed Core(3100)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd packages/embeds/embed-core && yarn dev",
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Embed React(3101)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd packages/embeds/embed-react && yarn dev",
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Prisma Studio(5555)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn db-studio",
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Maildev(587)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "maildev -s 587",
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
- Demonstrating empathy and kindness toward other people
|
||||||
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
- Giving and gracefully accepting constructive feedback
|
||||||
|
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
- Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
bailey@cal.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
+18
-1
@@ -1,9 +1,26 @@
|
|||||||
# Contributing to Cal.com
|
# Contributing to Cal.com
|
||||||
|
|
||||||
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||||
|
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
|
## Areas of expertise
|
||||||
|
|
||||||
|
### Legend
|
||||||
|
|
||||||
|
✅ = has knowledge
|
||||||
|
|
||||||
|
🥇 = is their main priority
|
||||||
|
|
||||||
|
⚠️ = is the only one with knowledge
|
||||||
|
|
||||||
|
👀 = has no knowledge but wants to be onboarded
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://dynamic-svgs.vercel.app/image.svg?dark">
|
||||||
|
<img alt="Areas of expertise table" src="https://dynamic-svgs.vercel.app/image.svg">
|
||||||
|
</picture>
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
The development branch is `main`. This is the branch that all pull
|
The development branch is `main`. This is the branch that all pull
|
||||||
|
|||||||
@@ -18,29 +18,33 @@
|
|||||||
<a href="https://cal.com">Website</a>
|
<a href="https://cal.com">Website</a>
|
||||||
·
|
·
|
||||||
<a href="https://github.com/calcom/cal.com/issues">Issues</a>
|
<a href="https://github.com/calcom/cal.com/issues">Issues</a>
|
||||||
|
·
|
||||||
|
<a href="https://cal.com/roadmap">Roadmap</a>
|
||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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://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://www.producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></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=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
||||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
||||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
||||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=social"></a>
|
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
||||||
|
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||||
|
<a href="https://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>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- ABOUT THE PROJECT -->
|
<!-- ABOUT THE PROJECT -->
|
||||||
|
|
||||||
## About The Project
|
## About The Project
|
||||||
|
|
||||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/134363898-4b29e18f-3e61-42b7-95bc-10891056249d.gif">
|
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/176390354-f1bc7069-0341-437a-9bb8-eb41092b4016.gif">
|
||||||
|
|
||||||
# Scheduling infrastructure for absolutely everyone
|
# Scheduling infrastructure for absolutely everyone
|
||||||
|
|
||||||
@@ -80,7 +84,7 @@ To get a local copy up and running, please follow these simple steps.
|
|||||||
|
|
||||||
Here is what you need to be able to run Cal.
|
Here is what you need to be able to run Cal.
|
||||||
|
|
||||||
- Node.js (Version: >=14.x <15)
|
- Node.js (Version: >=14.x <17)
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- Yarn _(recommended)_
|
- Yarn _(recommended)_
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ Here is what you need to be able to run Cal.
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Clone the repo into a public GitHub repository (to comply with AGPLv3. To clone in a private repository, [acquire a commercial license](https://cal.com/sales))
|
1. Clone the repo into a public GitHub repository (or fork https://github.com/calcom/cal.com/fork). If you plan to distribute the code, keep the source code public to comply with [AGPLv3](https://github.com/calcom/cal.com/blob/main/LICENSE). To clone in a private repository, [acquire a commercial license](https://cal.com/sales))
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/calcom/cal.com.git
|
git clone https://github.com/calcom/cal.com.git
|
||||||
@@ -102,19 +106,14 @@ Here is what you need to be able to run Cal.
|
|||||||
cd cal.com
|
cd cal.com
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Copy `apps/web/.env.example` to `apps/web/.env`
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp apps/web/.env.example apps/web/.env
|
|
||||||
cp packages/prisma/.env.example packages/prisma/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Install packages with yarn
|
1. Install packages with yarn
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn
|
yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
1. Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the .env file.
|
||||||
|
|
||||||
#### Quick start with `yarn dx`
|
#### Quick start with `yarn dx`
|
||||||
|
|
||||||
> - **Requires Docker and Docker Compose to be installed**
|
> - **Requires Docker and Docker Compose to be installed**
|
||||||
@@ -126,15 +125,15 @@ yarn dx
|
|||||||
|
|
||||||
#### Development tip
|
#### Development tip
|
||||||
|
|
||||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `apps/web/.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
|
```sh
|
||||||
echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Manual setup
|
#### 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 `packages/prisma/.env` file. Replace `<user>`, `<pass>`, `<db-host>`, `<db-port>` with their applicable values
|
||||||
|
|
||||||
```
|
```
|
||||||
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
||||||
@@ -195,11 +194,11 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
|||||||
|
|
||||||
### E2E-Testing
|
### E2E-Testing
|
||||||
|
|
||||||
|
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# In first terminal. Must run on port 3000.
|
# In a terminal just run:
|
||||||
yarn dx
|
yarn test-e2e
|
||||||
# In second terminal
|
|
||||||
yarn workspace @calcom/web test-e2e
|
|
||||||
|
|
||||||
# To open last HTML report run:
|
# To open last HTML report run:
|
||||||
yarn workspace @calcom/web playwright-report
|
yarn workspace @calcom/web playwright-report
|
||||||
@@ -213,7 +212,13 @@ yarn workspace @calcom/web playwright-report
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Apply database migrations by running <b>one of</b> the following commands:
|
1. Check if dependencies got added/updated/removed
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Apply database migrations by running <b>one of</b> the following commands:
|
||||||
|
|
||||||
In a development environment, run:
|
In a development environment, run:
|
||||||
|
|
||||||
@@ -229,16 +234,13 @@ yarn workspace @calcom/web playwright-report
|
|||||||
yarn workspace @calcom/prisma db-deploy
|
yarn workspace @calcom/prisma db-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Check the `.env.example` and compare it to your current `.env` file. In case there are any fields not present
|
1. Check for `.env` variables changes
|
||||||
in your current `.env`, add them there.
|
|
||||||
|
|
||||||
For the current version, especially check if the variable `BASE_URL` is present and properly set in your environment, for example:
|
```sh
|
||||||
|
yarn predev
|
||||||
```
|
|
||||||
BASE_URL='https://yourdomain.com'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Start the server. In a development environment, just do:
|
1. Start the server. In a development environment, just do:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn dev
|
yarn dev
|
||||||
@@ -251,7 +253,7 @@ yarn workspace @calcom/web playwright-report
|
|||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Enjoy the new version.
|
1. Enjoy the new version.
|
||||||
<!-- DEPLOYMENT -->
|
<!-- DEPLOYMENT -->
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -276,11 +278,18 @@ The Docker configuration can be found [in our docker repository](https://github.
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
|
### Vercel
|
||||||
|
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_SECRET,NEXTAUTH_URL,CALENDSO_ENCRYPTION_KEY,NEXT_PUBLIC_LICENSE_CONSENT,CALCOM_LICENSE_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com)
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
<!-- ROADMAP -->
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
<a href="https://cal.com/roadmap"><img src="https://user-images.githubusercontent.com/8019099/176388295-25081ca4-207e-4468-8079-37b195fa8e59.png" alt="Cal.com Roadmap" /></a>
|
||||||
|
|
||||||
See the [roadmap project](https://github.com/orgs/calcom/projects/1) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
|
||||||
|
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||||
|
|
||||||
<!-- CONTRIBUTING -->
|
<!-- CONTRIBUTING -->
|
||||||
|
|
||||||
@@ -349,6 +358,7 @@ oauth_config:
|
|||||||
bot:
|
bot:
|
||||||
- chat:write
|
- chat:write
|
||||||
- commands
|
- commands
|
||||||
|
- chat:write.public
|
||||||
settings:
|
settings:
|
||||||
interactivity:
|
interactivity:
|
||||||
is_enabled: true
|
is_enabled: true
|
||||||
@@ -391,6 +401,30 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
|||||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
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.
|
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.
|
||||||
|
|
||||||
|
### Obtaining HubSpot Client ID and Secret
|
||||||
|
|
||||||
|
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||||
|
2. From within the home of the Developer account page, go to "Manage apps".
|
||||||
|
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.
|
||||||
|
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspotothercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||||
|
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 Vital API Keys
|
||||||
|
|
||||||
|
1. Open [Vital](https://tryvital.io/) and click Get API Keys.
|
||||||
|
1. Create a team with the team name you desire
|
||||||
|
1. Head to the configuration section on the sidebar of the dashboard
|
||||||
|
1. Click on API keys and you'll find your sandbox `api_key`.
|
||||||
|
1. Copy your `api_key` to `VITAL_API_KEY` in the .env.appStore file.
|
||||||
|
1. Open [Vital Webhooks](https://app.tryvital.io/team/{team_id}/webhooks) and add `<CALCOM BASE URL>/api/integrations/vital/webhook` as webhook for connected applications.
|
||||||
|
1. Select all events for the webhook you interested, e.g. `sleep_created`
|
||||||
|
1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file.
|
||||||
|
|
||||||
<!-- LICENSE -->
|
<!-- LICENSE -->
|
||||||
|
|
||||||
## License
|
## License
|
||||||
@@ -411,6 +445,6 @@ Special thanks to these amazing projects which help power Cal.com:
|
|||||||
- [Tailwind CSS](https://tailwindcss.com/)
|
- [Tailwind CSS](https://tailwindcss.com/)
|
||||||
- [Prisma](https://prisma.io/)
|
- [Prisma](https://prisma.io/)
|
||||||
|
|
||||||
[<img src="https://jitsu.com/img/powered-by-jitsu.png?gh=true">](https://jitsu.com/?utm_source=cal.com-gihub)
|
<a href="https://jitsu.com/?utm_source=cal.com-gihub"><img height="40px" src="https://jitsu.com/img/powered-by-jitsu.png?gh=true" alt="Jitsu.com"></a>
|
||||||
|
|
||||||
Cal.com is an [open startup](https://jitsu.com) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics.
|
Cal.com is an [open startup](https://cal.com/open) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics.
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Security
|
||||||
|
Contact: security@cal.com
|
||||||
|
|
||||||
|
Based on [https://supabase.com/.well-known/security.txt](https://supabase.com/.well-known/security.txt)
|
||||||
|
|
||||||
|
At Cal.com, we consider the security of our systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present.
|
||||||
|
|
||||||
|
If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our clients and our systems.
|
||||||
|
|
||||||
|
## Out of scope vulnerabilities:
|
||||||
|
|
||||||
|
* Clickjacking on pages with no sensitive actions.
|
||||||
|
* Unauthenticated/logout/login CSRF.
|
||||||
|
* Attacks requiring MITM or physical access to a user's device.
|
||||||
|
* Any activity that could lead to the disruption of our service (DoS).
|
||||||
|
* Content spoofing and text injection issues without showing an attack vector/without being able to modify HTML/CSS.
|
||||||
|
* Email spoofing
|
||||||
|
* Missing DNSSEC, CAA, CSP headers
|
||||||
|
* Lack of Secure or HTTP only flag on non-sensitive cookies
|
||||||
|
* Deadlinks
|
||||||
|
|
||||||
|
## Please do the following:
|
||||||
|
|
||||||
|
* E-mail your findings to [security@cal.com](mailto:security@cal.com).
|
||||||
|
* Do not run automated scanners on our infrastructure or dashboard. If you wish to do this, contact us and we will set up a sandbox for you.
|
||||||
|
* Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data,
|
||||||
|
* Do not reveal the problem to others until it has been resolved,
|
||||||
|
* Do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties,
|
||||||
|
* Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Usually, the IP address or the URL of the affected system and a description of the vulnerability will be sufficient, but complex vulnerabilities may require further explanation.
|
||||||
|
|
||||||
|
## What we promise:
|
||||||
|
|
||||||
|
* We will respond to your report within 3 business days with our evaluation of the report and an expected resolution date,
|
||||||
|
* If you have followed the instructions above, we will not take any legal action against you in regard to the report,
|
||||||
|
* We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission,
|
||||||
|
* We will keep you informed of the progress towards resolving the problem,
|
||||||
|
* In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise), and
|
||||||
|
* We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved.
|
||||||
@@ -18,7 +18,11 @@
|
|||||||
"description": "Application Key for symmetric encryption and decryption. Must be 32 bytes for AES256 encryption algorithm.",
|
"description": "Application Key for symmetric encryption and decryption. Must be 32 bytes for AES256 encryption algorithm.",
|
||||||
"value": "secret"
|
"value": "secret"
|
||||||
},
|
},
|
||||||
"JWT_SECRET": "secret"
|
"NEXTAUTH_SECRET": "secret",
|
||||||
|
"NEXTAUTH_URL": {
|
||||||
|
"description": "Replace HEROKU_APP_NAME with the name given to your app",
|
||||||
|
"value": "https://HEROKU_APP_NAME.herokuapp.com"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postdeploy": "cd packages/prisma && npx prisma migrate deploy"
|
"postdeploy": "cd packages/prisma && npx prisma migrate deploy"
|
||||||
|
|||||||
+1
-1
Submodule apps/api updated: 187b97afa1...74c227f5f1.
Submodule
+1
Submodule apps/console added at 74bd9e2831.
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="https://cal.com">Website</a>
|
<a href="https://cal.com">Website</a>
|
||||||
·
|
·
|
||||||
<a href="https://github.com/calcom/docs/issues">Community Support</a>
|
<a href="https://github.com/calcom/cal.com/issues">Community Support</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Cal.com Documentation
|
# Cal.com Documentation
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
function getAnchor(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9 ]/g, "")
|
||||||
|
.replace(/[ ]/g, "-")
|
||||||
|
.replace(/ /g, "%20");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Anchor({ as, children }) {
|
||||||
|
const anchor = getAnchor(children);
|
||||||
|
const link = `#${anchor}`;
|
||||||
|
const Component = as || "div";
|
||||||
|
return (
|
||||||
|
<Component id={anchor}>
|
||||||
|
<a href={link} className="anchor-link">
|
||||||
|
§
|
||||||
|
</a>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
// Define general type for useWindowSize hook, which includes width and height
|
||||||
|
export interface Size {
|
||||||
|
width: number | undefined;
|
||||||
|
height: number | undefined;
|
||||||
|
}
|
||||||
|
// Hook from: https://usehooks.com/useWindowSize/
|
||||||
|
export function useWindowSize(): Size {
|
||||||
|
// Initialize state with undefined width/height so server and client renders match
|
||||||
|
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
|
||||||
|
const [windowSize, setWindowSize] = useState<Size>({
|
||||||
|
width: undefined,
|
||||||
|
height: undefined,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
// Handler to call on window resize
|
||||||
|
function handleResize() {
|
||||||
|
// Set window width/height to state
|
||||||
|
setWindowSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Add event listener
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
// Call handler right away so state gets updated with initial window size
|
||||||
|
handleResize();
|
||||||
|
// Remove event listener on cleanup
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []); // Empty array ensures that effect is only run on mount
|
||||||
|
return windowSize;
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const withNextra = require("nextra")({
|
const withNextra = require("nextra")({
|
||||||
theme: "nextra-theme-docs",
|
theme: "nextra-theme-docs",
|
||||||
themeConfig: "./theme.config.js",
|
themeConfig: "./theme.config.js",
|
||||||
unstable_staticImage: true,
|
unstable_staticImage: true,
|
||||||
});
|
});
|
||||||
module.exports = withNextra();
|
module.exports = withNextra({
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
// This redirects requests recieved at /api to /public-api to workaround nextjs default use of /api.
|
||||||
|
{
|
||||||
|
source: "/api",
|
||||||
|
destination: "/public-api",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -15,14 +15,15 @@
|
|||||||
"author": "Cal.com, Inc.",
|
"author": "Cal.com, Inc.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^12.1.0",
|
"iframe-resizer-react": "^1.1.0",
|
||||||
|
"next": "^12.1.6",
|
||||||
"nextra": "^1.1.0",
|
"nextra": "^1.1.0",
|
||||||
"nextra-theme-docs": "^1.2.2",
|
"nextra-theme-docs": "^1.2.2",
|
||||||
"react": "^17.0.2",
|
"react": "^18.1.0",
|
||||||
"react-dom": "^17.0.2"
|
"react-dom": "^18.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@calcom/config": "*",
|
"@calcom/config": "*",
|
||||||
"eslint": "^8.10.0"
|
"eslint": "^8.15.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,6 @@ This is also the home of our design system documentation and developer docs.
|
|||||||
|
|
||||||
If you don't already know what Cal.com is about, please head over to [our website](https://cal.com), where you can learn more about the product before venturing into the documentation.
|
If you don't already know what Cal.com is about, please head over to [our website](https://cal.com), where you can learn more about the product before venturing into the documentation.
|
||||||
|
|
||||||
Want to help make these docs even better? This site is fully open source, and the source code is available on [GitHub](https://github.com/calcom/docs). You can also click the edit button at the bottom of any page to start editing the source code and start a pull request.
|
Want to help make these docs even better? This site is fully open source, and the source code is available on [GitHub](https://github.com/calcom/cal.com/tree/main/apps/docs). You can also click the edit button at the bottom of any page to start editing the source code and start a pull request.
|
||||||
|
|
||||||
<Bleed></Bleed>
|
<Bleed></Bleed>
|
||||||
@@ -2,17 +2,18 @@
|
|||||||
title: Embed
|
title: Embed
|
||||||
---
|
---
|
||||||
|
|
||||||
|
import Anchor from "../../components/Anchor"
|
||||||
|
|
||||||
# Embed
|
# Embed
|
||||||
|
|
||||||
The Embed allows your website visitors to book a meeting with you directly from your website.
|
The Embed allows your website visitors to book a meeting with you directly from your website.
|
||||||
|
|
||||||
## Install on any website
|
## Install on any website
|
||||||
|
|
||||||
TODO: Mention possibility of installation through tag managers as well
|
Install the following Vanilla JS Snippet to get embed to work on any website. After that you can <a href="#popular-ways-in-which-you-can-embed-on-your-website">choose any of the ways</a> to show your Cal Link embedded on your website.
|
||||||
|
|
||||||
- _Step-1._ Install the Vanilla JS Snippet
|
```html
|
||||||
|
<script>
|
||||||
```javascript
|
|
||||||
(function (C, A, L) {
|
(function (C, A, L) {
|
||||||
let p = function (a, ar) {
|
let p = function (a, ar) {
|
||||||
a.q.push(ar);
|
a.q.push(ar);
|
||||||
@@ -40,14 +41,10 @@ TODO: Mention possibility of installation through tag managers as well
|
|||||||
}
|
}
|
||||||
p(cal, ar);
|
p(cal, ar);
|
||||||
};
|
};
|
||||||
})(window, "https://cal.com/embed.js", "init");
|
})(window, "https://cal.com/embed.js", "init");
|
||||||
```
|
Cal("init")
|
||||||
|
</script>
|
||||||
- _Step-2_. Initialize it
|
```
|
||||||
|
|
||||||
```javascript
|
|
||||||
Cal("init)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install with a Framework
|
## Install with a Framework
|
||||||
|
|
||||||
@@ -63,9 +60,9 @@ yarn add @calcom/embed-react
|
|||||||
|
|
||||||
You can use Vanilla JS Snippet to install
|
You can use Vanilla JS Snippet to install
|
||||||
|
|
||||||
## Popular ways in which you can embed on your website
|
<Anchor as="H2">Popular ways in which you can embed on your website</Anchor>
|
||||||
|
|
||||||
Assuming that you have followed the steps of installing and initializing the snippet, you can add show the embed in following ways:
|
Assuming that you have followed the steps of installing and initializing the snippet, you can show the embed in following ways:
|
||||||
|
|
||||||
### Inline
|
### Inline
|
||||||
|
|
||||||
@@ -74,20 +71,29 @@ Show the embed inline inside a container element. It would take the width and he
|
|||||||
<details>
|
<details>
|
||||||
<summary>_Vanilla JS_</summary>
|
<summary>_Vanilla JS_</summary>
|
||||||
|
|
||||||
```javascript
|
```html
|
||||||
Cal("inline", {
|
<script>
|
||||||
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
Cal("inline", {
|
||||||
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
||||||
config: {
|
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
||||||
name: "John Doe", // Prefill Name
|
config: {
|
||||||
email: "johndoe@gmail.com", // Prefill Email
|
name: "John Doe", // Prefill Name
|
||||||
notes: "Test Meeting", // Prefill Notes
|
email: "johndoe@gmail.com", // Prefill Email
|
||||||
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
notes: "Test Meeting", // Prefill Notes
|
||||||
theme: "dark", // "dark" or "light" theme
|
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
||||||
},
|
theme: "dark", // "dark" or "light" theme
|
||||||
});
|
},
|
||||||
```
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
*Sample sandbox*
|
||||||
|
```
|
||||||
|
<iframe src="https://codesandbox.io/embed/vanilla-js-inline-embed-r27n67?fontsize=14&hidenavigation=1&theme=dark"
|
||||||
|
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||||
|
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||||
|
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||||
|
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||||
|
></iframe>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
####
|
####
|
||||||
@@ -112,6 +118,14 @@ const MyComponent = () => (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*Sample sandbox*
|
||||||
|
|
||||||
|
<iframe src="https://codesandbox.io/embed/cal-component-embed-inline-demo-react-typescript-d1zlcn?fontsize=14&hidenavigation=1&theme=dark"
|
||||||
|
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||||
|
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||||
|
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||||
|
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||||
|
></iframe>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Popup on any existing element
|
### Popup on any existing element
|
||||||
@@ -124,9 +138,16 @@ To show the embed as a popup on clicking an element, add `data-cal-link` attribu
|
|||||||
|
|
||||||
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
|
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
|
||||||
|
|
||||||
|
*Sample sandbox*
|
||||||
|
<iframe src="https://codesandbox.io/embed/popup-on-click-of-an-existing-element-y9lcuo?fontsize=14&hidenavigation=1&theme=dark"
|
||||||
|
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||||
|
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||||
|
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||||
|
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||||
|
></iframe>
|
||||||
|
|
||||||
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
|
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>React</summary>
|
<summary>React</summary>
|
||||||
```jsx
|
```jsx
|
||||||
@@ -135,11 +156,36 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link`
|
|||||||
const MyComponent = ()=> {
|
const MyComponent = ()=> {
|
||||||
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
|
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
````
|
*Sample sandbox*
|
||||||
|
<iframe src="https://codesandbox.io/embed/embed-popup-on-click-of-an-existing-element-demo-react-sc967e?fontsize=14&hidenavigation=1&theme=dark"
|
||||||
|
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||||
|
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||||
|
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||||
|
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||||
|
></iframe>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
### Full Screen
|
|
||||||
|
### Floating pop-up button
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
Cal("floatingButton", {
|
||||||
|
// The link that you want to embed. It would open https://cal.com/jane in embed
|
||||||
|
calLink: "jane",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
*Sample sandbox*
|
||||||
|
<iframe src="https://codesandbox.io/embed/embed-floating-button-popup-all-websites-cg7pru?fontsize=14&hidenavigation=1&theme=dark"
|
||||||
|
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||||
|
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||||
|
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||||
|
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||||
|
></iframe>
|
||||||
|
|
||||||
## Supported Instructions
|
## Supported Instructions
|
||||||
|
|
||||||
@@ -149,8 +195,10 @@ Consider an instruction as a function with that name and that would be called wi
|
|||||||
|
|
||||||
Appends embed inline as the child of the element.
|
Appends embed inline as the child of the element.
|
||||||
|
|
||||||
```javascript
|
```html
|
||||||
|
<script>
|
||||||
Cal("inline", { elementOrSelector, calLink });
|
Cal("inline", { elementOrSelector, calLink });
|
||||||
|
</script>
|
||||||
````
|
````
|
||||||
|
|
||||||
- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly
|
- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly
|
||||||
@@ -161,8 +209,10 @@ Cal("inline", { elementOrSelector, calLink });
|
|||||||
|
|
||||||
Configure UI for embed. Make it look part of your webpage.
|
Configure UI for embed. Make it look part of your webpage.
|
||||||
|
|
||||||
```javascript
|
```html
|
||||||
|
<script>
|
||||||
Cal("inline", { styles });
|
Cal("inline", { styles });
|
||||||
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two.
|
- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two.
|
||||||
@@ -173,15 +223,18 @@ Usage:
|
|||||||
|
|
||||||
If you want to open cal link on some action. Make it pop open instantly by preloading it.
|
If you want to open cal link on some action. Make it pop open instantly by preloading it.
|
||||||
|
|
||||||
```javascript
|
```html
|
||||||
|
<script>
|
||||||
Cal("preload", { calLink });
|
Cal("preload", { calLink });
|
||||||
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john]()
|
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john]()
|
||||||
|
|
||||||
## Actions
|
## Actions
|
||||||
You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term events to not confuse it with Cal Events.
|
You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term "events" to not confuse it with Cal Events.
|
||||||
```javascript
|
```html
|
||||||
|
<script>
|
||||||
Cal("on", {
|
Cal("on", {
|
||||||
action: "ANY_ACTION_NAME",
|
action: "ANY_ACTION_NAME",
|
||||||
callback: (e)=>{
|
callback: (e)=>{
|
||||||
@@ -191,11 +244,12 @@ Cal("on", {
|
|||||||
const {data, type, namespace} = e.detail;
|
const {data, type, namespace} = e.detail;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
Following are the list of supported actions.
|
Following are the list of supported actions.
|
||||||
-
|
-
|
||||||
| action | description | properties |
|
| Action | Description | Properties |
|
||||||
|----------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|----------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| eventTypeSelected | When user chooses an event-type from the listing. | eventType:object // Event Type that has been selected" |
|
| eventTypeSelected | When user chooses an event-type from the listing. | eventType:object // Event Type that has been selected" |
|
||||||
| bookingSuccessful | When the booking is successfully done. It might not be confirmed. | confirmed: boolean; //Whether confirmation from organizer is pending or not <br/><br/>eventType: "Object for Event Type that has been booked"; <br/><br/>date: string; // Date of Event <br/><br/>duration: number; //Duration of booked Event <br/><br/>organizer: object //Organizer details like name, timezone, email |
|
| bookingSuccessful | When the booking is successfully done. It might not be confirmed. | confirmed: boolean; //Whether confirmation from organizer is pending or not <br/><br/>eventType: "Object for Event Type that has been booked"; <br/><br/>date: string; // Date of Event <br/><br/>duration: number; //Duration of booked Event <br/><br/>organizer: object //Organizer details like name, timezone, email |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ To remove a product from your account that isn't listed in your Google Account,
|
|||||||
6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
|
6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
|
||||||
7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
|
7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
|
||||||
8. Select Web Application as the Application Type.
|
8. Select Web Application as the Application Type.
|
||||||
9. Under Authorized redirect URI's, select Add URI and then add the URI `<CALENDSO URL>/api/integrations/googlecalendar/callback` replacing CALENDSO URL with the URI at which your application runs.
|
9. Under Authorized redirect URI's, select Add URI and then add the two URIs `<CALENDSO URL>/api/integrations/googlecalendar/callback` and `<CALENDSO URL>/api/auth/callback/google` replacing CALENDSO 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
Title: Slack
|
||||||
|
---
|
||||||
|
|
||||||
|
# Slack
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
Connecting the bot is easy - If you are a workspace admin, the install button will add the bot to the workspace and also authorize your account with the bot. If you are a normal user, the install button will connect your Slack account with Cal.com. This will allow you to perform commands in Slack.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
`/today` - This command will display all meetings you have in your Cal.com profile for the current day. This will send a hidden message (not visible to anyone other than you) to the channel you issued the command in.
|
||||||
|
|
||||||
|
`/create-event` - It will display a modal allowing you to simply create a meeting invite with anyone in Slack. Success/Error information will be displayed in a private direct message from the bot.
|
||||||
|
|
||||||
|
`/links` - This command will post all your Cal.com meeting links into the current Slack channel you are in. **Note**: The bot needs to have permission to talk in the channel you are sending the message in. Otherwise, you won't be able to send your links.
|
||||||
|
|
||||||
|
As this is the beggining stage of our Slack integration, we plan on adding more commands in the future that will further improve your Cal.com experience.
|
||||||
|
|
||||||
|
## Self-Hosted
|
||||||
|
If you are using our self-hosted version, please refer to our documentation in
|
||||||
|
[cal.com/README.md](https://github.com/calcom/cal.com/blob/main/README.md#obtaining-slack-client-id-and-secret-and-signing-secret)
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"event-types": "Event Types",
|
"event-types": "Event Types",
|
||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
|
"public-api": "API",
|
||||||
"webhooks": "Webhooks",
|
"webhooks": "Webhooks",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Bleed from 'nextra-theme-docs/bleed'
|
||||||
|
import Head from "next/head";
|
||||||
|
import IframeResizer from "iframe-resizer-react";
|
||||||
|
import {useWindowSize} from "../lib/useWindowSize";
|
||||||
|
|
||||||
|
|
||||||
|
<Bleed full>
|
||||||
|
<Head><title>Public API | Cal.com</title></Head>
|
||||||
|
<IframeResizer
|
||||||
|
autoResize
|
||||||
|
src={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://developer.cal.com"}
|
||||||
|
frameBorder="0"
|
||||||
|
style={{
|
||||||
|
width: useWindowSize().width > 768 ? "calc(100vw - 16rem)": "100vw",
|
||||||
|
minHeight: useWindowSize().width > 768 ? "100vh" : "200vh",
|
||||||
|
height: "auto",
|
||||||
|
border: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Bleed>
|
||||||
@@ -18,7 +18,7 @@ You need a PostgresDB database hosted somewhere. [Heroku](https://www.heroku.com
|
|||||||
git clone https://github.com/<your-fork>/cal.com.git
|
git clone https://github.com/<your-fork>/cal.com.git
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Copy the `.env.example` file in `apps/web`, rename it to `.env` and fill it with your settings ([See manual setup](https://github.com/calcom/cal.com#manual) and [Obtaining the Google API Credentials](https://github.com/calcom/cal.com#obtaining-the-google-api-credentials))
|
2. Copy the `.env.example` file in `apps/web`, rename it to `.env` and fill it with your settings ([See manual setup](https://github.com/calcom/cal.com#manual-setup) and [Obtaining the Google API Credentials](https://github.com/calcom/cal.com#obtaining-the-google-api-credentials))
|
||||||
|
|
||||||
3. Install packages with `yarn`
|
3. Install packages with `yarn`
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SEO_IMG_DEFAULT, DOCS_URL } from "@calcom/lib/constants";
|
||||||
|
|
||||||
const themeConfig = {
|
const themeConfig = {
|
||||||
github: "https://github.com/calcom/cal.com",
|
github: "https://github.com/calcom/cal.com",
|
||||||
docsRepositoryBase: "https://github.com/calcom/cal.com/blob/main/apps/docs/pages",
|
docsRepositoryBase: "https://github.com/calcom/cal.com/blob/main/apps/docs/pages",
|
||||||
@@ -18,11 +20,11 @@ const themeConfig = {
|
|||||||
content="Explore advice and explanations for all of our features, and discover new tips and tricks to get the most out of your subscription."
|
content="Explore advice and explanations for all of our features, and discover new tips and tricks to get the most out of your subscription."
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:image" content="https://cal.com/og-image.png" />
|
<meta name="twitter:image" content={SEO_IMG_DEFAULT} />
|
||||||
<meta name="twitter:site:domain" content="docs.cal.com" />
|
<meta name="twitter:site:domain" content="docs.cal.com" />
|
||||||
<meta name="twitter:url" content="https://docs.cal.com" />
|
<meta name="twitter:url" content={DOCS_URL} />
|
||||||
<meta name="og:title" content="Cal.com Documentation" />
|
<meta name="og:title" content="Cal.com Documentation" />
|
||||||
<meta name="og:image" content="https://cal.com/og-image.png" />
|
<meta name="og:image" content={SEO_IMG_DEFAULT} />
|
||||||
<meta name="apple-mobile-web-app-title" content="Cal.com Docs" />
|
<meta name="apple-mobile-web-app-title" content="Cal.com Docs" />
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
-3623
File diff suppressed because it is too large.
Load diff
File renamed without changes.
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import * as OpenAPISnippet from "openapi-snippet";
|
||||||
|
|
||||||
|
export const requestSnippets = {
|
||||||
|
generators: {
|
||||||
|
curl_bash: {
|
||||||
|
title: "cURL (bash)",
|
||||||
|
syntax: "bash",
|
||||||
|
},
|
||||||
|
curl_powershell: {
|
||||||
|
title: "cURL (PowerShell)",
|
||||||
|
syntax: "powershell",
|
||||||
|
},
|
||||||
|
curl_cmd: {
|
||||||
|
title: "cURL (CMD)",
|
||||||
|
syntax: "bash",
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
title: "Node",
|
||||||
|
syntax: "node",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultExpanded: true,
|
||||||
|
languages: ["node", "curl_bash"],
|
||||||
|
};
|
||||||
|
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
||||||
|
// configuration will be added programatically
|
||||||
|
// Custom Plugin
|
||||||
|
export const SnippedGenerator = {
|
||||||
|
statePlugins: {
|
||||||
|
// extend some internals to gain information about current path, method and spec in the generator function metioned later
|
||||||
|
spec: {
|
||||||
|
wrapSelectors: {
|
||||||
|
requestFor: (ori, system) => (state, path, method) => {
|
||||||
|
return ori(path, method)
|
||||||
|
?.set("spec", state.get("json", {}))
|
||||||
|
?.setIn(["oasPathMethod", "path"], path)
|
||||||
|
?.setIn(["oasPathMethod", "method"], method);
|
||||||
|
},
|
||||||
|
mutatedRequestFor: (ori) => (state, path, method) => {
|
||||||
|
return ori(path, method)
|
||||||
|
?.set("spec", state.get("json", {}))
|
||||||
|
?.setIn(["oasPathMethod", "path"], path)
|
||||||
|
?.setIn(["oasPathMethod", "method"], method);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// extend the request snippets core plugin
|
||||||
|
requestSnippets: {
|
||||||
|
wrapSelectors: {
|
||||||
|
// add additional snippet generators here
|
||||||
|
getSnippetGenerators:
|
||||||
|
(ori, system) =>
|
||||||
|
(state, ...args) =>
|
||||||
|
ori(state, ...args)
|
||||||
|
// add node native snippet generator
|
||||||
|
// .set(
|
||||||
|
// // key
|
||||||
|
// "node_native",
|
||||||
|
// // config and generator function
|
||||||
|
// system.Im.fromJS({
|
||||||
|
// title: "NodeJs Native",
|
||||||
|
// syntax: "javascript",
|
||||||
|
// hostname: "test",
|
||||||
|
// fn: (req) => {
|
||||||
|
// // get extended info about request
|
||||||
|
// const { spec, oasPathMethod } = req.toJS();
|
||||||
|
// const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// // run OpenAPISnippet for target node
|
||||||
|
// const targets = ["node_native"];
|
||||||
|
// let snippet;
|
||||||
|
// try {
|
||||||
|
// // set request snippet content
|
||||||
|
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||||
|
// spec,
|
||||||
|
// path,
|
||||||
|
// method,
|
||||||
|
// targets
|
||||||
|
// // Since I don't know why hostname was undefinedundefined, I harcoded it here
|
||||||
|
// ).snippets[0].content;
|
||||||
|
// } catch (err) {
|
||||||
|
// // set to error in case it happens the npm package has some flaws
|
||||||
|
// snippet = JSON.stringify(snippet);
|
||||||
|
// }
|
||||||
|
// // return stringified snipped
|
||||||
|
// return snippet;
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
// )
|
||||||
|
.set(
|
||||||
|
// key
|
||||||
|
"node_fetch",
|
||||||
|
// config and generator function
|
||||||
|
system.Im.fromJS({
|
||||||
|
title: "NodeJS",
|
||||||
|
syntax: "javascript",
|
||||||
|
fn: (req) => {
|
||||||
|
// get extended info about request
|
||||||
|
const { spec, oasPathMethod } = req.toJS();
|
||||||
|
const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// run OpenAPISnippet for target node
|
||||||
|
const targets = ["node_fetch"];
|
||||||
|
let snippet;
|
||||||
|
try {
|
||||||
|
// set request snippet content
|
||||||
|
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||||
|
.content;
|
||||||
|
} catch (err) {
|
||||||
|
// set to error in case it happens the npm package has some flaws
|
||||||
|
snippet = JSON.stringify(snippet);
|
||||||
|
}
|
||||||
|
// return stringified snipped
|
||||||
|
return snippet;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
// key
|
||||||
|
"shell_httpie",
|
||||||
|
// config and generator function
|
||||||
|
system.Im.fromJS({
|
||||||
|
title: "HTTPie",
|
||||||
|
syntax: "bash",
|
||||||
|
fn: (req) => {
|
||||||
|
// get extended info about request
|
||||||
|
const { spec, oasPathMethod } = req.toJS();
|
||||||
|
const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// run OpenAPISnippet for target node
|
||||||
|
const targets = ["shell_httpie"];
|
||||||
|
let snippet;
|
||||||
|
try {
|
||||||
|
// set request snippet content
|
||||||
|
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||||
|
.content;
|
||||||
|
} catch (err) {
|
||||||
|
// set to error in case it happens the npm package has some flaws
|
||||||
|
snippet = JSON.stringify(snippet);
|
||||||
|
}
|
||||||
|
// return stringified snipped
|
||||||
|
return snippet;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
// key
|
||||||
|
"php_curl",
|
||||||
|
// config and generator function
|
||||||
|
system.Im.fromJS({
|
||||||
|
title: "PHP",
|
||||||
|
syntax: "php",
|
||||||
|
fn: (req) => {
|
||||||
|
// get extended info about request
|
||||||
|
const { spec, oasPathMethod } = req.toJS();
|
||||||
|
const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// run OpenAPISnippet for target node
|
||||||
|
const targets = ["php_curl"];
|
||||||
|
let snippet;
|
||||||
|
try {
|
||||||
|
// set request snippet content
|
||||||
|
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||||
|
.content;
|
||||||
|
} catch (err) {
|
||||||
|
// set to error in case it happens the npm package has some flaws
|
||||||
|
snippet = JSON.stringify(snippet);
|
||||||
|
}
|
||||||
|
// return stringified snipped
|
||||||
|
return snippet;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
// key
|
||||||
|
"java_okhttp",
|
||||||
|
// config and generator function
|
||||||
|
system.Im.fromJS({
|
||||||
|
title: "Java",
|
||||||
|
syntax: "java",
|
||||||
|
fn: (req) => {
|
||||||
|
// get extended info about request
|
||||||
|
const { spec, oasPathMethod } = req.toJS();
|
||||||
|
const { path, method } = oasPathMethod;
|
||||||
|
console.log(spec, oasPathMethod, path, method);
|
||||||
|
// run OpenAPISnippet for target node
|
||||||
|
const targets = ["java_okhttp"];
|
||||||
|
let snippet;
|
||||||
|
try {
|
||||||
|
// set request snippet content
|
||||||
|
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||||
|
.content;
|
||||||
|
} catch (err) {
|
||||||
|
// set to error in case it happens the npm package has some flaws
|
||||||
|
snippet = JSON.stringify(snippet);
|
||||||
|
}
|
||||||
|
// return stringified snipped
|
||||||
|
return snippet;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
// .set(
|
||||||
|
// // key
|
||||||
|
// "java",
|
||||||
|
// // config and generator function
|
||||||
|
// system.Im.fromJS({
|
||||||
|
// title: "Java (Unirest)",
|
||||||
|
// syntax: "java",
|
||||||
|
// fn: (req) => {
|
||||||
|
// // get extended info about request
|
||||||
|
// const { spec, oasPathMethod } = req.toJS();
|
||||||
|
// const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// // run OpenAPISnippet for target node
|
||||||
|
// const targets = ["java"];
|
||||||
|
// let snippet;
|
||||||
|
// try {
|
||||||
|
// // set request snippet content
|
||||||
|
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||||
|
// spec,
|
||||||
|
// path,
|
||||||
|
// method,
|
||||||
|
// targets
|
||||||
|
// ).snippets[0].content;
|
||||||
|
// } catch (err) {
|
||||||
|
// // set to error in case it happens the npm package has some flaws
|
||||||
|
// snippet = JSON.stringify(snippet);
|
||||||
|
// }
|
||||||
|
// // return stringified snipped
|
||||||
|
// return snippet;
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
// )
|
||||||
|
// .set(
|
||||||
|
// // key
|
||||||
|
// "c_libcurl",
|
||||||
|
// // config and generator function
|
||||||
|
// system.Im.fromJS({
|
||||||
|
// title: "C (libcurl) ",
|
||||||
|
// syntax: "bash",
|
||||||
|
// fn: (req) => {
|
||||||
|
// // get extended info about request
|
||||||
|
// const { spec, oasPathMethod } = req.toJS();
|
||||||
|
// const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// // run OpenAPISnippet for target node
|
||||||
|
// const targets = ["c_libcurl"];
|
||||||
|
// let snippet;
|
||||||
|
// try {
|
||||||
|
// // set request snippet content
|
||||||
|
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||||
|
// spec,
|
||||||
|
// path,
|
||||||
|
// method,
|
||||||
|
// targets
|
||||||
|
// ).snippets[0].content;
|
||||||
|
// } catch (err) {
|
||||||
|
// // set to error in case it happens the npm package has some flaws
|
||||||
|
// snippet = JSON.stringify(snippet);
|
||||||
|
// }
|
||||||
|
// // return stringified snipped
|
||||||
|
// return snippet;
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
// )
|
||||||
|
.set(
|
||||||
|
// key
|
||||||
|
"go_native",
|
||||||
|
// config and generator function
|
||||||
|
system.Im.fromJS({
|
||||||
|
title: "Go",
|
||||||
|
syntax: "bash",
|
||||||
|
fn: (req) => {
|
||||||
|
// get extended info about request
|
||||||
|
const { spec, oasPathMethod } = req.toJS();
|
||||||
|
const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// run OpenAPISnippet for target node
|
||||||
|
const targets = ["go_native"];
|
||||||
|
let snippet;
|
||||||
|
try {
|
||||||
|
// set request snippet content
|
||||||
|
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||||
|
.content;
|
||||||
|
} catch (err) {
|
||||||
|
// set to error in case it happens the npm package has some flaws
|
||||||
|
snippet = JSON.stringify(snippet);
|
||||||
|
}
|
||||||
|
// return stringified snipped
|
||||||
|
return snippet;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
// key
|
||||||
|
"ruby",
|
||||||
|
// config and generator function
|
||||||
|
system.Im.fromJS({
|
||||||
|
title: "Ruby",
|
||||||
|
syntax: "ruby",
|
||||||
|
fn: (req) => {
|
||||||
|
// get extended info about request
|
||||||
|
const { spec, oasPathMethod } = req.toJS();
|
||||||
|
const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// run OpenAPISnippet for target node
|
||||||
|
const targets = ["ruby"];
|
||||||
|
let snippet;
|
||||||
|
try {
|
||||||
|
// set request snippet content
|
||||||
|
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||||
|
.content;
|
||||||
|
} catch (err) {
|
||||||
|
// set to error in case it happens the npm package has some flaws
|
||||||
|
snippet = JSON.stringify(snippet);
|
||||||
|
}
|
||||||
|
// return stringified snipped
|
||||||
|
return snippet;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
// key
|
||||||
|
"python",
|
||||||
|
// config and generator function
|
||||||
|
system.Im.fromJS({
|
||||||
|
title: "Python",
|
||||||
|
syntax: "python",
|
||||||
|
fn: (req) => {
|
||||||
|
// get extended info about request
|
||||||
|
const { spec, oasPathMethod } = req.toJS();
|
||||||
|
const { path, method } = oasPathMethod;
|
||||||
|
|
||||||
|
// run OpenAPISnippet for target node
|
||||||
|
const targets = ["python"];
|
||||||
|
let snippet;
|
||||||
|
try {
|
||||||
|
// set request snippet content
|
||||||
|
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||||
|
.content;
|
||||||
|
} catch (err) {
|
||||||
|
// set to error in case it happens the npm package has some flaws
|
||||||
|
snippet = JSON.stringify(snippet);
|
||||||
|
}
|
||||||
|
// return stringified snipped
|
||||||
|
return snippet;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+12
-10
@@ -3,21 +3,23 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "PORT=4200 next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "PORT=4200 next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"highlight.js": "^11.5.1",
|
||||||
"isarray": "2.0.5",
|
"isarray": "2.0.5",
|
||||||
"next": "12.1.4",
|
"next": "12.1.6",
|
||||||
"react": "17.0.2",
|
"openapi-snippet": "^0.13.0",
|
||||||
"react-dom": "17.0.2",
|
"react": "18.1.0",
|
||||||
"swagger-ui-react": "4.8.1"
|
"react-dom": "18.1.0",
|
||||||
|
"swagger-ui-react": "4.11.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "16.11.26",
|
"@types/node": "16.9.1",
|
||||||
"@types/react": "17.0.43",
|
"@types/react": "18.0.9",
|
||||||
"@types/react-dom": "17.0.14",
|
"@types/react-dom": "18.0.4",
|
||||||
"typescript": "4.6.3"
|
"typescript": "4.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import "highlight.js/styles/default.css";
|
||||||
|
import "swagger-ui-react/swagger-ui.css";
|
||||||
|
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
import "../styles/swagger-cal.css";
|
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }) {
|
function MyApp({ Component, pageProps }) {
|
||||||
return <Component {...pageProps} />;
|
return <Component {...pageProps} />;
|
||||||
|
|||||||
@@ -1,45 +1,26 @@
|
|||||||
import Head from "next/head";
|
import dynamic from "next/dynamic";
|
||||||
import SwaggerUI from "swagger-ui-react";
|
import { SwaggerUI } from "swagger-ui-react";
|
||||||
|
|
||||||
|
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
||||||
|
|
||||||
|
const SwaggerUIDynamic: SwaggerUI & { url: string } = dynamic(() => import("swagger-ui-react"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
const requestSnippets = {
|
|
||||||
generators: {
|
|
||||||
curl_bash: {
|
|
||||||
title: "cURL (bash)",
|
|
||||||
syntax: "bash",
|
|
||||||
},
|
|
||||||
curl_powershell: {
|
|
||||||
title: "cURL (PowerShell)",
|
|
||||||
syntax: "powershell",
|
|
||||||
},
|
|
||||||
curl_cmd: {
|
|
||||||
title: "cURL (CMD)",
|
|
||||||
syntax: "bash",
|
|
||||||
},
|
|
||||||
node: {
|
|
||||||
title: "Node",
|
|
||||||
syntax: "node",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultExpanded: true,
|
|
||||||
languages: ["curl_bash"],
|
|
||||||
// e.g. only show curl bash = ["curl_bash"]
|
|
||||||
};
|
|
||||||
export default function APIDocs() {
|
export default function APIDocs() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<SwaggerUIDynamic
|
||||||
<Head>
|
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
||||||
<title>Cal.com - Docs - SwaggerUI</title>
|
persistAuthorization={true}
|
||||||
<meta name="description" content="Generated by create next app" />
|
supportedSubmitMethods={["get", "post", "delete", "put", "options", "patch"]}
|
||||||
<link rel="icon" href="/favicon.ico" />
|
requestSnippetsEnabled={true}
|
||||||
</Head>
|
requestSnippets={requestSnippets}
|
||||||
<SwaggerUI
|
plugins={[SnippedGenerator]}
|
||||||
requestSnippets={requestSnippets}
|
tryItOutEnabled={true}
|
||||||
requestSnippetsEnabled={true}
|
syntaxHighlight={true}
|
||||||
docExpansion="none"
|
enableCORS={false} // Doesn't seem to work either
|
||||||
operationsSorter="method"
|
docExpansion="list"
|
||||||
filter={true}
|
filter={true}
|
||||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/api/docs"}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -14,3 +14,89 @@ a {
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.swagger-ui .opblock-tag {
|
||||||
|
font-size: 90% !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .opblock .opblock-summary {
|
||||||
|
display: grid;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.opblock-summary-path {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 10px 5px !important;
|
||||||
|
}
|
||||||
|
.opblock-summary-description {
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 0px 5px;
|
||||||
|
}
|
||||||
|
.swagger-ui .scheme-container .schemes {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.swagger-ui .info .title {
|
||||||
|
color: #3b4151;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.swagger-ui .scheme-container {
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
.swagger-ui .info {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.swagger-ui .auth-wrapper {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.swagger-ui .authorization__btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.swagger-ui .opblock {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
}
|
||||||
|
button.opblock-summary-control > svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.swagger-ui .filter .operation-filter-input {
|
||||||
|
border: 2px solid #d8dde7;
|
||||||
|
margin: 5px 5px;
|
||||||
|
padding: 5px;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
.swagger-ui .wrapper {
|
||||||
|
padding: 0 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.swagger-ui .info .title small {
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
.swagger-ui a.nostyle, .swagger-ui a.nostyle:visited {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
div.request-snippets > div.curl-command > div:nth-child(1) {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
.swagger-ui .opblock-body pre.microlight {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.swagger-ui table tbody tr td {
|
||||||
|
padding: 0px 0 0;
|
||||||
|
vertical-align: none;
|
||||||
|
}
|
||||||
|
td.response-col_description > div > div > p {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
||||||
|
display: flex;
|
||||||
|
width: 100vw;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 60%;
|
||||||
|
}
|
||||||
|
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > thead > tr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,11 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true
|
"incremental": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@lib/*": ["lib/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -61,3 +61,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Typescript
|
# Typescript
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Autogenerated embed content
|
||||||
|
public/embed
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Select from "react-select";
|
||||||
|
import { OptionProps } from "react-select";
|
||||||
|
|
||||||
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import type { App } from "@calcom/types/App";
|
||||||
|
import { Button } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
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.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: true }]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryCell
|
||||||
|
query={query}
|
||||||
|
success={({ data }) => {
|
||||||
|
const options = data.items.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
slug: item.slug,
|
||||||
|
image: item.imageSrc,
|
||||||
|
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 font-medium text-gray-700 sm:text-sm"
|
||||||
|
isLoading={isLoading}
|
||||||
|
components={{ Option: ImageOption }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdditionalCalendarSelector;
|
||||||
+65
-20
@@ -1,21 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
|
CheckIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FlagIcon,
|
FlagIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
|
PlusIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
} from "@heroicons/react/outline";
|
} from "@heroicons/react/outline";
|
||||||
import { ChevronLeftIcon } from "@heroicons/react/solid";
|
import { ChevronLeftIcon } from "@heroicons/react/solid";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { App as AppType } from "@calcom/types/App";
|
import { App as AppType } from "@calcom/types/App";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button, SkeletonButton } from "@calcom/ui";
|
||||||
|
|
||||||
//import NavTabs from "@components/NavTabs";
|
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
|
|
||||||
@@ -60,12 +61,40 @@ export default function App({
|
|||||||
currency: "USD",
|
currency: "USD",
|
||||||
useGrouping: false,
|
useGrouping: false,
|
||||||
}).format(price);
|
}).format(price);
|
||||||
|
const [installedAppCount, setInstalledAppCount] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
async function getInstalledApp(appCredentialType: string) {
|
||||||
|
const queryParam = new URLSearchParams();
|
||||||
|
queryParam.set("app-credential-type", appCredentialType);
|
||||||
|
try {
|
||||||
|
const result = await fetch(`/api/app-store/installed?${queryParam.toString()}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}).then((data) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
if (result.status === 200) {
|
||||||
|
const res = await result.json();
|
||||||
|
setInstalledAppCount(res.count);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getInstalledApp(type);
|
||||||
|
}, [type]);
|
||||||
|
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Shell large>
|
<Shell large isPublic>
|
||||||
<div className="-mx-4 md:-mx-8">
|
<div className="-mx-4 md:-mx-8">
|
||||||
<div className="bg-gray-50 px-4">
|
<div className="bg-gray-50 px-8">
|
||||||
<Link href="/apps">
|
<Link href="/apps">
|
||||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
||||||
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
|
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
|
||||||
@@ -73,7 +102,7 @@ export default function App({
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<img className="h-16 w-16" src={logo} alt={name} />
|
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
|
||||||
<header className="px-4 py-2">
|
<header className="px-4 py-2">
|
||||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||||
<h2 className="text-sm text-gray-500">
|
<h2 className="text-sm text-gray-500">
|
||||||
@@ -83,19 +112,35 @@ export default function App({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||||
{isGlobal ? (
|
{!isLoading ? (
|
||||||
<Button color="secondary" disabled title="This app is globally installed">
|
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
|
||||||
{t("installed")}
|
<div className="space-x-3">
|
||||||
</Button>
|
<Button StartIcon={CheckIcon} color="secondary" disabled>
|
||||||
) : (
|
{installedAppCount > 0
|
||||||
<InstallAppButton
|
? t("active_install", { count: installedAppCount })
|
||||||
type={type}
|
: t("globally_install")}
|
||||||
render={(buttonProps) => (
|
|
||||||
<Button data-testid="install-app-button" {...buttonProps}>
|
|
||||||
{t("install_app")}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<InstallAppButton
|
||||||
/>
|
type={type}
|
||||||
|
render={(buttonProps) => (
|
||||||
|
<Button StartIcon={PlusIcon} data-testid="install-app-button" {...buttonProps}>
|
||||||
|
{t("add_another")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<InstallAppButton
|
||||||
|
type={type}
|
||||||
|
render={(buttonProps) => (
|
||||||
|
<Button data-testid="install-app-button" {...buttonProps}>
|
||||||
|
{t("install_app")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<SkeletonButton width="24" height="10" />
|
||||||
)}
|
)}
|
||||||
{price !== 0 && (
|
{price !== 0 && (
|
||||||
<small className="block text-right">
|
<small className="block text-right">
|
||||||
@@ -111,7 +156,7 @@ export default function App({
|
|||||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
|
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="justify-between px-4 py-10 md:flex">
|
<div className="justify-between px-8 py-10 md:flex">
|
||||||
<div className="prose-sm prose mb-6">{body}</div>
|
<div className="prose-sm prose mb-6">{body}</div>
|
||||||
<div className="md:max-w-80 flex-1 md:ml-8">
|
<div className="md:max-w-80 flex-1 md:ml-8">
|
||||||
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import NavTabs from "./NavTabs";
|
import NavTabs from "./NavTabs";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: "app_store",
|
||||||
|
href: "/apps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "installed_apps",
|
||||||
|
href: "/apps/installed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function AppsShell({ children }: { children: React.ReactNode }) {
|
export default function AppsShell({ children }: { children: React.ReactNode }) {
|
||||||
const { t } = useLocale();
|
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
name: t("app_store"),
|
|
||||||
href: "/apps",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("installed_apps"),
|
|
||||||
href: "/apps/installed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-12 block lg:hidden">
|
<div className="mb-12 block lg:hidden">
|
||||||
{status === "authenticated" && <NavTabs tabs={tabs} linkProps={{ shallow: true }} />}
|
{status === "authenticated" && <NavTabs tabs={tabs} linkProps={{ shallow: true }} />}
|
||||||
</div>
|
</div>
|
||||||
<main>{children}</main>
|
<main className="pb-6">{children}</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,27 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import NavTabs from "./NavTabs";
|
import NavTabs from "./NavTabs";
|
||||||
|
|
||||||
export default function BookingsShell({ children }: { children: React.ReactNode }) {
|
const tabs = [
|
||||||
const { t } = useLocale();
|
{
|
||||||
const tabs = [
|
name: "upcoming",
|
||||||
{
|
href: "/bookings/upcoming",
|
||||||
name: t("upcoming"),
|
},
|
||||||
href: "/bookings/upcoming",
|
{
|
||||||
},
|
name: "recurring",
|
||||||
{
|
href: "/bookings/recurring",
|
||||||
name: t("past"),
|
},
|
||||||
href: "/bookings/past",
|
{
|
||||||
},
|
name: "past",
|
||||||
{
|
href: "/bookings/past",
|
||||||
name: t("cancelled"),
|
},
|
||||||
href: "/bookings/cancelled",
|
{
|
||||||
},
|
name: "cancelled",
|
||||||
];
|
href: "/bookings/cancelled",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BookingsShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
|
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { useBrandColors } from "@calcom/embed-core";
|
import { useBrandColors } from "@calcom/embed-core/embed-iframe";
|
||||||
|
|
||||||
const brandColor = "#292929";
|
const brandColor = "#292929";
|
||||||
const brandTextColor = "#ffffff";
|
const brandTextColor = "#ffffff";
|
||||||
@@ -274,7 +274,17 @@ const BrandColor = ({
|
|||||||
"--brand-text-color-dark-mode",
|
"--brand-text-color-dark-mode",
|
||||||
getContrastingTextColor(darkVal, true)
|
getContrastingTextColor(darkVal, true)
|
||||||
);
|
);
|
||||||
}, [lightVal, darkVal]);
|
}, [
|
||||||
|
embedBrandingColors.highlightColor,
|
||||||
|
embedBrandingColors.lightestColor,
|
||||||
|
embedBrandingColors.lighterColor,
|
||||||
|
embedBrandingColors.lightColor,
|
||||||
|
embedBrandingColors.medianColor,
|
||||||
|
embedBrandingColors.darkColor,
|
||||||
|
embedBrandingColors.darkerColor,
|
||||||
|
lightVal,
|
||||||
|
darkVal,
|
||||||
|
]);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
|
|
||||||
import Button from "@calcom/ui/Button";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,6 +24,22 @@ const DestinationCalendarSelector = ({
|
|||||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||||
|
|
||||||
|
// Extra styles to show prefixed text in react-select
|
||||||
|
const content = (hidePlaceholder = false) => {
|
||||||
|
if (!hidePlaceholder) {
|
||||||
|
return {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
":before": {
|
||||||
|
content: `'${t("select_destination_calendar")}:'`,
|
||||||
|
display: "block",
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selected = query.data?.connectedCalendars
|
const selected = query.data?.connectedCalendars
|
||||||
.map((connected) => connected.calendars ?? [])
|
.map((connected) => connected.calendars ?? [])
|
||||||
@@ -52,23 +68,39 @@ const DestinationCalendarSelector = ({
|
|||||||
})) ?? [];
|
})) ?? [];
|
||||||
return (
|
return (
|
||||||
<div className="relative" title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
<div className="relative" title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||||
{/* There's no easy way to customize the displayed value for a Select, so we fake it. */}
|
|
||||||
{!hidePlaceholder && (
|
|
||||||
<div className="pointer-events-none absolute z-10 w-full">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
className="m-[1px] w-[calc(100%_-_40px)] overflow-hidden overflow-ellipsis whitespace-nowrap rounded-sm border-none leading-5">
|
|
||||||
{t("select_destination_calendar")}: {selectedOption?.label || ""}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Select
|
<Select
|
||||||
name={"primarySelectedCalendar"}
|
name={"primarySelectedCalendar"}
|
||||||
placeholder={!hidePlaceholder ? `${t("select_destination_calendar")}:` : undefined}
|
placeholder={!hidePlaceholder ? `${t("select_destination_calendar")}:` : undefined}
|
||||||
options={options}
|
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: "2px",
|
||||||
|
"@media only screen and (min-width: 640px)": {
|
||||||
|
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||||
|
maxWidth: "320px",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-md border-gray-300 sm:text-sm"
|
className={classNames(
|
||||||
|
"mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm",
|
||||||
|
!hidePlaceholder && "font-medium"
|
||||||
|
)}
|
||||||
onChange={(option) => {
|
onChange={(option) => {
|
||||||
setSelectedOption(option);
|
setSelectedOption(option);
|
||||||
if (!option) {
|
if (!option) {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
export function useViewerI18n() {
|
export function useViewerI18n() {
|
||||||
return trpc.useQuery(["viewer.i18n"], {
|
return trpc.useQuery(["viewer.public.i18n"], {
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from "react";
|
|||||||
import Cropper from "react-easy-crop";
|
import Cropper from "react-easy-crop";
|
||||||
|
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
|
|
||||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||||
@@ -71,15 +71,15 @@ export default function ImageUploader({
|
|||||||
...props
|
...props
|
||||||
}: ImageUploaderProps) {
|
}: ImageUploaderProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [imageSrc, setImageSrc] = useState<string | null>();
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>();
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||||
|
|
||||||
const [{ result }, setFile] = useFileReader({
|
const [{ result }, setFile] = useFileReader({
|
||||||
method: "readAsDataURL",
|
method: "readAsDataURL",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageSrc(props.imageSrc);
|
if (props.imageSrc) setImageSrc(props.imageSrc);
|
||||||
}, [props.imageSrc]);
|
}, [props.imageSrc]);
|
||||||
|
|
||||||
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
|
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
|
||||||
@@ -90,8 +90,9 @@ export default function ImageUploader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showCroppedImage = useCallback(
|
const showCroppedImage = useCallback(
|
||||||
async (croppedAreaPixels) => {
|
async (croppedAreaPixels: Area | null) => {
|
||||||
try {
|
try {
|
||||||
|
if (!croppedAreaPixels) return;
|
||||||
const croppedImage = await getCroppedImg(
|
const croppedImage = await getCroppedImg(
|
||||||
result as string /* result is always string when using readAsDataUrl */,
|
result as string /* result is always string when using readAsDataUrl */,
|
||||||
croppedAreaPixels
|
croppedAreaPixels
|
||||||
@@ -134,7 +135,10 @@ export default function ImageUploader({
|
|||||||
{t("no_target", { target })}
|
{t("no_target", { target })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{imageSrc && <img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />}
|
{imageSrc && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
export default function Loader() {
|
export { default } from "@calcom/ui/Loader";
|
||||||
return (
|
|
||||||
<div className="loader border-brand dark:border-darkmodebrand">
|
|
||||||
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
|
import { LOGO_ICON, LOGO } from "@calcom/lib/constants";
|
||||||
|
|
||||||
export default function Logo({ small, icon }: { small?: boolean; icon?: boolean }) {
|
export default function Logo({ small, icon }: { small?: boolean; icon?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<h1 className="inline">
|
<h1 className="inline">
|
||||||
<strong>
|
<strong>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<img className="mx-auto w-9" alt="Cal" title="Cal" src="/cal-com-icon-white.svg" />
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img className="mx-auto w-9" alt="Cal" title="Cal" src={LOGO_ICON} />
|
||||||
) : (
|
) : (
|
||||||
<img
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
className={small ? "h-4 w-auto" : "h-5 w-auto"}
|
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Cal" title="Cal" src={LOGO} />
|
||||||
alt="Cal"
|
|
||||||
title="Cal"
|
|
||||||
src="/calendso-logo-white-word.svg"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</strong>
|
</strong>
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,49 +1,89 @@
|
|||||||
|
import { AdminRequired } from "components/ui/AdminRequired";
|
||||||
|
import noop from "lodash/noop";
|
||||||
import Link, { LinkProps } from "next/link";
|
import Link, { LinkProps } from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { ElementType, FC } from "react";
|
import { FC, Fragment, MouseEventHandler } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||||
|
|
||||||
interface Props {
|
export interface NavTabProps {
|
||||||
tabs: {
|
tabs: {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
/** If you want to change the path as per current tab */
|
||||||
icon?: ElementType;
|
href?: string;
|
||||||
|
/** If you want to change query param tabName as per current tab */
|
||||||
|
tabName?: string;
|
||||||
|
icon?: SVGComponent;
|
||||||
|
adminRequired?: boolean;
|
||||||
}[];
|
}[];
|
||||||
linkProps?: Omit<LinkProps, "href">;
|
linkProps?: Omit<LinkProps, "href">;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavTabs: FC<Props> = ({ tabs, linkProps }) => {
|
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
className="-mb-px flex space-x-2 space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse"
|
className="no-scrollbar -mb-px flex space-x-5 overflow-x-scroll rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||||
aria-label="Tabs">
|
aria-label="Tabs"
|
||||||
|
{...props}>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isCurrent = router.asPath === tab.href;
|
if ((tab.tabName && tab.href) || (!tab.tabName && !tab.href)) {
|
||||||
|
throw new Error("Use either tabName or href");
|
||||||
|
}
|
||||||
|
let href = "";
|
||||||
|
let isCurrent;
|
||||||
|
if (tab.href) {
|
||||||
|
href = tab.href;
|
||||||
|
isCurrent = router.asPath === tab.href;
|
||||||
|
} else if (tab.tabName) {
|
||||||
|
href = "";
|
||||||
|
isCurrent = router.query.tabName === tab.tabName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick: MouseEventHandler = tab.tabName
|
||||||
|
? (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
...router.query,
|
||||||
|
tabName: tab.tabName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: noop;
|
||||||
|
|
||||||
|
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={tab.name} href={tab.href} {...linkProps}>
|
<Component key={tab.name}>
|
||||||
<a
|
<Link key={tab.name} href={href} {...linkProps}>
|
||||||
className={classNames(
|
<a
|
||||||
isCurrent
|
onClick={onClick}
|
||||||
? "border-neutral-900 text-neutral-900"
|
className={classNames(
|
||||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
isCurrent
|
||||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
? "border-neutral-900 text-neutral-900"
|
||||||
)}
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||||
aria-current={isCurrent ? "page" : undefined}>
|
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||||
{tab.icon && (
|
)}
|
||||||
<tab.icon
|
aria-current={isCurrent ? "page" : undefined}>
|
||||||
className={classNames(
|
{tab.icon && (
|
||||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
<tab.icon
|
||||||
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
className={classNames(
|
||||||
)}
|
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||||
aria-hidden="true"
|
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||||
/>
|
)}
|
||||||
)}
|
aria-hidden="true"
|
||||||
<span>{tab.name}</span>
|
/>
|
||||||
</a>
|
)}
|
||||||
</Link>
|
<span>{t(tab.name)}</span>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</Component>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,42 +1,66 @@
|
|||||||
import { CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
import {
|
||||||
import React from "react";
|
CreditCardIcon,
|
||||||
|
KeyIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
UserIcon,
|
||||||
|
CodeIcon,
|
||||||
|
} from "@heroicons/react/solid";
|
||||||
|
import React, { ComponentProps } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import ErrorBoundary from "@lib/ErrorBoundary";
|
||||||
|
|
||||||
import NavTabs from "./NavTabs";
|
import NavTabs from "./NavTabs";
|
||||||
|
import Shell from "./Shell";
|
||||||
|
|
||||||
export default function SettingsShell({ children }: { children: React.ReactNode }) {
|
const tabs = [
|
||||||
const { t } = useLocale();
|
{
|
||||||
|
name: "profile",
|
||||||
const tabs = [
|
href: "/settings/profile",
|
||||||
{
|
icon: UserIcon,
|
||||||
name: t("profile"),
|
},
|
||||||
href: "/settings/profile",
|
{
|
||||||
icon: UserIcon,
|
name: "teams",
|
||||||
},
|
href: "/settings/teams",
|
||||||
{
|
icon: UserGroupIcon,
|
||||||
name: t("security"),
|
},
|
||||||
href: "/settings/security",
|
{
|
||||||
icon: KeyIcon,
|
name: "security",
|
||||||
},
|
href: "/settings/security",
|
||||||
{
|
icon: KeyIcon,
|
||||||
name: t("teams"),
|
},
|
||||||
href: "/settings/teams",
|
{
|
||||||
icon: UserGroupIcon,
|
name: "developer",
|
||||||
},
|
href: "/settings/developer",
|
||||||
{
|
icon: CodeIcon,
|
||||||
name: t("billing"),
|
},
|
||||||
href: "/settings/billing",
|
{
|
||||||
icon: CreditCardIcon,
|
name: "billing",
|
||||||
},
|
href: "/settings/billing",
|
||||||
];
|
icon: CreditCardIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "admin",
|
||||||
|
href: "/settings/admin",
|
||||||
|
icon: LockClosedIcon,
|
||||||
|
adminRequired: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsShell({
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Shell {...rest}>
|
||||||
<div className="sm:mx-auto">
|
<div className="sm:mx-auto">
|
||||||
<NavTabs tabs={tabs} />
|
<NavTabs tabs={tabs} />
|
||||||
</div>
|
</div>
|
||||||
<main className="max-w-4xl">{children}</main>
|
<main className="max-w-4xl">
|
||||||
</>
|
<>
|
||||||
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
|
</>
|
||||||
|
</main>
|
||||||
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+179
-134
@@ -10,16 +10,18 @@ import {
|
|||||||
MapIcon,
|
MapIcon,
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
ViewGridIcon,
|
ViewGridIcon,
|
||||||
|
QuestionMarkCircleIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
|
import { UserPlan } from "@prisma/client";
|
||||||
import { SessionContextValue, signOut, useSession } from "next-auth/react";
|
import { SessionContextValue, signOut, useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { Fragment, ReactNode, useEffect } from "react";
|
import React, { Fragment, ReactNode, useEffect, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
|
|
||||||
import { useIsEmbed } from "@calcom/embed-core";
|
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||||
|
import { WEBAPP_URL, JOIN_SLACK, ROADMAP } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { UserPlan } from "@calcom/prisma/client";
|
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import Dropdown, {
|
import Dropdown, {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -31,30 +33,22 @@ import LicenseBanner from "@ee/components/LicenseBanner";
|
|||||||
import TrialBanner from "@ee/components/TrialBanner";
|
import TrialBanner from "@ee/components/TrialBanner";
|
||||||
import HelpMenuItem from "@ee/components/support/HelpMenuItem";
|
import HelpMenuItem from "@ee/components/support/HelpMenuItem";
|
||||||
|
|
||||||
|
import ErrorBoundary from "@lib/ErrorBoundary";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { WEBAPP_URL } from "@lib/config/constants";
|
|
||||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import CustomBranding from "@components/CustomBranding";
|
import CustomBranding from "@components/CustomBranding";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
|
import ImpersonatingBanner from "@components/ui/ImpersonatingBanner";
|
||||||
|
|
||||||
import pkg from "../package.json";
|
import pkg from "../package.json";
|
||||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
|
|
||||||
export function useMeQuery() {
|
|
||||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
|
||||||
retry(failureCount) {
|
|
||||||
return failureCount > 3;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return meQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const loading = status === "loading";
|
const loading = status === "loading";
|
||||||
@@ -69,7 +63,7 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
|||||||
router.replace({
|
router.replace({
|
||||||
pathname: "/auth/login",
|
pathname: "/auth/login",
|
||||||
query: {
|
query: {
|
||||||
callbackUrl: `${WEBAPP_URL}/${location.pathname}${location.search}`,
|
callbackUrl: `${WEBAPP_URL}${location.pathname}${location.search}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,9 +119,10 @@ const Layout = ({
|
|||||||
status,
|
status,
|
||||||
plan,
|
plan,
|
||||||
...props
|
...props
|
||||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan }) => {
|
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
|
||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
@@ -204,7 +199,7 @@ const Layout = ({
|
|||||||
</Link>
|
</Link>
|
||||||
{/* logo icon for tablet */}
|
{/* logo icon for tablet */}
|
||||||
<Link href="/event-types">
|
<Link href="/event-types">
|
||||||
<a className="md:inline lg:hidden">
|
<a className="text-center md:inline lg:hidden">
|
||||||
<Logo small icon />
|
<Logo small icon />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -213,6 +208,7 @@ const Layout = ({
|
|||||||
<Fragment key={item.name}>
|
<Fragment key={item.name}>
|
||||||
<Link href={item.href}>
|
<Link href={item.href}>
|
||||||
<a
|
<a
|
||||||
|
aria-label={item.name}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
item.current
|
item.current
|
||||||
? "bg-neutral-100 text-neutral-900"
|
? "bg-neutral-100 text-neutral-900"
|
||||||
@@ -254,7 +250,7 @@ const Layout = ({
|
|||||||
</div>
|
</div>
|
||||||
<TrialBanner />
|
<TrialBanner />
|
||||||
<div
|
<div
|
||||||
className="rounded-sm pb-2 pl-3 pt-2 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2"
|
className="rounded-sm pt-2 pb-2 pl-3 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2"
|
||||||
data-testid="user-dropdown-trigger">
|
data-testid="user-dropdown-trigger">
|
||||||
<span className="hidden lg:inline">
|
<span className="hidden lg:inline">
|
||||||
<UserDropdown />
|
<UserDropdown />
|
||||||
@@ -311,6 +307,7 @@ const Layout = ({
|
|||||||
props.flexChildrenContainer && "flex flex-1 flex-col",
|
props.flexChildrenContainer && "flex flex-1 flex-col",
|
||||||
!props.large && "py-8"
|
!props.large && "py-8"
|
||||||
)}>
|
)}>
|
||||||
|
<ImpersonatingBanner />
|
||||||
{!!props.backPath && (
|
{!!props.backPath && (
|
||||||
<div className="mx-3 mb-8 sm:mx-8">
|
<div className="mx-3 mb-8 sm:mx-8">
|
||||||
<Button
|
<Button
|
||||||
@@ -329,10 +326,21 @@ const Layout = ({
|
|||||||
)}>
|
)}>
|
||||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||||
<div className="mb-8 w-full">
|
<div className="mb-8 w-full">
|
||||||
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
|
{props.isLoading ? (
|
||||||
{props.heading}
|
<>
|
||||||
</h1>
|
<div className="mb-1 h-6 w-24 animate-pulse rounded-md bg-gray-200"></div>
|
||||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
|
<div className="mb-1 h-6 w-32 animate-pulse rounded-md bg-gray-200"></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
|
||||||
|
{props.heading}
|
||||||
|
</h1>
|
||||||
|
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">
|
||||||
|
{props.subtitle}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{props.CTA && <div className="mb-4 flex-shrink-0">{props.CTA}</div>}
|
{props.CTA && <div className="mb-4 flex-shrink-0">{props.CTA}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -342,7 +350,7 @@ const Layout = ({
|
|||||||
"px-4 sm:px-6 md:px-8",
|
"px-4 sm:px-6 md:px-8",
|
||||||
props.flexChildrenContainer && "flex flex-1 flex-col"
|
props.flexChildrenContainer && "flex flex-1 flex-col"
|
||||||
)}>
|
)}>
|
||||||
{props.children}
|
<ErrorBoundary>{!props.isLoading ? props.children : props.customLoader}</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||||
{status === "authenticated" && (
|
{status === "authenticated" && (
|
||||||
@@ -403,19 +411,13 @@ type LayoutProps = {
|
|||||||
// use when content needs to expand with flex
|
// use when content needs to expand with flex
|
||||||
flexChildrenContainer?: boolean;
|
flexChildrenContainer?: boolean;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
customLoader?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Shell(props: LayoutProps) {
|
export default function Shell(props: LayoutProps) {
|
||||||
const router = useRouter();
|
|
||||||
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
|
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
|
||||||
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
|
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
|
||||||
const telemetry = useTelemetry();
|
const { isReady, Theme } = useTheme("light");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
telemetry.withJitsu((jitsu) => {
|
|
||||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
|
|
||||||
});
|
|
||||||
}, [telemetry, router.asPath]);
|
|
||||||
|
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
const user = query.data;
|
const user = query.data;
|
||||||
@@ -423,8 +425,11 @@ export default function Shell(props: LayoutProps) {
|
|||||||
const i18n = useViewerI18n();
|
const i18n = useViewerI18n();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
|
||||||
if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) {
|
const isLoading = query.status === "loading" || isRedirectingToOnboarding || loading || !isReady;
|
||||||
// show spinner whilst i18n is loading to avoid language flicker
|
|
||||||
|
// Don't show any content till translations are loaded.
|
||||||
|
// As they are cached infintely, this status would be loading just once for the app's lifetime until refresh
|
||||||
|
if (i18n.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -436,8 +441,9 @@ export default function Shell(props: LayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Theme />
|
||||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||||
<MemoizedLayout plan={user?.plan} status={status} {...props} />
|
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -446,27 +452,43 @@ function UserDropdown({ small }: { small?: boolean }) {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
const user = query.data;
|
const user = query.data;
|
||||||
|
|
||||||
const mutation = trpc.useMutation("viewer.away", {
|
const mutation = trpc.useMutation("viewer.away", {
|
||||||
onSettled() {
|
onSettled() {
|
||||||
utils.invalidateQueries("viewer.me");
|
utils.invalidateQueries("viewer.me");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const onHelpItemSelect = () => {
|
||||||
|
setHelpOpen(false);
|
||||||
|
setMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent rendering dropdown if user isn't available.
|
||||||
|
// We don't want to show nameless user.
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<Dropdown open={menuOpen} onOpenChange={() => setHelpOpen(false)}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild onClick={() => setMenuOpen(true)}>
|
||||||
<button className="group flex w-full cursor-pointer appearance-none items-center text-left">
|
<button className="group flex w-full cursor-pointer appearance-none items-center text-left">
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
small ? "h-8 w-8" : "h-10 w-10",
|
small ? "h-8 w-8" : "h-10 w-10",
|
||||||
"relative flex-shrink-0 rounded-full bg-gray-300 ltr:mr-3 rtl:ml-3"
|
"relative flex-shrink-0 rounded-full bg-gray-300 ltr:mr-3 rtl:ml-3"
|
||||||
)}>
|
)}>
|
||||||
<img
|
{
|
||||||
className="rounded-full"
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
|
<img
|
||||||
alt={user?.username || "Nameless User"}
|
className="rounded-full"
|
||||||
/>
|
src={WEBAPP_URL + "/" + user?.username + "/avatar.png"}
|
||||||
|
alt={user?.username || "Nameless User"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
{!user?.away && (
|
{!user?.away && (
|
||||||
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-green-500"></div>
|
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-green-500"></div>
|
||||||
)}
|
)}
|
||||||
@@ -478,10 +500,14 @@ function UserDropdown({ small }: { small?: boolean }) {
|
|||||||
<span className="flex flex-grow items-center truncate">
|
<span className="flex flex-grow items-center truncate">
|
||||||
<span className="flex-grow truncate text-sm">
|
<span className="flex-grow truncate text-sm">
|
||||||
<span className="block truncate font-medium text-gray-900">
|
<span className="block truncate font-medium text-gray-900">
|
||||||
{user?.username || "Nameless User"}
|
{user?.name || "Nameless User"}
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate font-normal text-neutral-500">
|
<span className="block truncate font-normal text-neutral-500">
|
||||||
{user?.username ? `cal.com/${user.username}` : "No public page"}
|
{user?.username
|
||||||
|
? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com"
|
||||||
|
? `cal.com/${user.username}`
|
||||||
|
: `/${user.username}`
|
||||||
|
: "No public page"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<SelectorIcon
|
<SelectorIcon
|
||||||
@@ -492,97 +518,116 @@ function UserDropdown({ small }: { small?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent portalled={true}>
|
<DropdownMenuContent portalled={true} onInteractOutside={() => setMenuOpen(false)}>
|
||||||
<DropdownMenuItem>
|
{helpOpen ? (
|
||||||
<a
|
<HelpMenuItem onHelpItemSelect={() => onHelpItemSelect()} />
|
||||||
onClick={() => {
|
) : (
|
||||||
mutation.mutate({ away: !user?.away });
|
<>
|
||||||
utils.invalidateQueries("viewer.me");
|
<DropdownMenuItem>
|
||||||
}}
|
<a
|
||||||
className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
onClick={() => {
|
||||||
<MoonIcon
|
mutation.mutate({ away: !user?.away });
|
||||||
className={classNames(
|
utils.invalidateQueries("viewer.me");
|
||||||
user?.away
|
}}
|
||||||
? "text-purple-500 group-hover:text-purple-700"
|
className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||||
: "text-gray-500 group-hover:text-gray-700",
|
<MoonIcon
|
||||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
className={classNames(
|
||||||
)}
|
user?.away
|
||||||
aria-hidden="true"
|
? "text-purple-500 group-hover:text-purple-700"
|
||||||
/>
|
: "text-gray-500 group-hover:text-gray-700",
|
||||||
{user?.away ? t("set_as_free") : t("set_as_away")}
|
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||||
</a>
|
)}
|
||||||
</DropdownMenuItem>
|
aria-hidden="true"
|
||||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
/>
|
||||||
{user?.username && (
|
{user?.away ? t("set_as_free") : t("set_as_away")}
|
||||||
<DropdownMenuItem>
|
</a>
|
||||||
<a
|
</DropdownMenuItem>
|
||||||
target="_blank"
|
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||||
rel="noopener noreferrer"
|
{user?.username && (
|
||||||
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`}
|
<DropdownMenuItem>
|
||||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
<a
|
||||||
<ExternalLinkIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("view_public_page")}
|
target="_blank"
|
||||||
</a>
|
rel="noopener noreferrer"
|
||||||
</DropdownMenuItem>
|
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`}
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||||
|
<ExternalLinkIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" />{" "}
|
||||||
|
{t("view_public_page")}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<a
|
||||||
|
href={JOIN_SLACK}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 2447.6 2452.5"
|
||||||
|
className={classNames(
|
||||||
|
"text-gray-500 group-hover:text-gray-700",
|
||||||
|
"mt-0.5 h-4 w-4 flex-shrink-0 ltr:mr-4 rtl:ml-4"
|
||||||
|
)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clipRule="evenodd" fillRule="evenodd">
|
||||||
|
<path
|
||||||
|
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
<path
|
||||||
|
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
<path
|
||||||
|
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
<path
|
||||||
|
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||||
|
fill="currentColor"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{t("join_our_slack")}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
href={ROADMAP}
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||||
|
<MapIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("visit_roadmap")}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex w-full px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
onClick={() => setHelpOpen(true)}>
|
||||||
|
<QuestionMarkCircleIcon
|
||||||
|
className={classNames(
|
||||||
|
"text-gray-500 group-hover:text-neutral-500",
|
||||||
|
"h-5 w-5 flex-shrink-0 ltr:mr-3"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{t("help")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<a
|
||||||
|
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
|
||||||
|
className="flex cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||||
|
<LogoutIcon
|
||||||
|
className={classNames(
|
||||||
|
"text-gray-500 group-hover:text-gray-700",
|
||||||
|
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t("sign_out")}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<a
|
|
||||||
href="https://cal.com/slack"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 2447.6 2452.5"
|
|
||||||
className={classNames(
|
|
||||||
"text-gray-500 group-hover:text-gray-700",
|
|
||||||
"mt-0.5 h-4 w-4 flex-shrink-0 ltr:mr-4 rtl:ml-4"
|
|
||||||
)}
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clipRule="evenodd" fillRule="evenodd">
|
|
||||||
<path
|
|
||||||
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
|
||||||
fill="currentColor"></path>
|
|
||||||
<path
|
|
||||||
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
|
||||||
fill="currentColor"></path>
|
|
||||||
<path
|
|
||||||
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
|
||||||
fill="currentColor"></path>
|
|
||||||
<path
|
|
||||||
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
|
||||||
fill="currentColor"></path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
{t("join_our_slack")}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
href="https://cal.com/roadmap"
|
|
||||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
|
||||||
<MapIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("visit_roadmap")}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<HelpMenuItem />
|
|
||||||
|
|
||||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<a
|
|
||||||
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
|
|
||||||
className="flex cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
|
||||||
<LogoutIcon
|
|
||||||
className={classNames(
|
|
||||||
"text-gray-500 group-hover:text-gray-700",
|
|
||||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t("sign_out")}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { StarIcon } from "@heroicons/react/solid";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
@@ -16,11 +15,15 @@ interface AppCardProps {
|
|||||||
export default function AppCard(props: AppCardProps) {
|
export default function AppCard(props: AppCardProps) {
|
||||||
return (
|
return (
|
||||||
<Link href={"/apps/" + props.slug}>
|
<Link href={"/apps/" + props.slug}>
|
||||||
<a className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50">
|
<a
|
||||||
|
className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50"
|
||||||
|
data-testid={`app-store-app-card-${props.slug}`}>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
|
{
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
|
||||||
|
}
|
||||||
<Button
|
<Button
|
||||||
data-testid={`app-store-app-card-${props.slug}`}
|
|
||||||
color="secondary"
|
color="secondary"
|
||||||
className="ml-auto flex self-start"
|
className="ml-auto flex self-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function AppStoreCategories({
|
|||||||
return (
|
return (
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("popular_categories")}</h2>
|
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("popular_categories")}</h2>
|
||||||
<div className="grid-col-1 grid gap-3 md:grid-flow-col">
|
<div className="grid-col-1 grid w-full gap-3 overflow-scroll sm:grid-flow-col">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Link key={category.name} href={"/apps/categories/" + category.name}>
|
<Link key={category.name} href={"/apps/categories/" + category.name}>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { ShellSubHeading } from "@components/Shell";
|
||||||
|
|
||||||
|
function SkeletonLoader({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} {...{ className }} />
|
||||||
|
<ul className="-mx-4 animate-pulse divide-y divide-neutral-200 rounded-sm border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between p-3">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div className="flex justify-start space-x-2">
|
||||||
|
<SkeletonText width="10" height="10"></SkeletonText>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SkeletonText height="4" width="32"></SkeletonText>
|
||||||
|
<SkeletonText height="4" width="16"></SkeletonText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||||
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
|
<SkeletonText width="32" height="11"></SkeletonText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import "@glidejs/glide/dist/css/glide.theme.min.css";
|
|||||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/solid";
|
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/solid";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
const Slider = <T extends unknown>({
|
const Slider = <T extends string | unknown>({
|
||||||
title = "",
|
title = "",
|
||||||
className = "",
|
className = "",
|
||||||
items,
|
items,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function SAMLLogin(props: Props) {
|
|||||||
const methods = useFormContext();
|
const methods = useFormContext();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const mutation = trpc.useMutation("viewer.samlTenantProduct", {
|
const mutation = trpc.useMutation("viewer.public.samlTenantProduct", {
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
await signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
||||||
},
|
},
|
||||||
@@ -40,9 +40,7 @@ export default function SAMLLogin(props: Props) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// track Google logins. Without personal data/payload
|
// track Google logins. Without personal data/payload
|
||||||
telemetry.withJitsu((jitsu) =>
|
telemetry.event(telemetryEventTypes.googleLogin, collectPageParameters());
|
||||||
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!props.hostedCal) {
|
if (!props.hostedCal) {
|
||||||
await signIn("saml", {}, { tenant: props.samlTenantID, product: props.samlProductID });
|
await signIn("saml", {}, { tenant: props.samlTenantID, product: props.samlProductID });
|
||||||
|
|||||||
@@ -29,8 +29,15 @@ export default function TwoFactor() {
|
|||||||
<div className="mx-auto !mt-0 max-w-sm">
|
<div className="mx-auto !mt-0 max-w-sm">
|
||||||
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
|
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
|
||||||
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
||||||
<div className="flex flex-row space-x-1">
|
<div className="flex flex-row justify-between">
|
||||||
<Input className={className} name="2fa1" inputMode="decimal" {...digits[0]} autoFocus />
|
<Input
|
||||||
|
className={className}
|
||||||
|
name="2fa1"
|
||||||
|
inputMode="decimal"
|
||||||
|
{...digits[0]}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
<Input className={className} name="2fa2" inputMode="decimal" {...digits[1]} />
|
<Input className={className} name="2fa2" inputMode="decimal" {...digits[1]} />
|
||||||
<Input className={className} name="2fa3" inputMode="decimal" {...digits[2]} />
|
<Input className={className} name="2fa3" inputMode="decimal" {...digits[2]} />
|
||||||
<Input className={className} name="2fa4" inputMode="decimal" {...digits[3]} />
|
<Input className={className} name="2fa4" inputMode="decimal" {...digits[3]} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import showToast from "@calcom/lib/notification";
|
import showToast from "@calcom/lib/notification";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button } from "@calcom/ui";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
import { Form } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
@@ -46,20 +46,17 @@ export function NewScheduleButton({ name = "new-schedule" }: { name?: string })
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="mb-4">
|
<div className="mb-8">
|
||||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||||
{t("add_new_schedule")}
|
{t("add_new_schedule")}
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
handleSubmit={(values) => {
|
handleSubmit={(values) => {
|
||||||
createMutation.mutate(values);
|
createMutation.mutate(values);
|
||||||
}}>
|
}}>
|
||||||
<div className="mt-3 space-y-4">
|
<div className="mt-3 space-y-2">
|
||||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
||||||
{t("name")}
|
{t("name")}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||||
import { DuplicateIcon } from "@heroicons/react/solid";
|
import { DuplicateIcon } from "@heroicons/react/solid";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
|
||||||
import timezone from "dayjs/plugin/timezone";
|
|
||||||
import utc from "dayjs/plugin/utc";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||||
import { GroupBase, Props, SingleValue } from "react-select";
|
import { GroupBase, Props } from "react-select";
|
||||||
|
|
||||||
|
import dayjs, { Dayjs, ConfigType } from "@calcom/dayjs";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/Dropdown";
|
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/Dropdown";
|
||||||
|
|
||||||
import { defaultDayRange } from "@lib/availability";
|
import { defaultDayRange } from "@lib/availability";
|
||||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||||
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
import { TimeRange } from "@lib/types/schedule";
|
import { TimeRange } from "@lib/types/schedule";
|
||||||
|
|
||||||
import { useMeQuery } from "@components/Shell";
|
|
||||||
import Select from "@components/ui/form/Select";
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
|
||||||
dayjs.extend(timezone);
|
|
||||||
|
|
||||||
/** Begin Time Increments For Select */
|
/** Begin Time Increments For Select */
|
||||||
const increment = 15;
|
const increment = 15;
|
||||||
|
|
||||||
@@ -57,12 +52,13 @@ const useOptions = () => {
|
|||||||
t = t.add(increment, "minutes");
|
t = t.add(increment, "minutes");
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}, []);
|
}, [timeFormat]);
|
||||||
|
|
||||||
const filter = useCallback(
|
const filter = useCallback(
|
||||||
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
||||||
if (current) {
|
if (current) {
|
||||||
setFilteredOptions([options.find((option) => option.value === dayjs(current).toDate().valueOf())!]);
|
const currentOption = options.find((option) => option.value === dayjs(current).toDate().valueOf());
|
||||||
|
if (currentOption) setFilteredOptions([currentOption]);
|
||||||
} else
|
} else
|
||||||
setFilteredOptions(
|
setFilteredOptions(
|
||||||
options.filter((option) => {
|
options.filter((option) => {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
import { availabilityAsString } from "@calcom/lib/availability";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Availability } from "@calcom/prisma/client";
|
||||||
|
import { Button } from "@calcom/ui";
|
||||||
|
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||||
|
|
||||||
|
import { inferQueryOutput } from "@lib/trpc";
|
||||||
|
|
||||||
|
export function ScheduleListItem({
|
||||||
|
schedule,
|
||||||
|
deleteFunction,
|
||||||
|
isDeleting = false,
|
||||||
|
}: {
|
||||||
|
schedule: inferQueryOutput<"viewer.availability.list">["schedules"][number];
|
||||||
|
deleteFunction: ({ scheduleId }: { scheduleId: number }) => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={schedule.id}>
|
||||||
|
<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="group flex w-full items-center justify-between hover:bg-neutral-50 sm:px-6">
|
||||||
|
<Link href={"/availability/" + schedule.id}>
|
||||||
|
<a className="flex-grow truncate text-sm" title={schedule.name}>
|
||||||
|
<div>
|
||||||
|
<span className="truncate font-medium text-neutral-900">{schedule.name}</span>
|
||||||
|
{schedule.isDefault && (
|
||||||
|
<span className="ml-2 inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800">
|
||||||
|
{t("default")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{schedule.availability.map((availability: Availability) => (
|
||||||
|
<Fragment key={availability.id}>
|
||||||
|
{availabilityAsString(availability, i18n.language)}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownMenuTrigger className="group mr-5 h-10 w-10 border border-transparent p-0 text-neutral-500 hover:border-gray-200">
|
||||||
|
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Button
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={() => {
|
||||||
|
deleteFunction({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
color="warn"
|
||||||
|
className="w-full font-normal"
|
||||||
|
StartIcon={isDeleting ? undefined : TrashIcon}
|
||||||
|
loading={isDeleting}>
|
||||||
|
{isDeleting ? t("deleting") : t("delete_schedule")}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
|
function SkeletonLoader() {
|
||||||
|
return (
|
||||||
|
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<SkeletonText width="32" height="4"></SkeletonText>
|
||||||
|
<SkeletonText width="32" height="2"></SkeletonText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||||
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
|
<SkeletonText width="12" height="6"></SkeletonText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvailabilitySelectSkeletonLoader = () => {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<SkeletonText width="32" height="4"></SkeletonText>
|
||||||
|
<SkeletonText width="4" height="4"></SkeletonText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,61 +1,47 @@
|
|||||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
|
||||||
import { SchedulingType } from "@prisma/client";
|
import { SchedulingType } from "@prisma/client";
|
||||||
import { Dayjs } from "dayjs";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { nameOfDay } from "@calcom/lib/weekday";
|
import { nameOfDay } from "@calcom/lib/weekday";
|
||||||
|
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { timeZone } from "@lib/clock";
|
||||||
import { useSlots } from "@lib/hooks/useSlots";
|
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import type { Slot } from "@server/routers/viewer/slots";
|
||||||
|
|
||||||
type AvailableTimesProps = {
|
type AvailableTimesProps = {
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
minimumBookingNotice: number;
|
|
||||||
beforeBufferTime: number;
|
|
||||||
afterBufferTime: number;
|
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
eventLength: number;
|
recurringCount: number | undefined;
|
||||||
eventTypeSlug: string;
|
eventTypeSlug: string;
|
||||||
slotInterval: number | null;
|
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
users: {
|
users: {
|
||||||
username: string | null;
|
username: string | null;
|
||||||
}[];
|
}[];
|
||||||
schedulingType: SchedulingType | null;
|
schedulingType: SchedulingType | null;
|
||||||
|
seatsPerTimeSlot?: number | null;
|
||||||
|
slots?: Slot[];
|
||||||
|
isLoading: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AvailableTimes: FC<AvailableTimesProps> = ({
|
const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
|
slots = [],
|
||||||
|
isLoading,
|
||||||
date,
|
date,
|
||||||
eventLength,
|
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
eventTypeSlug,
|
eventTypeSlug,
|
||||||
slotInterval,
|
recurringCount,
|
||||||
minimumBookingNotice,
|
|
||||||
timeFormat,
|
timeFormat,
|
||||||
users,
|
|
||||||
schedulingType,
|
schedulingType,
|
||||||
beforeBufferTime,
|
seatsPerTimeSlot,
|
||||||
afterBufferTime,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { rescheduleUid } = router.query;
|
const { rescheduleUid } = router.query;
|
||||||
const { slots, loading, error } = useSlots({
|
|
||||||
date,
|
|
||||||
slotInterval,
|
|
||||||
eventLength,
|
|
||||||
schedulingType,
|
|
||||||
users,
|
|
||||||
minimumBookingNotice,
|
|
||||||
beforeBufferTime,
|
|
||||||
afterBufferTime,
|
|
||||||
eventTypeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [brand, setBrand] = useState("#292929");
|
const [brand, setBrand] = useState("#292929");
|
||||||
|
|
||||||
@@ -74,9 +60,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow overflow-y-auto md:h-[364px]">
|
<div className="grid flex-grow grid-cols-2 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
||||||
{!loading &&
|
{slots.length > 0 &&
|
||||||
slots?.length > 0 &&
|
|
||||||
slots.map((slot) => {
|
slots.map((slot) => {
|
||||||
type BookingURL = {
|
type BookingURL = {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
@@ -86,9 +71,11 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
pathname: "book",
|
pathname: "book",
|
||||||
query: {
|
query: {
|
||||||
...router.query,
|
...router.query,
|
||||||
date: slot.time.format(),
|
date: dayjs(slot.time).format(),
|
||||||
type: eventTypeId,
|
type: eventTypeId,
|
||||||
slug: eventTypeSlug,
|
slug: eventTypeSlug,
|
||||||
|
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||||
|
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,40 +87,70 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
bookingUrl.query.user = slot.users;
|
bookingUrl.query.user = slot.users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If event already has an attendee add booking id
|
||||||
|
if (slot.bookingUid) {
|
||||||
|
bookingUrl.query.bookingUid = slot.bookingUid;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={slot.time.format()}>
|
<div key={dayjs(slot.time).format()}>
|
||||||
<Link href={bookingUrl}>
|
{/* Current there is no way to disable Next.js Links */}
|
||||||
<a
|
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||||
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
"text-primary-500 mb-2 block rounded-sm border bg-white py-4 font-medium opacity-25 dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 ",
|
||||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||||
)}
|
)}>
|
||||||
data-testid="time">
|
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||||
{slot.time.format(timeFormat)}
|
{!!seatsPerTimeSlot && <p className={`text-sm`}>{t("booking_full")}</p>}
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
) : (
|
||||||
|
<Link href={bookingUrl} prefetch={false}>
|
||||||
|
<a
|
||||||
|
className={classNames(
|
||||||
|
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
||||||
|
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||||
|
)}
|
||||||
|
data-testid="time">
|
||||||
|
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||||
|
{!!seatsPerTimeSlot && (
|
||||||
|
<p
|
||||||
|
className={`${
|
||||||
|
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
|
||||||
|
? "text-rose-600"
|
||||||
|
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-emerald-400"
|
||||||
|
} text-sm`}>
|
||||||
|
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
||||||
|
{seatsPerTimeSlot} {t("seats_available")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!loading && !error && !slots.length && (
|
|
||||||
|
{!isLoading && !slots.length && (
|
||||||
<div className="-mt-4 flex h-full w-full flex-col content-center items-center justify-center">
|
<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="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && <Loader />}
|
{isLoading && !slots.length && (
|
||||||
|
<>
|
||||||
{error && (
|
<SkeletonContainer className="mb-2">
|
||||||
<div className="border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
<SkeletonText width="full" height="20" />
|
||||||
<div className="flex">
|
</SkeletonContainer>
|
||||||
<div className="flex-shrink-0">
|
<SkeletonContainer className="mb-2">
|
||||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
<SkeletonText width="full" height="20" />
|
||||||
</div>
|
</SkeletonContainer>
|
||||||
<div className="ltr:ml-3 rtl:mr-3">
|
<SkeletonContainer className="mb-2">
|
||||||
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
|
<SkeletonText width="full" height="20" />
|
||||||
</div>
|
</SkeletonContainer>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,76 @@
|
|||||||
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
import {
|
||||||
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
BanIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ClockIcon,
|
||||||
|
LocationMarkerIcon,
|
||||||
|
PaperAirplaneIcon,
|
||||||
|
PencilAltIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "@heroicons/react/outline";
|
||||||
|
import { RefreshIcon } from "@heroicons/react/solid";
|
||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import showToast from "@calcom/lib/notification";
|
||||||
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import { TextArea } from "@calcom/ui/form/fields";
|
import { TextArea } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
import { linkValueToString } from "@lib/linkValueToString";
|
||||||
|
import { LocationType } from "@lib/location";
|
||||||
|
import { parseRecurringDates } from "@lib/parseDate";
|
||||||
|
import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import { useMeQuery } from "@components/Shell";
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||||
|
|
||||||
|
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||||
|
|
||||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||||
|
|
||||||
function BookingListItem(booking: BookingItem) {
|
type BookingItemProps = BookingItem & {
|
||||||
|
listingStatus: BookingListingStatus;
|
||||||
|
recurringCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BookingListItem(booking: BookingItemProps) {
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
// Get user so we can determine 12/24 hour format preferences
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
const user = query.data;
|
const user = query.data;
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
const router = useRouter();
|
||||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
async (confirm: boolean) => {
|
async (confirm: boolean) => {
|
||||||
|
let body = {
|
||||||
|
id: booking.id,
|
||||||
|
confirmed: confirm,
|
||||||
|
language: i18n.language,
|
||||||
|
reason: rejectionReason,
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||||
|
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
|
||||||
|
*/
|
||||||
|
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||||
|
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||||
|
}
|
||||||
const res = await fetch("/api/book/confirm", {
|
const res = await fetch("/api/book/confirm", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
id: booking.id,
|
|
||||||
confirmed: confirm,
|
|
||||||
language: i18n.language,
|
|
||||||
reason: rejectionReason,
|
|
||||||
}),
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -45,6 +78,7 @@ function BookingListItem(booking: BookingItem) {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new HttpError({ statusCode: res.status });
|
throw new HttpError({ statusCode: res.status });
|
||||||
}
|
}
|
||||||
|
setRejectionDialogIsOpen(false);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
@@ -54,51 +88,84 @@ function BookingListItem(booking: BookingItem) {
|
|||||||
);
|
);
|
||||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||||
|
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||||
|
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||||
|
const isPending = booking.status === BookingStatus.PENDING;
|
||||||
|
|
||||||
const pendingActions: ActionType[] = [
|
const pendingActions: ActionType[] = [
|
||||||
{
|
{
|
||||||
id: "reject",
|
id: "reject",
|
||||||
label: t("reject"),
|
label:
|
||||||
onClick: () => setRejectionDialogIsOpen(true),
|
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||||
|
? t("reject_all")
|
||||||
|
: t("reject"),
|
||||||
|
onClick: () => {
|
||||||
|
setRejectionDialogIsOpen(true);
|
||||||
|
},
|
||||||
icon: BanIcon,
|
icon: BanIcon,
|
||||||
disabled: mutation.isLoading,
|
disabled: mutation.isLoading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "confirm",
|
id: "confirm",
|
||||||
label: t("confirm"),
|
label:
|
||||||
onClick: () => mutation.mutate(true),
|
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||||
|
? t("confirm_all")
|
||||||
|
: t("confirm"),
|
||||||
|
onClick: () => {
|
||||||
|
mutation.mutate(true);
|
||||||
|
},
|
||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
disabled: mutation.isLoading,
|
disabled: mutation.isLoading,
|
||||||
color: "primary",
|
color: "primary",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const bookedActions: ActionType[] = [
|
let bookedActions: ActionType[] = [
|
||||||
{
|
{
|
||||||
id: "cancel",
|
id: "cancel",
|
||||||
label: t("cancel"),
|
label:
|
||||||
|
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||||
|
? t("cancel_all_remaining")
|
||||||
|
: t("cancel"),
|
||||||
href: `/cancel/${booking.uid}`,
|
href: `/cancel/${booking.uid}`,
|
||||||
icon: XIcon,
|
icon: XIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "reschedule",
|
id: "edit_booking",
|
||||||
label: t("reschedule"),
|
label: t("edit_booking"),
|
||||||
icon: ClockIcon,
|
icon: PencilAltIcon,
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: "edit",
|
id: "reschedule",
|
||||||
label: t("edit_booking"),
|
icon: ClockIcon,
|
||||||
|
label: t("reschedule_booking"),
|
||||||
href: `/reschedule/${booking.uid}`,
|
href: `/reschedule/${booking.uid}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "reschedule_request",
|
id: "reschedule_request",
|
||||||
|
icon: PaperAirplaneIcon,
|
||||||
|
iconClassName: "rotate-45 w-[18px] -ml-[2px]",
|
||||||
label: t("send_reschedule_request"),
|
label: t("send_reschedule_request"),
|
||||||
onClick: () => setIsOpenRescheduleDialog(true),
|
onClick: () => {
|
||||||
|
setIsOpenRescheduleDialog(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "change_location",
|
||||||
|
label: t("edit_location"),
|
||||||
|
onClick: () => {
|
||||||
|
setIsOpenLocationDialog(true);
|
||||||
|
},
|
||||||
|
icon: LocationMarkerIcon,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||||
|
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||||
|
}
|
||||||
|
|
||||||
const RequestSentMessage = () => {
|
const RequestSentMessage = () => {
|
||||||
return (
|
return (
|
||||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||||
@@ -110,6 +177,81 @@ function BookingListItem(booking: BookingItem) {
|
|||||||
|
|
||||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||||
|
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||||
|
const setLocationMutation = trpc.useMutation("viewer.bookings.editLocation", {
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast(t("location_updated"), "success");
|
||||||
|
setIsOpenLocationDialog(false);
|
||||||
|
utils.invalidateQueries("viewer.bookings");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveLocation = (newLocationType: LocationType, details: { [key: string]: string }) => {
|
||||||
|
let newLocation = newLocationType as string;
|
||||||
|
if (
|
||||||
|
newLocationType === LocationType.InPerson ||
|
||||||
|
newLocationType === LocationType.Link ||
|
||||||
|
newLocationType === LocationType.UserPhone
|
||||||
|
) {
|
||||||
|
newLocation = details[Object.keys(details)[0]];
|
||||||
|
}
|
||||||
|
setLocationMutation.mutate({ bookingId: booking.id, newLocation });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate the booking date(s) and setup recurring event data to show
|
||||||
|
let recurringStrings: string[] = [];
|
||||||
|
let recurringDates: Date[] = [];
|
||||||
|
const today = new Date();
|
||||||
|
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||||
|
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||||
|
{
|
||||||
|
startDate: booking.startTime,
|
||||||
|
timeZone: user?.timeZone,
|
||||||
|
recurringEvent: parseRecurringEvent(booking.eventType.recurringEvent),
|
||||||
|
recurringCount: booking.recurringCount,
|
||||||
|
},
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
if (booking.status === BookingStatus.PENDING) {
|
||||||
|
// Only take into consideration next up instances if booking is confirmed
|
||||||
|
recurringDates = recurringDates.filter((aDate) => aDate >= today);
|
||||||
|
recurringStrings = recurringDates.map((_, key) => recurringStrings[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let location = booking.location || "";
|
||||||
|
|
||||||
|
if (location.includes("integration")) {
|
||||||
|
if (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED) {
|
||||||
|
location = t("web_conference");
|
||||||
|
} else if (isConfirmed) {
|
||||||
|
location = linkValueToString(booking.location, t);
|
||||||
|
} else {
|
||||||
|
location = t("web_conferencing_details_to_follow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/success",
|
||||||
|
query: {
|
||||||
|
date: booking.startTime,
|
||||||
|
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
||||||
|
type: booking.eventType.id || 0,
|
||||||
|
eventSlug: booking.eventType.slug,
|
||||||
|
user: user?.username || "",
|
||||||
|
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
||||||
|
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||||
|
location: location,
|
||||||
|
eventName: booking.eventType.eventName || "",
|
||||||
|
bookingId: booking.id,
|
||||||
|
recur: booking.recurringEventId,
|
||||||
|
reschedule: isConfirmed,
|
||||||
|
listingStatus: booking.listingStatus,
|
||||||
|
status: booking.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RescheduleDialog
|
<RescheduleDialog
|
||||||
@@ -117,6 +259,12 @@ function BookingListItem(booking: BookingItem) {
|
|||||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||||
bookingUId={booking.uid}
|
bookingUId={booking.uid}
|
||||||
/>
|
/>
|
||||||
|
<EditLocationDialog
|
||||||
|
booking={booking}
|
||||||
|
saveLocation={saveLocation}
|
||||||
|
isOpenDialog={isOpenSetLocationDialog}
|
||||||
|
setShowLocationModal={setIsOpenLocationDialog}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||||
@@ -151,72 +299,102 @@ function BookingListItem(booking: BookingItem) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<tr className="flex">
|
<tr className="flex hover:bg-neutral-50">
|
||||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
|
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-64" onClick={onClick}>
|
||||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
<div className="cursor-pointer py-4">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
<div className="text-sm text-gray-500">
|
||||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||||
|
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{booking.recurringCount &&
|
||||||
|
booking.eventType?.recurringEvent?.freq &&
|
||||||
|
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
|
||||||
|
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||||
|
<div className="flex">
|
||||||
|
<Tooltip
|
||||||
|
content={recurringStrings.map((aDate, key) => (
|
||||||
|
<p key={key}>{aDate}</p>
|
||||||
|
))}>
|
||||||
|
<div className="text-gray-600 dark:text-white">
|
||||||
|
<RefreshIcon className="float-left mr-1 mt-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
|
<p className="pl-[21px]">
|
||||||
|
{booking.status === BookingStatus.ACCEPTED
|
||||||
|
? `${t("event_remaining", {
|
||||||
|
count: recurringDates.length,
|
||||||
|
})}`
|
||||||
|
: getEveryFreqFor({
|
||||||
|
t,
|
||||||
|
recurringEvent: booking.eventType.recurringEvent,
|
||||||
|
recurringCount: booking.recurringCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
<td className={"flex-1 ltr:pl-4 rtl:pr-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
|
||||||
<div className="sm:hidden">
|
<div className="cursor-pointer py-4">
|
||||||
{!booking.confirmed && !booking.rejected && (
|
<div className="sm:hidden">
|
||||||
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>
|
{isPending && <Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>}
|
||||||
)}
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
|
||||||
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
|
)}
|
||||||
)}
|
<div className="text-sm font-medium text-gray-900">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
{startTime}:{" "}
|
||||||
{startTime}:{" "}
|
<small className="text-sm text-gray-500">
|
||||||
<small className="text-sm text-gray-500">
|
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
</small>
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
<div
|
title={booking.title}
|
||||||
title={booking.title}
|
className={classNames(
|
||||||
className={classNames(
|
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
|
||||||
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
|
isCancelled ? "line-through" : ""
|
||||||
isCancelled ? "line-through" : ""
|
)}>
|
||||||
)}>
|
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
{booking.title}
|
||||||
{booking.title}
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
||||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
)}
|
||||||
)}
|
{isPending && <Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>}
|
||||||
{!booking.confirmed && !booking.rejected && (
|
|
||||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{booking.description && (
|
|
||||||
<div className="max-w-52 md:max-w-96 truncate text-sm text-gray-500" title={booking.description}>
|
|
||||||
"{booking.description}"
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{booking.description && (
|
||||||
|
<div
|
||||||
|
className="max-w-52 md:max-w-96 truncate text-sm text-gray-500"
|
||||||
|
title={booking.description}>
|
||||||
|
"{booking.description}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{booking.attendees.length !== 0 && (
|
{booking.attendees.length !== 0 && (
|
||||||
<div className="text-sm text-gray-900 hover:text-blue-500">
|
<a
|
||||||
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
|
className="text-sm text-gray-900 hover:text-blue-500"
|
||||||
</div>
|
href={"mailto:" + booking.attendees[0].email}
|
||||||
)}
|
onClick={(e) => e.stopPropagation()}>
|
||||||
{isCancelled && booking.rescheduled && (
|
{booking.attendees[0].email}
|
||||||
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
</a>
|
||||||
<RequestSentMessage />
|
)}
|
||||||
</div>
|
{isCancelled && booking.rescheduled && (
|
||||||
)}
|
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
||||||
|
<RequestSentMessage />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
|
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
|
||||||
{isUpcoming && !isCancelled ? (
|
{isUpcoming && !isCancelled ? (
|
||||||
<>
|
<>
|
||||||
{!booking.confirmed && !booking.rejected && user!.id === booking.user!.id && (
|
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||||
<TableActions actions={pendingActions} />
|
{isConfirmed && <TableActions actions={bookedActions} />}
|
||||||
)}
|
{isRejected && <div className="text-sm text-gray-500">{t("rejected")}</div>}
|
||||||
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
|
|
||||||
{!booking.confirmed && booking.rejected && (
|
|
||||||
<div className="text-sm text-gray-500">{t("rejected")}</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{isCancelled && booking.rescheduled && (
|
{isCancelled && booking.rescheduled && (
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { XIcon } from "@heroicons/react/solid";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
import { Button } from "@calcom/ui/Button";
|
||||||
|
|
||||||
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
booking: {
|
||||||
|
title?: string;
|
||||||
|
uid?: string;
|
||||||
|
};
|
||||||
|
profile: {
|
||||||
|
name: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
};
|
||||||
|
recurringEvent: RecurringEvent | null;
|
||||||
|
team?: string | null;
|
||||||
|
setIsCancellationMode: (value: boolean) => void;
|
||||||
|
theme: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CancelBooking(props: Props) {
|
||||||
|
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||||
|
const { t } = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const { booking, profile, team } = props;
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||||
|
const { isReady, Theme } = useTheme(props.theme);
|
||||||
|
|
||||||
|
if (isReady) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Theme />
|
||||||
|
{error && (
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
|
<XIcon 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">
|
||||||
|
{error}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && (
|
||||||
|
<div className="mt-5 sm:mt-6">
|
||||||
|
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||||
|
<textarea
|
||||||
|
placeholder={t("cancellation_reason_placeholder")}
|
||||||
|
value={cancellationReason}
|
||||||
|
onChange={(e) => setCancellationReason(e.target.value)}
|
||||||
|
className="mt-2 mb-3 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:mb-3 "
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex rtl:space-x-reverse">
|
||||||
|
{!props.recurringEvent && (
|
||||||
|
<div className="w-full">
|
||||||
|
<Button color="secondary" onClick={() => router.push("/reschedule/" + booking?.uid)}>
|
||||||
|
{t("reschedule_this")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full space-x-2 text-right">
|
||||||
|
<Button color="secondary" onClick={() => props.setIsCancellationMode(false)}>
|
||||||
|
{t("nevermind")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="cancel"
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
uid: booking?.uid,
|
||||||
|
reason: cancellationReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||||
|
|
||||||
|
const res = await fetch("/api/cancel", {
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status >= 200 && res.status < 300) {
|
||||||
|
await router.push(
|
||||||
|
`/cancel/success?name=${props.profile.name}&title=${booking?.title}&eventPage=${
|
||||||
|
profile.slug
|
||||||
|
}&team=${team ? 1 : 0}&recurring=${!!props.recurringEvent}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
setError(
|
||||||
|
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||||
|
"please_try_again"
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={loading}>
|
||||||
|
{t("cancel_event")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
|
||||||
import { EventType, PeriodType } from "@prisma/client";
|
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
|
||||||
import dayjsBusinessTime from "dayjs-business-time";
|
|
||||||
import timezone from "dayjs/plugin/timezone";
|
|
||||||
import utc from "dayjs/plugin/utc";
|
|
||||||
import { memoize } from "lodash";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { useEmbedStyles } from "@calcom/embed-core";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
|
||||||
import { timeZone } from "@lib/clock";
|
|
||||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
|
||||||
import { doWorkAsync } from "@lib/doWorkAsync";
|
|
||||||
import getSlots from "@lib/slots";
|
|
||||||
import { WorkingHours } from "@lib/types/schedule";
|
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
|
||||||
|
|
||||||
dayjs.extend(dayjsBusinessTime);
|
|
||||||
dayjs.extend(utc);
|
|
||||||
dayjs.extend(timezone);
|
|
||||||
|
|
||||||
type DatePickerProps = {
|
|
||||||
weekStart: string;
|
|
||||||
onDatePicked: (pickedDate: Dayjs) => void;
|
|
||||||
workingHours: WorkingHours[];
|
|
||||||
eventLength: number;
|
|
||||||
date: Dayjs | null;
|
|
||||||
periodType: PeriodType;
|
|
||||||
periodStartDate: Date | null;
|
|
||||||
periodEndDate: Date | null;
|
|
||||||
periodDays: number | null;
|
|
||||||
periodCountCalendarDays: boolean | null;
|
|
||||||
minimumBookingNotice: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isOutOfBounds(
|
|
||||||
time: dayjs.ConfigType,
|
|
||||||
{
|
|
||||||
periodType,
|
|
||||||
periodDays,
|
|
||||||
periodCountCalendarDays,
|
|
||||||
periodStartDate,
|
|
||||||
periodEndDate,
|
|
||||||
}: Pick<
|
|
||||||
EventType,
|
|
||||||
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
|
|
||||||
>
|
|
||||||
) {
|
|
||||||
const date = dayjs(time);
|
|
||||||
|
|
||||||
switch (periodType) {
|
|
||||||
case PeriodType.ROLLING: {
|
|
||||||
const periodRollingEndDay = periodCountCalendarDays
|
|
||||||
? dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
|
|
||||||
: dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
|
|
||||||
return date.endOf("day").isAfter(periodRollingEndDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
case PeriodType.RANGE: {
|
|
||||||
const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day");
|
|
||||||
const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day");
|
|
||||||
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
case PeriodType.UNLIMITED:
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DatePicker({
|
|
||||||
weekStart,
|
|
||||||
onDatePicked,
|
|
||||||
workingHours,
|
|
||||||
eventLength,
|
|
||||||
date,
|
|
||||||
periodType = PeriodType.UNLIMITED,
|
|
||||||
periodStartDate,
|
|
||||||
periodEndDate,
|
|
||||||
periodDays,
|
|
||||||
periodCountCalendarDays,
|
|
||||||
minimumBookingNotice,
|
|
||||||
}: DatePickerProps): JSX.Element {
|
|
||||||
const { i18n } = useLocale();
|
|
||||||
const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
|
|
||||||
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
|
|
||||||
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
|
|
||||||
const [month, setMonth] = useState<string>("");
|
|
||||||
const [year, setYear] = useState<string>("");
|
|
||||||
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
|
||||||
const [daysFromState, setDays] = useState<
|
|
||||||
| {
|
|
||||||
disabled: Boolean;
|
|
||||||
date: number;
|
|
||||||
}[]
|
|
||||||
| null
|
|
||||||
>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
|
|
||||||
setBrowsingDate(date || dayjs().tz(timeZone()));
|
|
||||||
}
|
|
||||||
}, [date, browsingDate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (browsingDate) {
|
|
||||||
setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
|
|
||||||
setYear(browsingDate.format("YYYY"));
|
|
||||||
setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
|
|
||||||
setDays(null);
|
|
||||||
}
|
|
||||||
}, [browsingDate, i18n.language]);
|
|
||||||
|
|
||||||
const isDisabled = (
|
|
||||||
day: number,
|
|
||||||
{
|
|
||||||
browsingDate,
|
|
||||||
periodType,
|
|
||||||
periodStartDate,
|
|
||||||
periodEndDate,
|
|
||||||
periodCountCalendarDays,
|
|
||||||
periodDays,
|
|
||||||
eventLength,
|
|
||||||
minimumBookingNotice,
|
|
||||||
workingHours,
|
|
||||||
}: Omit<DatePickerProps, "weekStart" | "onDatePicked" | "date"> & {
|
|
||||||
browsingDate: Dayjs;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const date = browsingDate.startOf("day").date(day);
|
|
||||||
return (
|
|
||||||
isOutOfBounds(date, {
|
|
||||||
periodType,
|
|
||||||
periodStartDate,
|
|
||||||
periodEndDate,
|
|
||||||
periodCountCalendarDays,
|
|
||||||
periodDays,
|
|
||||||
}) ||
|
|
||||||
!getSlots({
|
|
||||||
inviteeDate: date,
|
|
||||||
frequency: eventLength,
|
|
||||||
minimumBookingNotice,
|
|
||||||
workingHours,
|
|
||||||
eventLength,
|
|
||||||
}).length
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabledRef = useRef(
|
|
||||||
memoize(isDisabled, (day, { browsingDate }) => {
|
|
||||||
// Make a composite cache key
|
|
||||||
return day + "_" + browsingDate.toString();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const days = (() => {
|
|
||||||
if (!browsingDate) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (daysFromState) {
|
|
||||||
return daysFromState;
|
|
||||||
}
|
|
||||||
// Create placeholder elements for empty days in first week
|
|
||||||
let weekdayOfFirst = browsingDate.date(1).day();
|
|
||||||
if (weekStart === "Monday") {
|
|
||||||
weekdayOfFirst -= 1;
|
|
||||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Array(weekdayOfFirst).fill(null);
|
|
||||||
|
|
||||||
const isDisabledMemoized = isDisabledRef.current;
|
|
||||||
|
|
||||||
const daysInMonth = browsingDate.daysInMonth();
|
|
||||||
const daysInitialOffset = days.length;
|
|
||||||
|
|
||||||
// Build UI with All dates disabled
|
|
||||||
for (let i = 1; i <= daysInMonth; i++) {
|
|
||||||
days.push({
|
|
||||||
disabled: true,
|
|
||||||
date: i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update dates with their availability
|
|
||||||
doWorkAsync({
|
|
||||||
batch: 1,
|
|
||||||
name: "DatePicker",
|
|
||||||
length: daysInMonth,
|
|
||||||
callback: (i: number) => {
|
|
||||||
let day = i + 1;
|
|
||||||
days[daysInitialOffset + i] = {
|
|
||||||
disabled: isDisabledMemoized(day, {
|
|
||||||
browsingDate,
|
|
||||||
periodType,
|
|
||||||
periodStartDate,
|
|
||||||
periodEndDate,
|
|
||||||
periodCountCalendarDays,
|
|
||||||
periodDays,
|
|
||||||
eventLength,
|
|
||||||
minimumBookingNotice,
|
|
||||||
workingHours,
|
|
||||||
}),
|
|
||||||
date: day,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
batchDone: () => {
|
|
||||||
setDays([...days]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return days;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!browsingDate) {
|
|
||||||
return <Loader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle month changes
|
|
||||||
const incrementMonth = () => {
|
|
||||||
setBrowsingDate(browsingDate?.add(1, "month"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const decrementMonth = () => {
|
|
||||||
setBrowsingDate(browsingDate?.subtract(1, "month"));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"mt-8 sm:mt-0 sm:min-w-[455px] " +
|
|
||||||
(date
|
|
||||||
? "w-full sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
|
|
||||||
: "w-full sm:pl-4")
|
|
||||||
}>
|
|
||||||
<div className="mb-4 flex text-xl font-light">
|
|
||||||
<span className="w-1/2 dark:text-white">
|
|
||||||
<strong className="text-bookingdarker dark:text-white">{month}</strong>{" "}
|
|
||||||
<span className="text-bookinglight">{year}</span>
|
|
||||||
</span>
|
|
||||||
<div className="w-1/2 text-right dark:text-gray-400">
|
|
||||||
<button
|
|
||||||
onClick={decrementMonth}
|
|
||||||
className={classNames(
|
|
||||||
"group p-1 ltr:mr-2 rtl:ml-2",
|
|
||||||
isFirstMonth && "text-bookinglighter dark:text-gray-600"
|
|
||||||
)}
|
|
||||||
disabled={isFirstMonth}
|
|
||||||
data-testid="decrementMonth">
|
|
||||||
<ChevronLeftIcon className="h-5 w-5 group-hover:text-black dark:group-hover:text-white" />
|
|
||||||
</button>
|
|
||||||
<button className="group p-1" onClick={incrementMonth} data-testid="incrementMonth">
|
|
||||||
<ChevronRightIcon className="h-5 w-5 group-hover:text-black dark:group-hover:text-white" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-bookinglightest grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
|
|
||||||
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
|
|
||||||
<div key={weekDay} className="text-bookinglight my-4 text-xs uppercase tracking-widest">
|
|
||||||
{weekDay}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-2 text-center">
|
|
||||||
{days.map((day, idx) => (
|
|
||||||
<div
|
|
||||||
key={day === null ? `e-${idx}` : `day-${day.date}`}
|
|
||||||
style={{
|
|
||||||
paddingTop: "100%",
|
|
||||||
}}
|
|
||||||
className="relative w-full">
|
|
||||||
{day === null ? (
|
|
||||||
<div key={`e-${idx}`} />
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => onDatePicked(browsingDate.date(day.date))}
|
|
||||||
disabled={day.disabled}
|
|
||||||
style={
|
|
||||||
day.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }
|
|
||||||
}
|
|
||||||
className={classNames(
|
|
||||||
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center",
|
|
||||||
"hover:border-brand hover:border dark:hover:border-white",
|
|
||||||
day.disabled
|
|
||||||
? "text-bookinglighter cursor-default font-light hover:border-0"
|
|
||||||
: "font-medium",
|
|
||||||
date && date.isSame(browsingDate.date(day.date), "day")
|
|
||||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
|
|
||||||
: !day.disabled
|
|
||||||
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
|
|
||||||
: ""
|
|
||||||
)}
|
|
||||||
data-testid="day"
|
|
||||||
data-disabled={day.disabled}>
|
|
||||||
{day.date}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DatePicker;
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
|
function SkeletonLoader() {
|
||||||
|
return (
|
||||||
|
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between px-2 py-4 sm:px-6">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<SkeletonText width="16" height="5" />
|
||||||
|
<SkeletonText width="32" height="4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||||
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
|
<SkeletonText width="16" height="6" />
|
||||||
|
<SkeletonText width="32" height="6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
|
||||||
|
|
||||||
import Switch from "@calcom/ui/Switch";
|
import Switch from "@calcom/ui/Switch";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
|
||||||
|
import TimezoneSelect, { ITimezoneOption } from "@components/ui/form/TimezoneSelect";
|
||||||
|
|
||||||
import { is24h, timeZone } from "../../lib/clock";
|
import { is24h, timeZone } from "../../lib/clock";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,137 +1,373 @@
|
|||||||
// Get router variables
|
// Get router variables
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CalendarIcon,
|
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
|
ClipboardCheckIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
|
LocationMarkerIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
VideoCameraIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
|
import { EventType } from "@prisma/client";
|
||||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
import { useContracts } from "contexts/contractsContext";
|
import { useContracts } from "contexts/contractsContext";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import { TFunction } from "next-i18next";
|
||||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
import Link from "next/link";
|
||||||
import utc from "dayjs/plugin/utc";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core";
|
import { AppStoreLocationType, LocationObject, LocationType } from "@calcom/app-store/locations";
|
||||||
|
import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||||
|
import {
|
||||||
|
useEmbedNonStylesConfig,
|
||||||
|
useEmbedStyles,
|
||||||
|
useIsBackgroundTransparent,
|
||||||
|
useIsEmbed,
|
||||||
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { CAL_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||||
|
import { localStorage } from "@calcom/lib/webstorage";
|
||||||
|
import DatePicker from "@calcom/ui/booker/DatePicker";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||||
import { timeZone } from "@lib/clock";
|
// import { timeZone } from "@lib/clock";
|
||||||
import { BASE_URL } from "@lib/config/constants";
|
|
||||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
import { parseDate } from "@lib/parseDate";
|
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||||
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import CustomBranding from "@components/CustomBranding";
|
import CustomBranding from "@components/CustomBranding";
|
||||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||||
import DatePicker from "@components/booking/DatePicker";
|
|
||||||
import TimeOptions from "@components/booking/TimeOptions";
|
import TimeOptions from "@components/booking/TimeOptions";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||||
|
|
||||||
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||||
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||||
|
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||||
dayjs.extend(customParseFormat);
|
|
||||||
|
|
||||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
export const locationKeyToString = (location: LocationObject, t: TFunction) => {
|
||||||
|
switch (location.type) {
|
||||||
|
case LocationType.InPerson:
|
||||||
|
return location.address || "In Person"; // If disabled address won't exist on the object
|
||||||
|
case LocationType.Link:
|
||||||
|
return location.link || "Link"; // If disabled link won't exist on the object
|
||||||
|
case LocationType.Phone:
|
||||||
|
return t("your_number");
|
||||||
|
case LocationType.UserPhone:
|
||||||
|
return t("phone_call");
|
||||||
|
case LocationType.GoogleMeet:
|
||||||
|
return "Google Meet";
|
||||||
|
case LocationType.Zoom:
|
||||||
|
return "Zoom";
|
||||||
|
case LocationType.Daily:
|
||||||
|
return "Cal Video";
|
||||||
|
case LocationType.Jitsi:
|
||||||
|
return "Jitsi";
|
||||||
|
case LocationType.Huddle01:
|
||||||
|
return "Huddle Video";
|
||||||
|
case LocationType.Tandem:
|
||||||
|
return "Tandem";
|
||||||
|
case LocationType.Teams:
|
||||||
|
return "Microsoft Teams";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => {
|
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const path = router.asPath.split("/");
|
||||||
|
path.pop(); // Remove the last item (where we currently are)
|
||||||
|
path.shift(); // Removes first item e.g. if we were visitng "/teams/test/30mins" the array will new look like ["teams","test"]
|
||||||
|
const slug = path.join("/");
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col justify-end">
|
||||||
|
<button title={t("profile")} onClick={() => router.replace(`${WEBSITE_URL}/${slug}`)}>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white" />
|
||||||
|
<p className="sr-only">Go Back</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSlots = ({
|
||||||
|
eventTypeId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
timeZone,
|
||||||
|
}: {
|
||||||
|
eventTypeId: number;
|
||||||
|
startTime?: Dayjs;
|
||||||
|
endTime?: Dayjs;
|
||||||
|
timeZone: string;
|
||||||
|
}) => {
|
||||||
|
const { data, isLoading, isIdle } = trpc.useQuery(
|
||||||
|
[
|
||||||
|
"viewer.public.slots.getSchedule",
|
||||||
|
{
|
||||||
|
eventTypeId,
|
||||||
|
startTime: startTime?.toISOString() || "",
|
||||||
|
endTime: endTime?.toISOString() || "",
|
||||||
|
timeZone,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ enabled: !!startTime && !!endTime }
|
||||||
|
);
|
||||||
|
|
||||||
|
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.slots) {
|
||||||
|
setCachedSlots((c) => ({ ...c, ...data?.slots }));
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// The very first time isIdle is set if auto-fetch is disabled, so isIdle should also be considered a loading state.
|
||||||
|
return { slots: cachedSlots, isLoading: isLoading || isIdle };
|
||||||
|
};
|
||||||
|
|
||||||
|
const SlotPicker = ({
|
||||||
|
eventType,
|
||||||
|
timeFormat,
|
||||||
|
timeZone,
|
||||||
|
recurringEventCount,
|
||||||
|
seatsPerTimeSlot,
|
||||||
|
weekStart = 0,
|
||||||
|
}: {
|
||||||
|
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
|
||||||
|
timeFormat: string;
|
||||||
|
timeZone: string;
|
||||||
|
seatsPerTimeSlot?: number;
|
||||||
|
recurringEventCount?: number;
|
||||||
|
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
}) => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||||
|
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||||
|
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||||
|
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) return;
|
||||||
|
|
||||||
|
// Etc/GMT is not actually a timeZone, so handle this select option explicitly to prevent a hard crash.
|
||||||
|
if (timeZone === "Etc/GMT") {
|
||||||
|
setBrowsingDate(dayjs.utc(month).startOf("month"));
|
||||||
|
if (date) {
|
||||||
|
setSelectedDate(dayjs.utc(date));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBrowsingDate(dayjs(month).tz(timeZone, true).startOf("month"));
|
||||||
|
if (date) {
|
||||||
|
setSelectedDate(dayjs(date).tz(timeZone, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [router.isReady, month, date, timeZone]);
|
||||||
|
|
||||||
|
const { i18n, isLocaleReady } = useLocale();
|
||||||
|
const { slots: _1 } = useSlots({
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
startTime: selectedDate?.startOf("day"),
|
||||||
|
endTime: selectedDate?.endOf("day"),
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const { slots: _2, isLoading } = useSlots({
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
startTime: browsingDate?.startOf("month"),
|
||||||
|
endTime: browsingDate?.endOf("month"),
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = useMemo(() => ({ ..._1, ..._2 }), [_1, _2]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DatePicker
|
||||||
|
isLoading={isLoading}
|
||||||
|
className={
|
||||||
|
"mt-8 w-full sm:mt-0 sm:min-w-[455px] " +
|
||||||
|
(selectedDate
|
||||||
|
? "sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
|
||||||
|
: "sm:pl-4")
|
||||||
|
}
|
||||||
|
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
||||||
|
locale={isLocaleReady ? i18n.language : "en"}
|
||||||
|
selected={selectedDate}
|
||||||
|
onChange={(newDate) => {
|
||||||
|
setDate(newDate.format("YYYY-MM-DD"));
|
||||||
|
}}
|
||||||
|
onMonthChange={(newMonth) => {
|
||||||
|
setMonth(newMonth.format("YYYY-MM"));
|
||||||
|
}}
|
||||||
|
browsingDate={browsingDate}
|
||||||
|
weekStart={weekStart}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<AvailableTimes
|
||||||
|
isLoading={isLoading}
|
||||||
|
slots={slots[selectedDate.format("YYYY-MM-DD")]}
|
||||||
|
date={selectedDate}
|
||||||
|
timeFormat={timeFormat}
|
||||||
|
eventTypeId={eventType.id}
|
||||||
|
eventTypeSlug={eventType.slug}
|
||||||
|
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||||
|
recurringCount={recurringEventCount}
|
||||||
|
schedulingType={eventType.schedulingType}
|
||||||
|
users={[]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function TimezoneDropdown({
|
||||||
|
onChangeTimeFormat,
|
||||||
|
onChangeTimeZone,
|
||||||
|
timeZone,
|
||||||
|
}: {
|
||||||
|
onChangeTimeFormat: (newTimeFormat: string) => void;
|
||||||
|
onChangeTimeZone: (newTimeZone: string) => void;
|
||||||
|
timeZone?: string;
|
||||||
|
}) {
|
||||||
|
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||||
|
onChangeTimeZone(newTimeZone);
|
||||||
|
localStorageTimeZone(newTimeZone);
|
||||||
|
setIsTimeOptionsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||||
|
onChangeTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||||
|
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left dark:text-white">
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
<GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
{timeZone}
|
||||||
|
{isTimeOptionsOpen ? (
|
||||||
|
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="ml-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateQuerySchema = z.object({
|
||||||
|
rescheduleUid: z.string().optional().default(""),
|
||||||
|
date: z.string().optional().default(""),
|
||||||
|
timeZone: z.string().optional().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const useRouterQuery = <T extends string>(name: T) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const query = z.object({ [name]: z.string().optional() }).parse(router.query);
|
||||||
|
|
||||||
|
const setQuery = (newValue: string | number | null | undefined) => {
|
||||||
|
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { [name]: query[name], setQuery } as {
|
||||||
|
[K in T]: string | undefined;
|
||||||
|
} & { setQuery: typeof setQuery };
|
||||||
|
};
|
||||||
|
|
||||||
|
const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed();
|
||||||
const { rescheduleUid } = router.query;
|
const query = dateQuerySchema.parse(router.query);
|
||||||
const { isReady, Theme } = useTheme(profile.theme);
|
const { rescheduleUid } = query;
|
||||||
const { t, i18n } = useLocale();
|
const { Theme } = useTheme(profile.theme);
|
||||||
|
const { t } = useLocale();
|
||||||
const { contracts } = useContracts();
|
const { contracts } = useContracts();
|
||||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||||
let isBackgroundTransparent = useIsBackgroundTransparent();
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
useExposePlanGlobally(plan);
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
|
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||||
|
|
||||||
|
const [timeZone, setTimeZone] = useState<string>();
|
||||||
|
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||||
|
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAvailableTimesVisible(!!query.date);
|
||||||
|
}, [query.date]);
|
||||||
|
|
||||||
|
// TODO: Improve this;
|
||||||
|
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||||
|
|
||||||
|
// TODO: this needs to be extracted elsewhere
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (eventType.metadata.smartContractAddress) {
|
if (eventType.metadata.smartContractAddress) {
|
||||||
const eventOwner = eventType.users[0];
|
const eventOwner = eventType.users[0];
|
||||||
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
||||||
router.replace(`/${eventOwner.username}`);
|
router.replace(`/${eventOwner.username}`);
|
||||||
}
|
}
|
||||||
}, [contracts, eventType.metadata.smartContractAddress, router]);
|
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||||
|
|
||||||
const selectedDate = useMemo(() => {
|
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||||
const dateString = asStringOrNull(router.query.date);
|
|
||||||
if (dateString) {
|
|
||||||
const offsetString = dateString.substr(11, 14); // hhmm
|
|
||||||
const offsetSign = dateString.substr(10, 1); // + or -
|
|
||||||
|
|
||||||
const offsetHour = offsetString.slice(0, -2);
|
|
||||||
const offsetMinute = offsetString.slice(-2);
|
|
||||||
|
|
||||||
const utcOffsetInMinutes =
|
|
||||||
(offsetSign === "-" ? -1 : 1) *
|
|
||||||
(60 * (offsetHour !== "" ? parseInt(offsetHour) : 0) +
|
|
||||||
(offsetMinute !== "" ? parseInt(offsetMinute) : 0));
|
|
||||||
|
|
||||||
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true);
|
|
||||||
return date.isValid() ? date : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [router.query.date]);
|
|
||||||
|
|
||||||
if (selectedDate) {
|
|
||||||
// Let iframe take the width available due to increase in max-width
|
|
||||||
sdkActionManager?.fire("__refreshWidth", {});
|
|
||||||
}
|
|
||||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
|
||||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
if (top !== window) {
|
||||||
|
//page_view will be collected automatically by _middleware.ts
|
||||||
telemetry.withJitsu((jitsu) =>
|
telemetry.event(
|
||||||
jitsu.track(
|
telemetryEventTypes.embedView,
|
||||||
telemetryEventTypes.pageView,
|
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
|
||||||
collectPageParameters("availability", { isTeamBooking: document.URL.includes("team/") })
|
);
|
||||||
)
|
}
|
||||||
);
|
|
||||||
}, [telemetry]);
|
}, [telemetry]);
|
||||||
|
|
||||||
const changeDate = (newDate: Dayjs) => {
|
// Recurring event sidebar requires more space
|
||||||
router.replace(
|
const maxWidth = isAvailableTimesVisible
|
||||||
{
|
? recurringEventCount
|
||||||
query: {
|
? "max-w-6xl"
|
||||||
...router.query,
|
: "max-w-5xl"
|
||||||
date: newDate.format("YYYY-MM-DDZZ"),
|
: recurringEventCount
|
||||||
},
|
? "max-w-4xl"
|
||||||
},
|
: "max-w-3xl";
|
||||||
undefined,
|
const timezoneDropdown = useMemo(
|
||||||
{
|
() => (
|
||||||
shallow: true,
|
<TimezoneDropdown
|
||||||
}
|
onChangeTimeFormat={setTimeFormat}
|
||||||
);
|
timeZone={timeZone}
|
||||||
};
|
onChangeTimeZone={setTimeZone}
|
||||||
|
/>
|
||||||
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
),
|
||||||
if (selectedDate) {
|
[timeZone]
|
||||||
changeDate(selectedDate.tz(selectedTimeZone, true));
|
);
|
||||||
}
|
|
||||||
timeZone(selectedTimeZone);
|
|
||||||
setIsTimeOptionsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
|
||||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -141,59 +377,98 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||||
name={profile.name || undefined}
|
name={profile.name || undefined}
|
||||||
username={profile.slug || undefined}
|
username={profile.slug || undefined}
|
||||||
// avatar={profile.image || undefined}
|
|
||||||
/>
|
/>
|
||||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||||
<div>
|
<div>
|
||||||
<main
|
<main
|
||||||
className={
|
className={classNames(
|
||||||
|
shouldAlignCentrally ? "mx-auto" : "",
|
||||||
isEmbed
|
isEmbed
|
||||||
? classNames("m-auto", selectedDate ? "max-w-5xl" : "max-w-3xl")
|
? classNames(maxWidth)
|
||||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
)}>
|
||||||
}>
|
<div
|
||||||
{isReady && (
|
style={availabilityDatePickerEmbedStyles}
|
||||||
<div
|
className={classNames(
|
||||||
style={availabilityDatePickerEmbedStyles}
|
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||||
className={classNames(
|
"border-bookinglightest rounded-md md:border",
|
||||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
isEmbed ? "mx-auto" : maxWidth
|
||||||
"border-bookinglightest rounded-sm md:border",
|
)}>
|
||||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
{/* mobile: details */}
|
||||||
)}>
|
<div className="block px-4 pt-4 sm:p-8 md:hidden">
|
||||||
{/* mobile: details */}
|
<div>
|
||||||
<div className="block p-4 sm:p-8 md:hidden">
|
<AvatarGroup
|
||||||
<div className="block items-center sm:flex sm:space-x-4">
|
border="border-2 dark:border-gray-800 border-white"
|
||||||
<AvatarGroup
|
items={
|
||||||
border="border-2 dark:border-gray-800 border-white"
|
[
|
||||||
items={
|
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||||
[
|
...eventType.users
|
||||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
.filter((user) => user.name !== profile.name)
|
||||||
...eventType.users
|
.map((user) => ({
|
||||||
.filter((user) => user.name !== profile.name)
|
title: user.name,
|
||||||
.map((user) => ({
|
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||||
title: user.name,
|
alt: user.name || undefined,
|
||||||
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
|
})),
|
||||||
alt: user.name || undefined,
|
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||||
})),
|
}
|
||||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
size={9}
|
||||||
}
|
truncateAfter={5}
|
||||||
size={9}
|
/>
|
||||||
truncateAfter={5}
|
<div className="mt-4">
|
||||||
/>
|
<p className="break-words text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||||
<div className="mt-4 sm:-mt-2">
|
<div className="mt-2 gap-2 dark:text-gray-100">
|
||||||
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
<h1 className="text-bookingdark mb-4 break-words text-xl font-semibold dark:text-white">
|
||||||
<div className="mt-2 flex gap-2 text-xl font-medium dark:text-gray-100">
|
|
||||||
{eventType.title}
|
{eventType.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
{eventType?.description && (
|
{eventType?.description && (
|
||||||
<p className="mb-2 text-gray-600 dark:text-white">
|
<p className="text-gray-600 dark:text-white">
|
||||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||||
{eventType.description}
|
{eventType.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div>
|
{eventType?.requiresConfirmation && (
|
||||||
<ClockIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
<ClipboardCheckIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||||
|
{t("requires_confirmation")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{eventType.locations.length === 1 && (
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
{Object.values(AppStoreLocationType).includes(
|
||||||
|
eventType.locations[0].type as unknown as AppStoreLocationType
|
||||||
|
) ? (
|
||||||
|
<VideoCameraIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<LocationMarkerIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationKeyToString(eventType.locations[0], t)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{eventType.locations.length > 1 && (
|
||||||
|
<div className="flex-warp flex text-gray-600 dark:text-white">
|
||||||
|
<div className="mr-[10px] ml-[2px] -mt-1 ">
|
||||||
|
<LocationMarkerIcon className="inline-block h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{eventType.locations.map((el, i, arr) => {
|
||||||
|
return (
|
||||||
|
<span key={el.type}>
|
||||||
|
{locationKeyToString(el, t)}{" "}
|
||||||
|
{arr.length - 1 !== i && (
|
||||||
|
<span className="font-light"> {t("or_lowercase")} </span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
{eventType.length} {t("minutes")}
|
{eventType.length} {t("minutes")}
|
||||||
</div>
|
</p>
|
||||||
{eventType.price > 0 && (
|
{eventType.price > 0 && (
|
||||||
<div className="text-gray-600 dark:text-white">
|
<div className="text-gray-600 dark:text-white">
|
||||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
|
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
|
||||||
@@ -206,50 +481,164 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!rescheduleUid && eventType.recurringEvent && (
|
||||||
|
<div className="text-gray-600 dark:text-white">
|
||||||
|
<RefreshIcon className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
|
<div className="ml-[27px]">
|
||||||
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
|
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={eventType.recurringEvent.count}
|
||||||
|
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||||
|
defaultValue={eventType.recurringEvent.count}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRecurringEventCount(parseInt(event?.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="inline text-gray-600 dark:text-white">
|
||||||
|
{t("occurrence", {
|
||||||
|
count: recurringEventCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{timezoneDropdown}
|
||||||
|
|
||||||
|
<div className="md:hidden">
|
||||||
|
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="mt-8 text-gray-600 dark:text-white"
|
||||||
|
data-testid="former_time_p_mobile">
|
||||||
|
{t("former_time")}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 line-through dark:text-white">
|
||||||
|
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
{typeof booking.startTime === "string" &&
|
||||||
|
parseDate(dayjs(booking.startTime), i18n)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)*/}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-4 sm:flex sm:p-4 sm:py-5">
|
<div className="p-4 sm:flex sm:py-5">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
"hidden overflow-hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
||||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||||
}>
|
}>
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
border="border-2 dark:border-gray-800 border-white"
|
border="border-2 dark:border-gray-800 border-white"
|
||||||
items={
|
items={
|
||||||
[
|
[
|
||||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||||
...eventType.users
|
...eventType.users
|
||||||
.filter((user) => user.name !== profile.name)
|
.filter((user) => user.name !== profile.name)
|
||||||
.map((user) => ({
|
.map((user) => ({
|
||||||
title: user.name,
|
title: user.name,
|
||||||
alt: user.name,
|
alt: user.name,
|
||||||
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
|
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||||
})),
|
})),
|
||||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||||
}
|
}
|
||||||
size={10}
|
size={10}
|
||||||
truncateAfter={3}
|
truncateAfter={3}
|
||||||
/>
|
/>
|
||||||
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
|
<h2 className="mt-3 break-words font-medium text-gray-500 dark:text-gray-300">
|
||||||
<h1 className="font-cal mb-4 text-xl font-semibold text-gray-900 dark:text-white">
|
{profile.name}
|
||||||
{eventType.title}
|
</h2>
|
||||||
</h1>
|
<h1 className="font-cal mb-4 break-words text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{eventType.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
{eventType?.description && (
|
{eventType?.description && (
|
||||||
<p className="mb-2 text-gray-600 dark:text-white">
|
<div className="flex text-gray-600 dark:text-white">
|
||||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
<div>
|
||||||
{eventType.description}
|
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p>{eventType.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eventType?.requiresConfirmation && (
|
||||||
|
<div className="flex text-gray-600 dark:text-white">
|
||||||
|
<div>
|
||||||
|
<ClipboardCheckIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
{t("requires_confirmation")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eventType.locations.length === 1 && (
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
{Object.values(AppStoreLocationType).includes(
|
||||||
|
eventType.locations[0].type as unknown as AppStoreLocationType
|
||||||
|
) ? (
|
||||||
|
<VideoCameraIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<LocationMarkerIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationKeyToString(eventType.locations[0], t)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
{eventType.locations.length > 1 && (
|
||||||
|
<div className="flex-warp flex text-gray-600 dark:text-white">
|
||||||
|
<div className="mr-[10px] ml-[2px] -mt-1 ">
|
||||||
|
<LocationMarkerIcon className="inline-block h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{eventType.locations.map((el, i, arr) => {
|
||||||
|
return (
|
||||||
|
<span key={el.type}>
|
||||||
|
{locationKeyToString(el, t)}{" "}
|
||||||
|
{arr.length - 1 !== i && (
|
||||||
|
<span className="font-light"> {t("or_lowercase")} </span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
{eventType.length} {t("minutes")}
|
{eventType.length} {t("minutes")}
|
||||||
</p>
|
</p>
|
||||||
|
{!rescheduleUid && eventType.recurringEvent && (
|
||||||
|
<div className="text-gray-600 dark:text-white">
|
||||||
|
<RefreshIcon className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
|
<div className="ml-[27px]">
|
||||||
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
|
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={eventType.recurringEvent.count}
|
||||||
|
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||||
|
defaultValue={eventType.recurringEvent.count}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRecurringEventCount(parseInt(event?.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="inline text-gray-600 dark:text-white">
|
||||||
|
{t("occurrence", {
|
||||||
|
count: recurringEventCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{eventType.price > 0 && (
|
{eventType.price > 0 && (
|
||||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
<p className="-ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
@@ -260,20 +649,16 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{timezoneDropdown}
|
||||||
|
</div>
|
||||||
|
|
||||||
<TimezoneDropdown />
|
<GoBackToPreviousPage t={t} />
|
||||||
{previousPage === `${BASE_URL}/${profile.slug}` && (
|
|
||||||
<div className="flex h-full flex-col justify-end">
|
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||||
<ArrowLeftIcon
|
|
||||||
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
/>
|
|
||||||
<p className="sr-only">Go Back</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{booking?.startTime && rescheduleUid && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mt-8 mb-2 text-gray-600 dark:text-white" data-testid="former_time_p">
|
<p
|
||||||
|
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||||
|
data-testid="former_time_p_desktop">
|
||||||
{t("former_time")}
|
{t("former_time")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 line-through dark:text-white">
|
<p className="text-gray-500 line-through dark:text-white">
|
||||||
@@ -281,68 +666,37 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)*/}
|
||||||
</div>
|
|
||||||
<DatePicker
|
|
||||||
date={selectedDate}
|
|
||||||
periodType={eventType?.periodType}
|
|
||||||
periodStartDate={eventType?.periodStartDate}
|
|
||||||
periodEndDate={eventType?.periodEndDate}
|
|
||||||
periodDays={eventType?.periodDays}
|
|
||||||
periodCountCalendarDays={eventType?.periodCountCalendarDays}
|
|
||||||
onDatePicked={changeDate}
|
|
||||||
workingHours={workingHours}
|
|
||||||
weekStart={profile.weekStart || "Sunday"}
|
|
||||||
eventLength={eventType.length}
|
|
||||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4 ml-1 block sm:hidden">
|
|
||||||
<TimezoneDropdown />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedDate && (
|
|
||||||
<AvailableTimes
|
|
||||||
timeFormat={timeFormat}
|
|
||||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
|
||||||
eventTypeId={eventType.id}
|
|
||||||
eventTypeSlug={eventType.slug}
|
|
||||||
slotInterval={eventType.slotInterval}
|
|
||||||
eventLength={eventType.length}
|
|
||||||
date={selectedDate}
|
|
||||||
users={eventType.users}
|
|
||||||
schedulingType={eventType.schedulingType ?? null}
|
|
||||||
beforeBufferTime={eventType.beforeEventBuffer}
|
|
||||||
afterBufferTime={eventType.afterEventBuffer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{timeZone && (
|
||||||
|
<SlotPicker
|
||||||
|
weekStart={
|
||||||
|
typeof profile.weekStart === "string"
|
||||||
|
? ([
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||||
|
: profile.weekStart /* Allows providing weekStart as number */
|
||||||
|
}
|
||||||
|
eventType={eventType}
|
||||||
|
timeFormat={timeFormat}
|
||||||
|
timeZone={timeZone}
|
||||||
|
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||||
|
recurringEventCount={recurringEventCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
function TimezoneDropdown() {
|
|
||||||
return (
|
|
||||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
|
||||||
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-600 dark:text-white">
|
|
||||||
<GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
|
||||||
{timeZone()}
|
|
||||||
{isTimeOptionsOpen ? (
|
|
||||||
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDownIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<Collapsible.Content>
|
|
||||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AvailabilityPage;
|
export default AvailabilityPage;
|
||||||
@@ -2,12 +2,16 @@ import {
|
|||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
|
ExclamationCircleIcon,
|
||||||
ExclamationIcon,
|
ExclamationIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
|
ClipboardCheckIcon,
|
||||||
|
RefreshIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { EventTypeCustomInputType } from "@prisma/client";
|
import { EventTypeCustomInputType } from "@prisma/client";
|
||||||
import { useContracts } from "contexts/contractsContext";
|
import { useContracts } from "contexts/contractsContext";
|
||||||
import dayjs from "dayjs";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
@@ -17,22 +21,32 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
|||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { ReactMultiEmail } from "react-multi-email";
|
import { ReactMultiEmail } from "react-multi-email";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { useIsEmbed, useIsBackgroundTransparent } from "@calcom/embed-core";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import {
|
||||||
|
useEmbedNonStylesConfig,
|
||||||
|
useIsBackgroundTransparent,
|
||||||
|
useIsEmbed,
|
||||||
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
import { createPaymentLink } from "@calcom/stripe/client";
|
import { createPaymentLink } from "@calcom/stripe/client";
|
||||||
import { Button } from "@calcom/ui/Button";
|
import { Button } from "@calcom/ui/Button";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { ensureArray } from "@lib/ensureArray";
|
import { ensureArray } from "@lib/ensureArray";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { LocationType } from "@lib/location";
|
import { LocationObject, LocationType } from "@lib/location";
|
||||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||||
import { parseDate } from "@lib/parseDate";
|
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||||
|
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||||
import slugify from "@lib/slugify";
|
import slugify from "@lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
|
|
||||||
@@ -41,14 +55,26 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
|||||||
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||||
|
|
||||||
import { BookPageProps } from "../../../pages/[user]/book";
|
import { BookPageProps } from "../../../pages/[user]/book";
|
||||||
|
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var web3: {
|
||||||
|
currentProvider: {
|
||||||
|
selectedAddress: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** These are like 40kb that not every user needs */
|
/** These are like 40kb that not every user needs */
|
||||||
const PhoneInput = dynamic(
|
const PhoneInput = dynamic(
|
||||||
() => import("@components/ui/form/PhoneInput")
|
() => import("@components/ui/form/PhoneInput")
|
||||||
) as unknown as typeof PhoneInputType;
|
) as unknown as typeof PhoneInputType;
|
||||||
|
|
||||||
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
type BookingPageProps = (BookPageProps | TeamBookingPageProps | HashLinkPageProps) & {
|
||||||
|
locationLabels: Record<LocationType, string>;
|
||||||
|
};
|
||||||
|
|
||||||
type BookingFormValues = {
|
type BookingFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,9 +83,11 @@ type BookingFormValues = {
|
|||||||
locationType?: LocationType;
|
locationType?: LocationType;
|
||||||
guests?: string[];
|
guests?: string[];
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers
|
||||||
customInputs?: {
|
customInputs?: {
|
||||||
[key: string]: string;
|
[key: string]: string | boolean;
|
||||||
};
|
};
|
||||||
|
rescheduleReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BookingPage = ({
|
const BookingPage = ({
|
||||||
@@ -67,34 +95,51 @@ const BookingPage = ({
|
|||||||
booking,
|
booking,
|
||||||
profile,
|
profile,
|
||||||
isDynamicGroupBooking,
|
isDynamicGroupBooking,
|
||||||
|
recurringEventCount,
|
||||||
locationLabels,
|
locationLabels,
|
||||||
|
hasHashedBookingLink,
|
||||||
|
hashedLink,
|
||||||
}: BookingPageProps) => {
|
}: BookingPageProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed();
|
||||||
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { contracts } = useContracts();
|
const { contracts } = useContracts();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (top !== window) {
|
||||||
|
//page_view will be collected automatically by _middleware.ts
|
||||||
|
telemetry.event(
|
||||||
|
telemetryEventTypes.embedView,
|
||||||
|
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (eventType.metadata.smartContractAddress) {
|
if (eventType.metadata.smartContractAddress) {
|
||||||
const eventOwner = eventType.users[0];
|
const eventOwner = eventType.users[0];
|
||||||
|
|
||||||
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
||||||
/* @ts-ignore */
|
|
||||||
router.replace(`/${eventOwner.username}`);
|
router.replace(`/${eventOwner.username}`);
|
||||||
}
|
}
|
||||||
}, [contracts, eventType.metadata.smartContractAddress, router]);
|
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||||
|
|
||||||
const mutation = useMutation(createBooking, {
|
const mutation = useMutation(createBooking, {
|
||||||
onSuccess: async (responseData) => {
|
onSuccess: async (responseData) => {
|
||||||
const { attendees, paymentUid } = responseData;
|
const { id, attendees, paymentUid } = responseData;
|
||||||
if (paymentUid) {
|
if (paymentUid) {
|
||||||
return await router.push(
|
return await router.push(
|
||||||
createPaymentLink({
|
createPaymentLink({
|
||||||
paymentUid,
|
paymentUid,
|
||||||
date,
|
date,
|
||||||
name: attendees[0].name,
|
name: attendees[0].name,
|
||||||
|
email: attendees[0].email,
|
||||||
absolute: false,
|
absolute: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -122,6 +167,40 @@ const BookingPage = ({
|
|||||||
email: attendees[0].email,
|
email: attendees[0].email,
|
||||||
location,
|
location,
|
||||||
eventName: profile.eventName || "",
|
eventName: profile.eventName || "",
|
||||||
|
bookingId: id,
|
||||||
|
isSuccessBookingPage: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recurringMutation = useMutation(createRecurringBooking, {
|
||||||
|
onSuccess: async (responseData = []) => {
|
||||||
|
const { attendees = [], id, recurringEventId } = responseData[0] || {};
|
||||||
|
const location = (function humanReadableLocation(location) {
|
||||||
|
if (!location) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location.includes("integration")) {
|
||||||
|
return t("web_conferencing_details_to_follow");
|
||||||
|
}
|
||||||
|
return location;
|
||||||
|
})(responseData[0].location);
|
||||||
|
|
||||||
|
return router.push({
|
||||||
|
pathname: "/success",
|
||||||
|
query: {
|
||||||
|
date,
|
||||||
|
type: eventType.id,
|
||||||
|
eventSlug: eventType.slug,
|
||||||
|
recur: recurringEventId,
|
||||||
|
user: profile.slug,
|
||||||
|
reschedule: !!rescheduleUid,
|
||||||
|
name: attendees[0].name,
|
||||||
|
email: attendees[0].email,
|
||||||
|
location,
|
||||||
|
eventName: profile.eventName || "",
|
||||||
|
bookingId: id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -135,10 +214,9 @@ const BookingPage = ({
|
|||||||
|
|
||||||
const eventTypeDetail = { isWeb3Active: false, ...eventType };
|
const eventTypeDetail = { isWeb3Active: false, ...eventType };
|
||||||
|
|
||||||
type Location = { type: LocationType; address?: string; link?: string };
|
|
||||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||||
const locations: Location[] = useMemo(
|
const locations: LocationObject[] = useMemo(
|
||||||
() => (eventType.locations as Location[]) || [],
|
() => (eventType.locations as LocationObject[]) || [],
|
||||||
[eventType.locations]
|
[eventType.locations]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -148,10 +226,8 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
}, [router.query.guest]);
|
}, [router.query.guest]);
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
|
||||||
|
|
||||||
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
||||||
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
|
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
||||||
const guestListEmails = !isDynamicGroupBooking
|
const guestListEmails = !isDynamicGroupBooking
|
||||||
? booking?.attendees.slice(1).map((attendee) => attendee.email)
|
? booking?.attendees.slice(1).map((attendee) => attendee.email)
|
||||||
: [];
|
: [];
|
||||||
@@ -159,8 +235,8 @@ const BookingPage = ({
|
|||||||
const defaultValues = () => {
|
const defaultValues = () => {
|
||||||
if (!rescheduleUid) {
|
if (!rescheduleUid) {
|
||||||
return {
|
return {
|
||||||
name: loggedInIsOwner ? "" : session?.user?.name || (router.query.name as string) || "",
|
name: (router.query.name as string) || (!loggedInIsOwner && session?.user?.name) || "",
|
||||||
email: loggedInIsOwner ? "" : session?.user?.email || (router.query.email as string) || "",
|
email: (router.query.email as string) || (!loggedInIsOwner && session?.user?.email) || "",
|
||||||
notes: (router.query.notes as string) || "",
|
notes: (router.query.notes as string) || "",
|
||||||
guests: ensureArray(router.query.guest) as string[],
|
guests: ensureArray(router.query.guest) as string[],
|
||||||
customInputs: eventType.customInputs.reduce(
|
customInputs: eventType.customInputs.reduce(
|
||||||
@@ -179,16 +255,40 @@ const BookingPage = ({
|
|||||||
if (!primaryAttendee) {
|
if (!primaryAttendee) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customInputType = booking.customInputs;
|
||||||
return {
|
return {
|
||||||
name: primaryAttendee.name || "",
|
name: primaryAttendee.name || "",
|
||||||
email: primaryAttendee.email || "",
|
email: primaryAttendee.email || "",
|
||||||
guests: guestListEmails,
|
guests: guestListEmails,
|
||||||
notes: booking.description || "",
|
notes: booking.description || "",
|
||||||
|
rescheduleReason: "",
|
||||||
|
customInputs: eventType.customInputs.reduce(
|
||||||
|
(customInputs, input) => ({
|
||||||
|
...customInputs,
|
||||||
|
[input.id]: booking.customInputs
|
||||||
|
? booking.customInputs[input.label as keyof typeof customInputType]
|
||||||
|
: "",
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bookingFormSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email(),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
const bookingForm = useForm<BookingFormValues>({
|
const bookingForm = useForm<BookingFormValues>({
|
||||||
defaultValues: defaultValues(),
|
defaultValues: defaultValues(),
|
||||||
|
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedLocation = useWatch({
|
const selectedLocation = useWatch({
|
||||||
@@ -204,7 +304,9 @@ const BookingPage = ({
|
|||||||
})(),
|
})(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getLocationValue = (booking: Pick<BookingFormValues, "locationType" | "phone">) => {
|
const getLocationValue = (
|
||||||
|
booking: Pick<BookingFormValues, "locationType" | "phone" | "hostPhoneNumber">
|
||||||
|
) => {
|
||||||
const { locationType } = booking;
|
const { locationType } = booking;
|
||||||
switch (locationType) {
|
switch (locationType) {
|
||||||
case LocationType.Phone: {
|
case LocationType.Phone: {
|
||||||
@@ -216,20 +318,35 @@ const BookingPage = ({
|
|||||||
case LocationType.Link: {
|
case LocationType.Link: {
|
||||||
return locationInfo(locationType)?.link || "";
|
return locationInfo(locationType)?.link || "";
|
||||||
}
|
}
|
||||||
|
case LocationType.UserPhone: {
|
||||||
|
return locationInfo(locationType)?.hostPhoneNumber || "";
|
||||||
|
}
|
||||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||||
default:
|
default:
|
||||||
return selectedLocation || "";
|
return selectedLocation || "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookEvent = (booking: BookingFormValues) => {
|
// Calculate the booking date(s)
|
||||||
telemetry.withJitsu((jitsu) =>
|
let recurringStrings: string[] = [],
|
||||||
jitsu.track(
|
recurringDates: Date[] = [];
|
||||||
telemetryEventTypes.bookingConfirmed,
|
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
|
||||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||||
)
|
{
|
||||||
|
startDate: date,
|
||||||
|
timeZone: timeZone(),
|
||||||
|
recurringEvent: eventType.recurringEvent,
|
||||||
|
recurringCount: parseInt(recurringEventCount.toString()),
|
||||||
|
},
|
||||||
|
i18n
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookEvent = (booking: BookingFormValues) => {
|
||||||
|
telemetry.event(
|
||||||
|
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||||
|
{ isTeamBooking: document.URL.includes("team/") }
|
||||||
|
);
|
||||||
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
||||||
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
|
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
|
||||||
|
|
||||||
@@ -244,38 +361,74 @@ const BookingPage = ({
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
let web3Details;
|
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
|
||||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||||
web3Details = {
|
web3Details = {
|
||||||
// @ts-ignore
|
|
||||||
userWallet: window.web3.currentProvider.selectedAddress,
|
userWallet: window.web3.currentProvider.selectedAddress,
|
||||||
userSignature: contracts[(eventTypeDetail.metadata.smartContractAddress || null) as number],
|
userSignature: contracts[(eventTypeDetail.metadata.smartContractAddress || null) as number],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation.mutate({
|
if (recurringDates.length) {
|
||||||
...booking,
|
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||||
web3Details,
|
const recurringEventId = uuidv4();
|
||||||
start: dayjs(date).format(),
|
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
...booking,
|
||||||
eventTypeId: eventType.id,
|
web3Details,
|
||||||
eventTypeSlug: eventType.slug,
|
start: dayjs(recurringDate).format(),
|
||||||
timeZone: timeZone(),
|
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
||||||
language: i18n.language,
|
eventTypeId: eventType.id,
|
||||||
rescheduleUid,
|
eventTypeSlug: eventType.slug,
|
||||||
user: router.query.user,
|
recurringEventId,
|
||||||
location: getLocationValue(
|
// Added to track down the number of actual occurrences selected by the user
|
||||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
recurringCount: recurringDates.length,
|
||||||
),
|
timeZone: timeZone(),
|
||||||
metadata,
|
language: i18n.language,
|
||||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
rescheduleUid,
|
||||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
user: router.query.user,
|
||||||
value: booking.customInputs![inputId],
|
location: getLocationValue(
|
||||||
})),
|
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||||
});
|
),
|
||||||
|
metadata,
|
||||||
|
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||||
|
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||||
|
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
||||||
|
})),
|
||||||
|
hasHashedBookingLink,
|
||||||
|
hashedLink,
|
||||||
|
}));
|
||||||
|
recurringMutation.mutate(recurringBookings);
|
||||||
|
} else {
|
||||||
|
mutation.mutate({
|
||||||
|
...booking,
|
||||||
|
web3Details,
|
||||||
|
start: dayjs(date).format(),
|
||||||
|
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
eventTypeSlug: eventType.slug,
|
||||||
|
timeZone: timeZone(),
|
||||||
|
language: i18n.language,
|
||||||
|
rescheduleUid,
|
||||||
|
bookingUid: router.query.bookingUid as string,
|
||||||
|
user: router.query.user,
|
||||||
|
location: getLocationValue(
|
||||||
|
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||||
|
),
|
||||||
|
metadata,
|
||||||
|
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||||
|
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||||
|
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
||||||
|
})),
|
||||||
|
hasHashedBookingLink,
|
||||||
|
hashedLink,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableInput = !!rescheduleUid;
|
const disableInput = !!rescheduleUid;
|
||||||
|
const disabledExceptForOwner = disableInput && !loggedInIsOwner;
|
||||||
|
const inputClassName =
|
||||||
|
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -298,16 +451,17 @@ const BookingPage = ({
|
|||||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||||
<main
|
<main
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isEmbed ? "mx-auto" : "mx-auto my-0 rounded-sm sm:my-24",
|
shouldAlignCentrally ? "mx-auto" : "",
|
||||||
"max-w-3xl sm:border sm:dark:border-gray-600"
|
isEmbed ? "" : "sm:my-24",
|
||||||
|
"my-0 max-w-3xl "
|
||||||
)}>
|
)}>
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"overflow-hidden",
|
"main overflow-hidden",
|
||||||
isEmbed ? "" : "border border-gray-200",
|
isEmbed ? "" : "border border-gray-200",
|
||||||
isBackgroundTransparent ? "" : "bg-white dark:border-0 dark:bg-gray-800",
|
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
|
||||||
"sm:rounded-sm"
|
"rounded-md dark:border-gray-600 sm:border"
|
||||||
)}>
|
)}>
|
||||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
|
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
|
||||||
@@ -329,12 +483,33 @@ const BookingPage = ({
|
|||||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||||
{eventType.title}
|
{eventType.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
{!!eventType.seatsPerTimeSlot && (
|
||||||
|
<p
|
||||||
|
className={`${
|
||||||
|
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||||
|
? "text-rose-600"
|
||||||
|
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-emerald-400"
|
||||||
|
} mb-2`}>
|
||||||
|
{booking
|
||||||
|
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||||
|
: eventType.seatsPerTimeSlot}{" "}
|
||||||
|
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{eventType?.description && (
|
{eventType?.description && (
|
||||||
<p className="text-bookinglight mb-2 dark:text-white">
|
<p className="text-bookinglight mb-2 dark:text-white">
|
||||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
{eventType.description}
|
{eventType.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{eventType?.requiresConfirmation && (
|
||||||
|
<p className="text-bookinglight mb-2 dark:text-white">
|
||||||
|
<ClipboardCheckIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
{t("requires_confirmation")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-bookinglight mb-2 dark:text-white">
|
<p className="text-bookinglight mb-2 dark:text-white">
|
||||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
{eventType.length} {t("minutes")}
|
{eventType.length} {t("minutes")}
|
||||||
@@ -351,10 +526,40 @@ const BookingPage = ({
|
|||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-bookinghighlight mb-4">
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||||
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<div className="mb-3 text-gray-600 dark:text-white">
|
||||||
{parseDate(date, i18n)}
|
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
</p>
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
|
{getEveryFreqFor({
|
||||||
|
t,
|
||||||
|
recurringEvent: eventType.recurringEvent,
|
||||||
|
recurringCount: recurringEventCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-bookinghighlight mb-4 flex">
|
||||||
|
<CalendarIcon className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||||
|
<div className="-mt-1">
|
||||||
|
{(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||||
|
parseDate(dayjs(date).tz(timeZone()), i18n)}
|
||||||
|
{!rescheduleUid &&
|
||||||
|
eventType.recurringEvent?.freq &&
|
||||||
|
recurringStrings.slice(0, 5).map((aDate, key) => <p key={key}>{aDate}</p>)}
|
||||||
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||||
|
<div className="flex">
|
||||||
|
<Tooltip
|
||||||
|
content={recurringStrings.slice(5).map((aDate, key) => (
|
||||||
|
<p key={key}>{aDate}</p>
|
||||||
|
))}>
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
{t("plus_more", { count: recurringStrings.length - 5 })}
|
||||||
|
</p>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
|
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
|
||||||
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
||||||
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
||||||
@@ -380,15 +585,12 @@ const BookingPage = ({
|
|||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<input
|
<input
|
||||||
{...bookingForm.register("name")}
|
{...bookingForm.register("name", { required: true })}
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
id="name"
|
id="name"
|
||||||
required
|
required
|
||||||
className={classNames(
|
className={inputClassName}
|
||||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
|
||||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
|
||||||
)}
|
|
||||||
placeholder={t("example_name")}
|
placeholder={t("example_name")}
|
||||||
disabled={disableInput}
|
disabled={disableInput}
|
||||||
/>
|
/>
|
||||||
@@ -405,13 +607,21 @@ const BookingPage = ({
|
|||||||
{...bookingForm.register("email")}
|
{...bookingForm.register("email")}
|
||||||
required
|
required
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
inputClassName,
|
||||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
bookingForm.formState.errors.email
|
||||||
|
? "border-red-700 focus:ring-red-700"
|
||||||
|
: " border-gray-300 dark:border-gray-900"
|
||||||
)}
|
)}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
|
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
|
||||||
disabled={disableInput}
|
disabled={disableInput}
|
||||||
/>
|
/>
|
||||||
|
{bookingForm.formState.errors.email && (
|
||||||
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
|
<ExclamationCircleIcon className="mr-2 h-3 w-3" />
|
||||||
|
<p>{t("email_validation_error")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{locations.length > 1 && (
|
{locations.length > 1 && (
|
||||||
@@ -427,7 +637,6 @@ const BookingPage = ({
|
|||||||
{...bookingForm.register("locationType", { required: true })}
|
{...bookingForm.register("locationType", { required: true })}
|
||||||
value={location.type}
|
value={location.type}
|
||||||
defaultChecked={selectedLocation === location.type}
|
defaultChecked={selectedLocation === location.type}
|
||||||
disabled={disableInput}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
|
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
|
||||||
{locationLabels[location.type]}
|
{locationLabels[location.type]}
|
||||||
@@ -453,6 +662,12 @@ const BookingPage = ({
|
|||||||
disabled={disableInput}
|
disabled={disableInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{bookingForm.formState.errors.phone && (
|
||||||
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
|
<ExclamationCircleIcon className="mr-2 h-3 w-3" />
|
||||||
|
<p>{t("invalid_number")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{eventType.customInputs
|
{eventType.customInputs
|
||||||
@@ -473,12 +688,9 @@ const BookingPage = ({
|
|||||||
})}
|
})}
|
||||||
id={"custom_" + input.id}
|
id={"custom_" + input.id}
|
||||||
rows={3}
|
rows={3}
|
||||||
className={classNames(
|
className={inputClassName}
|
||||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
|
||||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
|
||||||
)}
|
|
||||||
placeholder={input.placeholder}
|
placeholder={input.placeholder}
|
||||||
disabled={disableInput}
|
disabled={disabledExceptForOwner}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{input.type === EventTypeCustomInputType.TEXT && (
|
{input.type === EventTypeCustomInputType.TEXT && (
|
||||||
@@ -488,9 +700,9 @@ const BookingPage = ({
|
|||||||
required: input.required,
|
required: input.required,
|
||||||
})}
|
})}
|
||||||
id={"custom_" + input.id}
|
id={"custom_" + input.id}
|
||||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
className={inputClassName}
|
||||||
placeholder={input.placeholder}
|
placeholder={input.placeholder}
|
||||||
disabled={disableInput}
|
disabled={disabledExceptForOwner}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{input.type === EventTypeCustomInputType.NUMBER && (
|
{input.type === EventTypeCustomInputType.NUMBER && (
|
||||||
@@ -500,8 +712,9 @@ const BookingPage = ({
|
|||||||
required: input.required,
|
required: input.required,
|
||||||
})}
|
})}
|
||||||
id={"custom_" + input.id}
|
id={"custom_" + input.id}
|
||||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
className={inputClassName}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
|
disabled={disabledExceptForOwner}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{input.type === EventTypeCustomInputType.BOOL && (
|
{input.type === EventTypeCustomInputType.BOOL && (
|
||||||
@@ -512,8 +725,9 @@ const BookingPage = ({
|
|||||||
required: input.required,
|
required: input.required,
|
||||||
})}
|
})}
|
||||||
id={"custom_" + input.id}
|
id={"custom_" + input.id}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
|
disabled={disabledExceptForOwner}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={"custom_" + input.id}
|
htmlFor={"custom_" + input.id}
|
||||||
@@ -593,26 +807,36 @@ const BookingPage = ({
|
|||||||
<label
|
<label
|
||||||
htmlFor="notes"
|
htmlFor="notes"
|
||||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
{t("additional_notes")}
|
{rescheduleUid ? t("reschedule_optional") : t("additional_notes")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
{rescheduleUid ? (
|
||||||
{...bookingForm.register("notes")}
|
<textarea
|
||||||
id="notes"
|
{...bookingForm.register("rescheduleReason")}
|
||||||
name="notes"
|
id="rescheduleReason"
|
||||||
rows={3}
|
name="rescheduleReason"
|
||||||
className={classNames(
|
rows={3}
|
||||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
className={inputClassName}
|
||||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
placeholder={t("reschedule_placeholder")}
|
||||||
)}
|
disabled={disabledExceptForOwner}
|
||||||
placeholder={t("share_additional_notes")}
|
/>
|
||||||
disabled={disableInput}
|
) : (
|
||||||
/>
|
<textarea
|
||||||
|
{...bookingForm.register("notes")}
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
rows={3}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder={t("share_additional_notes")}
|
||||||
|
disabled={disabledExceptForOwner}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-2 rtl:space-x-reverse">
|
<div className="flex items-start space-x-2 rtl:space-x-reverse">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||||
loading={mutation.isLoading}>
|
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="secondary" type="button" onClick={() => router.back()}>
|
<Button color="secondary" type="button" onClick={() => router.back()}>
|
||||||
@@ -620,22 +844,8 @@ const BookingPage = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
{mutation.isError && (
|
{(mutation.isError || recurringMutation.isError) && (
|
||||||
<div
|
<ErrorMessage error={mutation.error || recurringMutation.error} />
|
||||||
data-testid="booking-fail"
|
|
||||||
className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className="ltr:ml-3 rtl:mr-3">
|
|
||||||
<p className="text-sm text-yellow-700">
|
|
||||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
|
||||||
{(mutation.error as HttpError)?.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -647,3 +857,24 @@ const BookingPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default BookingPage;
|
export default BookingPage;
|
||||||
|
|
||||||
|
function ErrorMessage({ error }: { error: unknown }) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { query: { rescheduleUid } = {} } = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="booking-fail" className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="ltr:ml-3 rtl:mr-3">
|
||||||
|
<p className="text-sm text-yellow-700">
|
||||||
|
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||||
|
{error instanceof HttpError || error instanceof Error ? error.message : "Unknown error"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,16 @@ import { CheckIcon } from "@heroicons/react/solid";
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import React, { PropsWithChildren, ReactNode } from "react";
|
import React, { PropsWithChildren, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button } from "@calcom/ui/Button";
|
import { Button } from "@calcom/ui/Button";
|
||||||
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
export type ConfirmationDialogContentProps = {
|
export type ConfirmationDialogContentProps = {
|
||||||
confirmBtn?: ReactNode;
|
confirmBtn?: ReactNode;
|
||||||
confirmBtnText?: string;
|
confirmBtnText?: string;
|
||||||
cancelBtnText?: string;
|
cancelBtnText?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||||
title: string;
|
title: string;
|
||||||
variety?: "danger" | "warning" | "success";
|
variety?: "danger" | "warning" | "success";
|
||||||
@@ -25,6 +26,8 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
|||||||
confirmBtn = null,
|
confirmBtn = null,
|
||||||
confirmBtnText = t("confirm"),
|
confirmBtnText = t("confirm"),
|
||||||
cancelBtnText = t("cancel"),
|
cancelBtnText = t("cancel"),
|
||||||
|
loadingText = t("loading"),
|
||||||
|
isLoading = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
children,
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
@@ -59,10 +62,14 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
||||||
<DialogClose onClick={onConfirm} asChild>
|
<DialogClose disabled={isLoading} onClick={onConfirm} asChild>
|
||||||
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
|
{confirmBtn || (
|
||||||
|
<Button color="primary" loading={isLoading}>
|
||||||
|
{isLoading ? loadingText : confirmBtnText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<DialogClose asChild>
|
<DialogClose disabled={isLoading} asChild>
|
||||||
<Button color="secondary">{cancelBtnText}</Button>
|
<Button color="secondary">{cancelBtnText}</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ExclamationIcon } from "@heroicons/react/outline";
|
||||||
|
import { CheckIcon } from "@heroicons/react/solid";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import React, { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button } from "@calcom/ui/Button";
|
||||||
|
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||||
|
|
||||||
|
export type DeleteStripeDialogContentProps = {
|
||||||
|
cancelAllBookingsBtnText?: string;
|
||||||
|
removeBtnText?: string;
|
||||||
|
cancelBtnText?: string;
|
||||||
|
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||||
|
onRemove?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||||
|
title: string;
|
||||||
|
variety?: "danger" | "warning" | "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteStripeDialogContent(props: PropsWithChildren<DeleteStripeDialogContentProps>) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
variety,
|
||||||
|
cancelAllBookingsBtnText,
|
||||||
|
removeBtnText,
|
||||||
|
cancelBtnText = t("cancel"),
|
||||||
|
onConfirm,
|
||||||
|
onRemove,
|
||||||
|
children,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent>
|
||||||
|
<div className="flex">
|
||||||
|
{variety && (
|
||||||
|
<div className="mt-0.5 ltr:mr-3">
|
||||||
|
{variety === "danger" && (
|
||||||
|
<div className="mx-auto rounded-full bg-red-100 p-2 text-center">
|
||||||
|
<ExclamationIcon className="h-5 w-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variety === "warning" && (
|
||||||
|
<div className="mx-auto rounded-full bg-orange-100 p-2 text-center">
|
||||||
|
<ExclamationIcon className="h-5 w-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variety === "success" && (
|
||||||
|
<div className="mx-auto rounded-full bg-green-100 p-2 text-center">
|
||||||
|
<CheckIcon className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
|
||||||
|
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
||||||
|
<DialogClose onClick={onConfirm} asChild>
|
||||||
|
<Button color="alert">{cancelAllBookingsBtnText}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose onClick={onRemove} asChild>
|
||||||
|
<Button color="alert2">{removeBtnText}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button color="secondary">{cancelBtnText}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import { LocationMarkerIcon } from "@heroicons/react/solid";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button } from "@calcom/ui";
|
||||||
|
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||||
|
import { Form } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
import { linkValueToString } from "@lib/linkValueToString";
|
||||||
|
import { LocationType } from "@lib/location";
|
||||||
|
import { LocationOptionsToString } from "@lib/locationOptions";
|
||||||
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
|
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||||
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
|
const PhoneInput = dynamic(
|
||||||
|
() => import("@components/ui/form/PhoneInput")
|
||||||
|
) as unknown as typeof PhoneInputType;
|
||||||
|
|
||||||
|
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||||
|
|
||||||
|
type OptionTypeBase = {
|
||||||
|
label: string;
|
||||||
|
value: LocationType;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocationFormValues = {
|
||||||
|
locationType: LocationType;
|
||||||
|
locationAddress?: string;
|
||||||
|
locationLink?: string;
|
||||||
|
locationPhoneNumber?: string;
|
||||||
|
displayLocationPublicly?: boolean;
|
||||||
|
};
|
||||||
|
interface ISetLocationDialog {
|
||||||
|
saveLocation: (newLocationType: LocationType, details: { [key: string]: string }) => void;
|
||||||
|
selection?: OptionTypeBase;
|
||||||
|
booking?: BookingItem;
|
||||||
|
defaultValues?: {
|
||||||
|
type: LocationType;
|
||||||
|
address?: string | undefined;
|
||||||
|
link?: string | undefined;
|
||||||
|
hostPhoneNumber?: string | undefined;
|
||||||
|
displayLocationPublicly?: boolean | undefined;
|
||||||
|
}[];
|
||||||
|
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
isOpenDialog: boolean;
|
||||||
|
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||||
|
const {
|
||||||
|
saveLocation,
|
||||||
|
selection,
|
||||||
|
booking,
|
||||||
|
setShowLocationModal,
|
||||||
|
isOpenDialog,
|
||||||
|
defaultValues,
|
||||||
|
setSelectedLocation,
|
||||||
|
} = props;
|
||||||
|
const { t } = useLocale();
|
||||||
|
const locationsQuery = trpc.useQuery(["viewer.locationOptions"]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selection) {
|
||||||
|
locationFormMethods.setValue("locationType", selection?.value);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selection]);
|
||||||
|
|
||||||
|
const locationFormSchema = z.object({
|
||||||
|
locationType: z.string(),
|
||||||
|
locationAddress: z.string().optional(),
|
||||||
|
locationLink:
|
||||||
|
selection?.value === LocationType.Whereby
|
||||||
|
? z
|
||||||
|
.string()
|
||||||
|
.regex(/^http(s)?:\/\/(www\.)?whereby.com\/[a-zA-Z0-9]*/)
|
||||||
|
.optional()
|
||||||
|
: selection?.value === LocationType.Around
|
||||||
|
? z
|
||||||
|
.string()
|
||||||
|
.regex(/^http(s)?:\/\/(www\.)?around.co\/[a-zA-Z0-9]*/)
|
||||||
|
.optional()
|
||||||
|
: selection?.value === LocationType.Riverside
|
||||||
|
? z
|
||||||
|
.string()
|
||||||
|
.regex(/^http(s)?:\/\/(www\.)?riverside.fm\/studio\/[a-zA-Z0-9]*/)
|
||||||
|
.optional()
|
||||||
|
: z.string().url().optional(),
|
||||||
|
displayLocationPublicly: z.boolean().optional(),
|
||||||
|
locationPhoneNumber: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationFormMethods = useForm<LocationFormValues>({
|
||||||
|
mode: "onSubmit",
|
||||||
|
resolver: zodResolver(locationFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedLocation = useWatch({
|
||||||
|
control: locationFormMethods.control,
|
||||||
|
name: "locationType",
|
||||||
|
});
|
||||||
|
|
||||||
|
const LocationOptions =
|
||||||
|
selectedLocation === LocationType.InPerson ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("set_address_place")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...locationFormMethods.register("locationAddress")}
|
||||||
|
id="address"
|
||||||
|
required
|
||||||
|
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||||
|
defaultValue={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find(
|
||||||
|
(location: { type: LocationType }) => location.type === LocationType.InPerson
|
||||||
|
)?.address
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!booking && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Controller
|
||||||
|
name="displayLocationPublicly"
|
||||||
|
control={locationFormMethods.control}
|
||||||
|
render={() => (
|
||||||
|
<CheckboxField
|
||||||
|
defaultChecked={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find((location) => location.type === LocationType.InPerson)
|
||||||
|
?.displayLocationPublicly
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
description={t("display_location_label")}
|
||||||
|
onChange={(e) =>
|
||||||
|
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||||
|
}
|
||||||
|
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : selectedLocation === LocationType.Link ? (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="link" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("set_link_meeting")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...locationFormMethods.register("locationLink")}
|
||||||
|
required
|
||||||
|
id="link"
|
||||||
|
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||||
|
defaultValue={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find(
|
||||||
|
(location: { type: LocationType }) => location.type === LocationType.Link
|
||||||
|
)?.link
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{locationFormMethods.formState.errors.locationLink && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{t("url_start_with_https")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!booking && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Controller
|
||||||
|
name="displayLocationPublicly"
|
||||||
|
control={locationFormMethods.control}
|
||||||
|
render={() => (
|
||||||
|
<CheckboxField
|
||||||
|
description={t("display_location_label")}
|
||||||
|
defaultChecked={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find((location) => location.type === LocationType.Link)
|
||||||
|
?.displayLocationPublicly
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(e) => locationFormMethods.setValue("displayLocationPublicly", e.target.checked)}
|
||||||
|
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : selectedLocation === LocationType.UserPhone ? (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phonenumber" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("set_your_phone_number")}
|
||||||
|
{locationFormMethods.formState?.errors?.locationPhoneNumber?.message}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<PhoneInput<LocationFormValues>
|
||||||
|
control={locationFormMethods.control}
|
||||||
|
name="locationPhoneNumber"
|
||||||
|
required
|
||||||
|
id="locationPhoneNumber"
|
||||||
|
placeholder={t("host_phone_number")}
|
||||||
|
defaultValue={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find(
|
||||||
|
(location: { type: LocationType }) => location.type === LocationType.UserPhone
|
||||||
|
)?.hostPhoneNumber
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{locationFormMethods.formState.errors.locationPhoneNumber && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{t("invalid_number")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedLocation === LocationType.Whereby ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("set_whereby_link")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...locationFormMethods.register("locationLink")}
|
||||||
|
id="wherebylink"
|
||||||
|
placeholder="https://www.whereby.com/cal"
|
||||||
|
required
|
||||||
|
className={"block w-full rounded-sm border-gray-300 text-sm"}
|
||||||
|
defaultValue={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find(
|
||||||
|
(location: { type: LocationType }) => location.type === LocationType.Whereby
|
||||||
|
)?.address
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!booking && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Controller
|
||||||
|
name="displayLocationPublicly"
|
||||||
|
control={locationFormMethods.control}
|
||||||
|
render={() => (
|
||||||
|
<CheckboxField
|
||||||
|
defaultChecked={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find((location) => location.type === LocationType.Whereby)
|
||||||
|
?.displayLocationPublicly
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
description={t("display_location_label")}
|
||||||
|
onChange={(e) =>
|
||||||
|
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||||
|
}
|
||||||
|
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : selectedLocation === LocationType.Around ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("set_around_link")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...locationFormMethods.register("locationLink")}
|
||||||
|
id="aroundlink"
|
||||||
|
placeholder="https://www.around.co/rick"
|
||||||
|
required
|
||||||
|
className={"block w-full rounded-sm border-gray-300 text-sm"}
|
||||||
|
defaultValue={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find(
|
||||||
|
(location: { type: LocationType }) => location.type === LocationType.Around
|
||||||
|
)?.address
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!booking && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Controller
|
||||||
|
name="displayLocationPublicly"
|
||||||
|
control={locationFormMethods.control}
|
||||||
|
render={() => (
|
||||||
|
<CheckboxField
|
||||||
|
defaultChecked={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find((location) => location.type === LocationType.Around)
|
||||||
|
?.displayLocationPublicly
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
description={t("display_location_label")}
|
||||||
|
onChange={(e) =>
|
||||||
|
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||||
|
}
|
||||||
|
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : selectedLocation === LocationType.Riverside ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("set_around_link")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...locationFormMethods.register("locationLink")}
|
||||||
|
id="aroundlink"
|
||||||
|
placeholder="https://www.riverside.fm/studio/rick"
|
||||||
|
required
|
||||||
|
className={"block w-full rounded-sm border-gray-300 text-sm"}
|
||||||
|
defaultValue={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find(
|
||||||
|
(location: { type: LocationType }) => location.type === LocationType.Riverside
|
||||||
|
)?.address
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!booking && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Controller
|
||||||
|
name="displayLocationPublicly"
|
||||||
|
control={locationFormMethods.control}
|
||||||
|
render={() => (
|
||||||
|
<CheckboxField
|
||||||
|
defaultChecked={
|
||||||
|
defaultValues
|
||||||
|
? defaultValues.find((location) => location.type === LocationType.Riverside)
|
||||||
|
?.displayLocationPublicly
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
description={t("display_location_label")}
|
||||||
|
onChange={(e) =>
|
||||||
|
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||||
|
}
|
||||||
|
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{LocationOptionsToString(selectedLocation, t)}</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpenDialog}>
|
||||||
|
<DialogContent asChild>
|
||||||
|
<div className="inline-block transform rounded-sm bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||||
|
<div className="mb-4 sm:flex sm:items-start">
|
||||||
|
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
<LocationMarkerIcon className="text-primary-600 h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
|
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||||
|
{t("edit_location")}
|
||||||
|
</h3>
|
||||||
|
{!booking && (
|
||||||
|
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"></div>
|
||||||
|
</div>
|
||||||
|
{booking && (
|
||||||
|
<>
|
||||||
|
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
||||||
|
<p className="mb-2 ml-1 text-sm text-black">{linkValueToString(booking.location, t)}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
form={locationFormMethods}
|
||||||
|
handleSubmit={async (values) => {
|
||||||
|
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||||
|
|
||||||
|
let details = {};
|
||||||
|
if (newLocation === LocationType.InPerson) {
|
||||||
|
details = {
|
||||||
|
address: values.locationAddress,
|
||||||
|
displayLocationPublicly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
newLocation === LocationType.Link ||
|
||||||
|
newLocation === LocationType.Whereby ||
|
||||||
|
newLocation === LocationType.Around ||
|
||||||
|
newLocation === LocationType.Riverside
|
||||||
|
) {
|
||||||
|
details = { link: values.locationLink, displayLocationPublicly };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newLocation === LocationType.UserPhone) {
|
||||||
|
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLocation(newLocation, details);
|
||||||
|
setShowLocationModal(false);
|
||||||
|
setSelectedLocation?.(undefined);
|
||||||
|
locationFormMethods.unregister([
|
||||||
|
"locationType",
|
||||||
|
"locationLink",
|
||||||
|
"locationAddress",
|
||||||
|
"locationPhoneNumber",
|
||||||
|
]);
|
||||||
|
}}>
|
||||||
|
<QueryCell
|
||||||
|
query={locationsQuery}
|
||||||
|
success={({ data: locationOptions }) => {
|
||||||
|
if (!locationOptions.length) return null;
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name="locationType"
|
||||||
|
control={locationFormMethods.control}
|
||||||
|
render={() => (
|
||||||
|
<Select
|
||||||
|
maxMenuHeight={150}
|
||||||
|
name="location"
|
||||||
|
defaultValue={selection}
|
||||||
|
options={
|
||||||
|
booking
|
||||||
|
? locationOptions.filter((location) => location.value !== "phone")
|
||||||
|
: locationOptions
|
||||||
|
}
|
||||||
|
isSearchable={false}
|
||||||
|
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm"
|
||||||
|
onChange={(val) => {
|
||||||
|
if (val) {
|
||||||
|
locationFormMethods.setValue("locationType", val.value);
|
||||||
|
locationFormMethods.unregister([
|
||||||
|
"locationLink",
|
||||||
|
"locationAddress",
|
||||||
|
"locationPhoneNumber",
|
||||||
|
]);
|
||||||
|
locationFormMethods.clearErrors([
|
||||||
|
"locationLink",
|
||||||
|
"locationPhoneNumber",
|
||||||
|
"locationAddress",
|
||||||
|
]);
|
||||||
|
setSelectedLocation?.(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedLocation && LocationOptions}
|
||||||
|
<div className="mt-4 flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowLocationModal(false);
|
||||||
|
setSelectedLocation?.(undefined);
|
||||||
|
locationFormMethods.unregister("locationType");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
color="secondary">
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{t("update")}</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ClockIcon, XIcon } from "@heroicons/react/outline";
|
import { ClockIcon } from "@heroicons/react/outline";
|
||||||
import { RescheduleResponse } from "pages/api/book/request-reschedule";
|
import { RescheduleResponse } from "pages/api/book/request-reschedule";
|
||||||
import React, { useState, Dispatch, SetStateAction } from "react";
|
import React, { useState, Dispatch, SetStateAction } from "react";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
@@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose>
|
<DialogClose asChild>
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
<Button color="secondary">{t("cancel")}</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -56,23 +56,32 @@ export default function CreateEventTypeButton(props: Props) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
const pageSlug = router.query.eventPage || props.options[0].slug;
|
||||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||||
const title: string =
|
|
||||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
|
||||||
const length: number =
|
|
||||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
|
||||||
const description: string =
|
|
||||||
typeof router.query.description === "string" && router.query.description ? router.query.description : "";
|
|
||||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
|
||||||
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||||
resolver: zodResolver(createEventTypeInput),
|
resolver: zodResolver(createEventTypeInput),
|
||||||
});
|
});
|
||||||
const { setValue, watch, register } = form;
|
const { setValue, watch, register } = form;
|
||||||
setValue("title", title);
|
|
||||||
setValue("length", length);
|
useEffect(() => {
|
||||||
setValue("description", description);
|
if (!router.isReady) return;
|
||||||
setValue("slug", slug);
|
|
||||||
|
const title: string =
|
||||||
|
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||||
|
const length: number =
|
||||||
|
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||||
|
const description: string =
|
||||||
|
typeof router.query.description === "string" && router.query.description
|
||||||
|
? router.query.description
|
||||||
|
: "";
|
||||||
|
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||||
|
|
||||||
|
setValue("title", title);
|
||||||
|
setValue("length", length);
|
||||||
|
setValue("description", description);
|
||||||
|
setValue("slug", slug);
|
||||||
|
// If query params change, update the form
|
||||||
|
}, [router.isReady, router.query, setValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = watch((value, { name, type }) => {
|
const subscription = watch((value, { name, type }) => {
|
||||||
@@ -86,7 +95,7 @@ export default function CreateEventTypeButton(props: Props) {
|
|||||||
|
|
||||||
const createMutation = trpc.useMutation("viewer.eventTypes.create", {
|
const createMutation = trpc.useMutation("viewer.eventTypes.create", {
|
||||||
onSuccess: async ({ eventType }) => {
|
onSuccess: async ({ eventType }) => {
|
||||||
await router.push("/event-types/" + eventType.id);
|
await router.replace("/event-types/" + eventType.id);
|
||||||
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -95,6 +104,11 @@ export default function CreateEventTypeButton(props: Props) {
|
|||||||
showToast(message, "error");
|
showToast(message, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err.data?.code === "BAD_REQUEST") {
|
||||||
|
const message = `${err.data.code}: URL already exists.`;
|
||||||
|
showToast(message, "error");
|
||||||
|
}
|
||||||
|
|
||||||
if (err.data?.code === "UNAUTHORIZED") {
|
if (err.data?.code === "UNAUTHORIZED") {
|
||||||
const message = `${err.data.code}: You are not able to create this event`;
|
const message = `${err.data.code}: You are not able to create this event`;
|
||||||
showToast(message, "error");
|
showToast(message, "error");
|
||||||
@@ -161,7 +175,7 @@ export default function CreateEventTypeButton(props: Props) {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent className="overflow-y-auto">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||||
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
|
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
|
||||||
@@ -186,16 +200,26 @@ export default function CreateEventTypeButton(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
|
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
|
||||||
|
|
||||||
<TextField
|
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
|
||||||
label={t("url")}
|
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
|
||||||
required
|
<TextField
|
||||||
addOnLeading={
|
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
|
||||||
<InputLeading>
|
required
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
addOnLeading={<InputLeading>/{pageSlug}/</InputLeading>}
|
||||||
</InputLeading>
|
{...register("slug")}
|
||||||
}
|
/>
|
||||||
{...register("slug")}
|
) : (
|
||||||
/>
|
<TextField
|
||||||
|
label={t("url")}
|
||||||
|
required
|
||||||
|
addOnLeading={
|
||||||
|
<InputLeading>
|
||||||
|
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
||||||
|
</InputLeading>
|
||||||
|
}
|
||||||
|
{...register("slug")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
label={t("description")}
|
label={t("description")}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { ClockIcon, CreditCardIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
import {
|
||||||
import { SchedulingType } from "@prisma/client";
|
ClipboardCheckIcon,
|
||||||
import { Prisma } from "@prisma/client";
|
ClockIcon,
|
||||||
import React from "react";
|
CreditCardIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "@heroicons/react/solid";
|
||||||
|
import { Prisma, SchedulingType } from "@prisma/client";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
|
||||||
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||||
select: {
|
select: baseEventTypeSelect,
|
||||||
id: true,
|
|
||||||
length: true,
|
|
||||||
price: true,
|
|
||||||
currency: true,
|
|
||||||
schedulingType: true,
|
|
||||||
description: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||||
@@ -28,6 +30,11 @@ export type EventTypeDescriptionProps = {
|
|||||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const recurringEvent = useMemo(
|
||||||
|
() => parseRecurringEvent(eventType.recurringEvent),
|
||||||
|
[eventType.recurringEvent]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
||||||
@@ -37,26 +44,34 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
|||||||
{eventType.description.length > 100 && "..."}
|
{eventType.description.length > 100 && "..."}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
<ul className="mt-2 flex flex-wrap sm:flex-nowrap">
|
||||||
<li className="flex whitespace-nowrap">
|
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||||
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
<ClockIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
{eventType.length}m
|
{eventType.length}m
|
||||||
</li>
|
</li>
|
||||||
{eventType.schedulingType ? (
|
{eventType.schedulingType ? (
|
||||||
<li className="flex whitespace-nowrap">
|
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||||
<UsersIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
<UsersIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
||||||
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
|
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
<li className="flex whitespace-nowrap">
|
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||||
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
<UserIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
{t("1_on_1")}
|
{t("1_on_1")}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{recurringEvent?.count && recurringEvent.count > 0 && (
|
||||||
|
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||||
|
<RefreshIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
|
{t("repeats_up_to", {
|
||||||
|
count: recurringEvent.count,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{eventType.price > 0 && (
|
{eventType.price > 0 && (
|
||||||
<li className="flex whitespace-nowrap">
|
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||||
<CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
<CreditCardIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
value={eventType.price / 100.0}
|
value={eventType.price / 100.0}
|
||||||
@@ -66,6 +81,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
|||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{eventType.requiresConfirmation && (
|
||||||
|
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||||
|
<ClipboardCheckIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
|
{t("requires_confirmation")}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { FormValues } from "pages/event-types/[type]";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
|
|
||||||
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
|
type RecurringEventControllerProps = {
|
||||||
|
recurringEvent: RecurringEvent | null;
|
||||||
|
formMethods: UseFormReturn<FormValues>;
|
||||||
|
paymentEnabled: boolean;
|
||||||
|
onRecurringEventDefined: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecurringEventController({
|
||||||
|
recurringEvent,
|
||||||
|
formMethods,
|
||||||
|
paymentEnabled,
|
||||||
|
onRecurringEventDefined,
|
||||||
|
}: RecurringEventControllerProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
||||||
|
|
||||||
|
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||||
|
const recurringEventFreqOptions = Object.entries(Frequency)
|
||||||
|
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
|
||||||
|
value: value.toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="block items-start sm:flex">
|
||||||
|
<div className="min-w-48 mb-4 sm:mb-0">
|
||||||
|
<span className="flex text-sm font-medium text-neutral-700">{t("recurring_event")}</span>
|
||||||
|
</div>
|
||||||
|
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||||
|
{paymentEnabled ? (
|
||||||
|
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<div className="flex h-5 items-center">
|
||||||
|
<input
|
||||||
|
onChange={(event) => {
|
||||||
|
onRecurringEventDefined(event?.target.checked);
|
||||||
|
if (!event?.target.checked) {
|
||||||
|
formMethods.setValue("recurringEvent", null);
|
||||||
|
setRecurringEventState(null);
|
||||||
|
} else {
|
||||||
|
const newVal = recurringEvent || {
|
||||||
|
interval: 1,
|
||||||
|
count: 12,
|
||||||
|
freq: Frequency.WEEKLY,
|
||||||
|
};
|
||||||
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
|
setRecurringEventState(newVal);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
className="text-primary-600 h-4 w-4 rounded border-gray-300"
|
||||||
|
defaultChecked={recurringEventState !== null}
|
||||||
|
data-testid="recurring-event-check"
|
||||||
|
id="recurringEvent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||||
|
<label htmlFor="recurringEvent" className="text-neutral-900">
|
||||||
|
{t("recurring_event_description")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{recurringEventState && (
|
||||||
|
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||||
|
defaultValue={recurringEventState.interval}
|
||||||
|
onChange={(event) => {
|
||||||
|
const newVal = {
|
||||||
|
...recurringEventState,
|
||||||
|
interval: parseInt(event?.target.value),
|
||||||
|
};
|
||||||
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
|
setRecurringEventState(newVal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={recurringEventFreqOptions}
|
||||||
|
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||||
|
isSearchable={false}
|
||||||
|
className="w-18 block min-w-0 rounded-sm sm:text-sm"
|
||||||
|
onChange={(event) => {
|
||||||
|
const newVal = {
|
||||||
|
...recurringEventState,
|
||||||
|
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||||
|
};
|
||||||
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
|
setRecurringEventState(newVal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<p className="mr-2 text-neutral-900">{t("max")}</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||||
|
defaultValue={recurringEventState.count}
|
||||||
|
onChange={(event) => {
|
||||||
|
const newVal = {
|
||||||
|
...recurringEventState,
|
||||||
|
count: parseInt(event?.target.value),
|
||||||
|
};
|
||||||
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
|
setRecurringEventState(newVal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mr-2 text-neutral-900">
|
||||||
|
{t("occurrence", {
|
||||||
|
count: recurringEventState.count,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { LinkIcon } from "@heroicons/react/outline";
|
||||||
|
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon, UserIcon } from "@heroicons/react/solid";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
|
function SkeletonLoader() {
|
||||||
|
return (
|
||||||
|
<SkeletonContainer>
|
||||||
|
<div className="mb-4 flex items-center">
|
||||||
|
<SkeletonAvatar width="8" height="8"></SkeletonAvatar>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<SkeletonText height="4" width="16"></SkeletonText>
|
||||||
|
<SkeletonText height="4" width="24"></SkeletonText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
</SkeletonContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div>
|
||||||
|
<SkeletonText width="32" height="5"></SkeletonText>
|
||||||
|
</div>
|
||||||
|
<div className="">
|
||||||
|
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||||
|
<li className="flex items-center whitespace-nowrap">
|
||||||
|
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></ClockIcon>
|
||||||
|
<SkeletonText width="12" height="4"></SkeletonText>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center whitespace-nowrap">
|
||||||
|
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></UserIcon>
|
||||||
|
<SkeletonText width="16" height="4"></SkeletonText>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
|
||||||
|
<div className="flex justify-between rtl:space-x-reverse">
|
||||||
|
<div className="btn-icon appearance-none">
|
||||||
|
<ExternalLinkIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="btn-icon appearance-none">
|
||||||
|
<LinkIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="btn-icon appearance-none">
|
||||||
|
<DotsHorizontalIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,18 +2,20 @@ import { Fragment } from "react";
|
|||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
import showToast from "@calcom/lib/notification";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import Switch from "@calcom/ui/Switch";
|
import Switch from "@calcom/ui/Switch";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
import { trpc } from "@lib/trpc";
|
|
||||||
|
|
||||||
|
import AdditionalCalendarSelector from "@components/AdditionalCalendarSelector";
|
||||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||||
import { List } from "@components/List";
|
import { List } from "@components/List";
|
||||||
import { ShellSubHeading } from "@components/Shell";
|
import { ShellSubHeading } from "@components/Shell";
|
||||||
|
import SkeletonLoader from "@components/apps/SkeletonLoader";
|
||||||
|
|
||||||
import DisconnectIntegration from "./DisconnectIntegration";
|
import DisconnectIntegration from "./DisconnectIntegration";
|
||||||
import IntegrationListItem from "./IntegrationListItem";
|
import IntegrationListItem from "./IntegrationListItem";
|
||||||
@@ -21,6 +23,7 @@ import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChanged: () => unknown | Promise<unknown>;
|
onChanged: () => unknown | Promise<unknown>;
|
||||||
|
fromOnboarding?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CalendarSwitch(props: {
|
function CalendarSwitch(props: {
|
||||||
@@ -92,10 +95,44 @@ function CalendarSwitch(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CalendarList(props: Props) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: false }]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryCell
|
||||||
|
query={query}
|
||||||
|
success={({ data }) => (
|
||||||
|
<List>
|
||||||
|
{data.items.map((item) => (
|
||||||
|
<IntegrationListItem
|
||||||
|
key={item.title}
|
||||||
|
title={item.title}
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
description={item.description}
|
||||||
|
actions={
|
||||||
|
<InstallAppButton
|
||||||
|
type={item.type}
|
||||||
|
render={(buttonProps) => (
|
||||||
|
<Button color="secondary" {...buttonProps}>
|
||||||
|
{t("connect")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
onChanged={() => props.onChanged()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ConnectedCalendarsList(props: Props) {
|
function ConnectedCalendarsList(props: Props) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
||||||
|
const { fromOnboarding } = props;
|
||||||
return (
|
return (
|
||||||
<QueryCell
|
<QueryCell
|
||||||
query={query}
|
query={query}
|
||||||
@@ -124,22 +161,27 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
onOpenChange={props.onChanged}
|
onOpenChange={props.onChanged}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
<ul className="space-y-2 p-4">
|
{!fromOnboarding && (
|
||||||
{item.calendars.map((cal) => (
|
<>
|
||||||
<CalendarSwitch
|
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
|
||||||
key={cal.externalId}
|
<ul className="space-y-2 p-4">
|
||||||
externalId={cal.externalId}
|
{item.calendars.map((cal) => (
|
||||||
title={cal.name || "Nameless calendar"}
|
<CalendarSwitch
|
||||||
type={item.integration.type}
|
key={cal.externalId}
|
||||||
defaultSelected={cal.isSelected}
|
externalId={cal.externalId}
|
||||||
/>
|
title={cal.name || "Nameless calendar"}
|
||||||
))}
|
type={item.integration.type}
|
||||||
</ul>
|
defaultSelected={cal.isSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</IntegrationListItem>
|
</IntegrationListItem>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
severity="warning"
|
severity="warning"
|
||||||
title="Something went wrong"
|
title={t("calendar_error")}
|
||||||
message={item.error?.message}
|
message={item.error?.message}
|
||||||
actions={
|
actions={
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
@@ -163,83 +205,82 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarList(props: Props) {
|
export function CalendarListContainer(props: {
|
||||||
|
heading?: boolean;
|
||||||
|
items?: inferQueryOutput<"viewer.integrations">["items"];
|
||||||
|
fromOnboarding?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.integrations"]);
|
const { heading = true, fromOnboarding } = props;
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryCell
|
|
||||||
query={query}
|
|
||||||
success={({ data }) => (
|
|
||||||
<List>
|
|
||||||
{data.calendar.items.map((item) => (
|
|
||||||
<IntegrationListItem
|
|
||||||
key={item.title}
|
|
||||||
title={item.title}
|
|
||||||
imageSrc={item.imageSrc}
|
|
||||||
description={item.description}
|
|
||||||
actions={
|
|
||||||
<InstallAppButton
|
|
||||||
type={item.type}
|
|
||||||
render={(buttonProps) => (
|
|
||||||
<Button color="secondary" {...buttonProps}>
|
|
||||||
{t("connect")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
onChanged={() => props.onChanged()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarListContainer(props: { heading?: false }) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const { heading = true } = props;
|
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const onChanged = () =>
|
const onChanged = () =>
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
utils.invalidateQueries(["viewer.integrations"]),
|
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
||||||
]);
|
]);
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||||
|
const installedCalendars = trpc.useQuery([
|
||||||
|
"viewer.integrations",
|
||||||
|
{ variant: "calendar", onlyInstalled: true },
|
||||||
|
]);
|
||||||
const mutation = trpc.useMutation("viewer.setDestinationCalendar");
|
const mutation = trpc.useMutation("viewer.setDestinationCalendar");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<QueryCell
|
||||||
{heading && (
|
query={query}
|
||||||
<ShellSubHeading
|
customLoader={<SkeletonLoader className="mt-10" />}
|
||||||
className="mt-10 mb-0"
|
success={({ data }) => {
|
||||||
title={
|
return (
|
||||||
<SubHeadingTitleWithConnections
|
<>
|
||||||
title="Calendars"
|
{(!!data.connectedCalendars.length || !!installedCalendars.data?.items.length) && (
|
||||||
numConnections={query.data?.connectedCalendars.length}
|
<>
|
||||||
/>
|
{heading && (
|
||||||
}
|
<ShellSubHeading
|
||||||
subtitle={t("configure_how_your_event_types_interact")}
|
className="mt-10 mb-0"
|
||||||
actions={
|
title={
|
||||||
<div className="sm:min-w-80 block max-w-full">
|
<SubHeadingTitleWithConnections
|
||||||
<DestinationCalendarSelector
|
title="Calendars"
|
||||||
onChange={mutation.mutate}
|
numConnections={data.connectedCalendars.length}
|
||||||
isLoading={mutation.isLoading}
|
/>
|
||||||
value={query.data?.destinationCalendar?.externalId}
|
}
|
||||||
/>
|
subtitle={t("configure_how_your_event_types_interact")}
|
||||||
</div>
|
actions={
|
||||||
}
|
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||||
/>
|
<div className="sm:min-w-80 block max-w-full">
|
||||||
)}
|
<DestinationCalendarSelector
|
||||||
<ConnectedCalendarsList onChanged={onChanged} />
|
onChange={mutation.mutate}
|
||||||
{!!query.data?.connectedCalendars.length && (
|
isLoading={mutation.isLoading}
|
||||||
<ShellSubHeading
|
value={data.destinationCalendar?.externalId}
|
||||||
className="mt-6"
|
/>
|
||||||
title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
{!!data.connectedCalendars.length && (
|
||||||
<CalendarList onChanged={onChanged} />
|
<div className="sm:min-w-80 inline max-w-full">
|
||||||
</>
|
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConnectedCalendarsList onChanged={onChanged} fromOnboarding={fromOnboarding} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{fromOnboarding && (
|
||||||
|
<>
|
||||||
|
{!!query.data?.connectedCalendars.length && (
|
||||||
|
<ShellSubHeading
|
||||||
|
className="mt-4"
|
||||||
|
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CalendarList onChanged={onChanged} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loaded 100 of 994 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user