Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a30aa51d | ||
|
|
eb13057157 | ||
|
|
bb4d587359 | ||
|
|
3d7c32c0d8 | ||
|
|
39508b3daa | ||
|
|
e79c818edb | ||
|
|
8a1023087f | ||
|
|
9343aee778 | ||
|
|
89f645c450 | ||
|
|
da5d90eb07 | ||
|
|
9f30f95dd9 | ||
|
|
bc0c1341f6 | ||
|
|
1c0a51cc02 | ||
|
|
7cfe7647f3 | ||
|
|
cc91834ddd | ||
|
|
2cb1a62906 | ||
|
|
6135d449ec | ||
|
|
073b29c214 | ||
|
|
ea99dc5520 | ||
|
|
c3280a62b7 | ||
|
|
a41b42359a | ||
|
|
cc15fb5285 | ||
|
|
e554268592 | ||
|
|
f4c2764cbf | ||
|
|
ac54ccdd70 | ||
|
|
b3216634c9 | ||
|
|
6c437edc0e | ||
|
|
bed153b497 | ||
|
|
2be245eefc | ||
|
|
caf3b40687 | ||
|
|
5650bd6409 | ||
|
|
94a86acdaa | ||
|
|
defd49f986 | ||
|
|
679d3da957 | ||
|
|
e84fd39e11 | ||
|
|
1d74e9c201 | ||
|
|
3356cbc1df | ||
|
|
4584f666e0 | ||
|
|
c9078cfd94 | ||
|
|
4ae1c2aa8c | ||
|
|
cf15f5f2a2 | ||
|
|
d21f42c257 | ||
|
|
92a0dff2c7 | ||
|
|
47add7e7fc | ||
|
|
6dfc19247e | ||
|
|
2db4998eaa | ||
|
|
dad4fd17fb | ||
|
|
c26efcabe4 | ||
|
|
6a8db56dbb | ||
|
|
6c058d041a | ||
|
|
07b43e107e | ||
|
|
ab8ad6434f | ||
|
|
4d7a3ac21a | ||
|
|
2ac709630f | ||
|
|
64147d3618 | ||
|
|
fb0e89ab22 | ||
|
|
7bee930983 | ||
|
|
9678361f5c | ||
|
|
a404ba471d | ||
|
|
9a8aab2e54 | ||
|
|
4c54e9c384 | ||
|
|
8dd2135656 | ||
|
|
4ba72425b9 | ||
|
|
f4b0772746 | ||
|
|
1a605b555a | ||
|
|
17324e406c | ||
|
|
833459cabb | ||
|
|
6076fd7ba0 | ||
|
|
ca8b380815 | ||
|
|
d7953aa53a | ||
|
|
494012484e | ||
|
|
8c6669e204 | ||
|
|
d5f3623377 | ||
|
|
ef483a6520 | ||
|
|
339f00f6aa | ||
|
|
e98bebb9b2 | ||
|
|
dcd7e80e6a | ||
|
|
fcc13da23d | ||
|
|
67a210d439 | ||
|
|
e813f8bb17 | ||
|
|
5ff8d2db6e | ||
|
|
7953551189 | ||
|
|
879bc3f169 | ||
|
|
4aaf5da36d | ||
|
|
fb16d8e900 | ||
|
|
58d6bb840a | ||
|
|
1419c534c8 | ||
|
|
4de1de3418 | ||
|
|
c23a2102f4 | ||
|
|
df686370ab | ||
|
|
0bc7d4ceda | ||
|
|
9444f925e5 | ||
|
|
2918a34e1a | ||
|
|
213caa722e | ||
|
|
6c7d7be9cc | ||
|
|
f1ef50e606 | ||
|
|
22c51ba483 | ||
|
|
b6eaafa39f | ||
|
|
98707e66e1 | ||
|
|
44308cbfa0 | ||
|
|
352a9e2fec | ||
|
|
c1a9f4b656 | ||
|
|
32c6ca0ad5 | ||
|
|
e79b4168f2 | ||
|
|
54833b65ff | ||
|
|
26afc077a7 | ||
|
|
0093b6faa5 | ||
|
|
ef49fc4ce1 | ||
|
|
605f7d35ec | ||
|
|
9b37d65503 | ||
|
|
a1f9012501 | ||
|
|
008016902e | ||
|
|
9f33d85903 | ||
|
|
496b03e92f | ||
|
|
358da42a9b | ||
|
|
8812580f72 | ||
|
|
3009f17cb0 | ||
|
|
3328cb2226 | ||
|
|
7b57b4bcda | ||
|
|
6d413f5721 | ||
|
|
c9fc88ddc7 | ||
|
|
6d284ae15c | ||
|
|
c6791a8d3c | ||
|
|
aa1370b9c7 | ||
|
|
db27c7e4bc | ||
|
|
325c6ddc86 | ||
|
|
9c7428571d | ||
|
|
9391bb5b64 | ||
|
|
d58924ecad | ||
|
|
fdef15712a | ||
|
|
a5b5382306 | ||
|
|
c2fceb2e0f | ||
|
|
a73065ba5d | ||
|
|
7b0f677bdb | ||
|
|
4166a0ab02 | ||
|
|
da80a2bae8 | ||
|
|
c6fd91d181 | ||
|
|
f62e6d9586 | ||
|
|
b03c6d5040 | ||
|
|
172d77dcc3 | ||
|
|
1a905aa330 | ||
|
|
a8da4b87f0 | ||
|
|
eb679a8e7d | ||
|
|
b364a85ed8 | ||
|
|
a2e0d44fb8 | ||
|
|
a9ee5d6f7f | ||
|
|
6d35750784 | ||
|
|
4f423ba692 | ||
|
|
d097c1fada | ||
|
|
fc049e12b3 | ||
|
|
267422a1f4 | ||
|
|
6e15de31aa | ||
|
|
2f6b5ced04 | ||
|
|
cf57be2aef | ||
|
|
c2996cd2f8 | ||
|
|
79944dfed1 | ||
|
|
e83647c652 | ||
|
|
556bf346f0 | ||
|
|
b98f2045d0 | ||
|
|
a13c050e9d | ||
|
|
e088196c8d | ||
|
|
9456ffbb48 | ||
|
|
6d19cc22a5 | ||
|
|
c69c9d6314 | ||
|
|
e0ef30eb52 | ||
|
|
045828d4ab | ||
|
|
354d57f393 | ||
|
|
650d0082a3 | ||
|
|
7c4b7209c2 | ||
|
|
5736821466 | ||
|
|
75f76c130a | ||
|
|
936432a730 | ||
|
|
4fda760e5e | ||
|
|
c467fd41ac | ||
|
|
0969d5960a | ||
|
|
cbe1907118 | ||
|
|
afd41475c9 | ||
|
|
f07c0b09fa | ||
|
|
d0707422b9 | ||
|
|
cc0c686e65 | ||
|
|
7057da7327 | ||
|
|
fbb27ceab0 | ||
|
|
01a5d588f3 | ||
|
|
5003ada671 | ||
|
|
54374cf43e | ||
|
|
55bca88d9b | ||
|
|
f755312ed7 | ||
|
|
207e0aac1f | ||
|
|
c8f7b36307 | ||
|
|
b5f0f0c0de | ||
|
|
816bfdd8f7 | ||
|
|
499b433d4d | ||
|
|
7b1fbd2853 | ||
|
|
9c2e15f016 | ||
|
|
4ac2c52c4e | ||
|
|
4a4cc7c766 | ||
|
|
648886f630 | ||
|
|
8d7b2cfe51 | ||
|
|
bfc2b8df34 | ||
|
|
a36f2aa871 | ||
|
|
6d8a576f60 | ||
|
|
b5e54d6cf9 | ||
|
|
ea20fa9f35 | ||
|
|
c3b2a52815 | ||
|
|
6bd97065e6 | ||
|
|
878a7cbfc5 | ||
|
|
9c84c3db0a | ||
|
|
1cf8ef8155 | ||
|
|
14aa0a2f2d | ||
|
|
5d73095060 | ||
|
|
08683a4883 | ||
|
|
2f3cc03255 | ||
|
|
b5fd33d689 | ||
|
|
6799e5f214 | ||
|
|
63e93fb12c | ||
|
|
94289a84f1 | ||
|
|
0f332cc35e | ||
|
|
504d900d05 | ||
|
|
7ebcc08182 | ||
|
|
b137c87634 | ||
|
|
fefb297c47 | ||
|
|
c412e7f815 | ||
|
+2 |
cc99e1697b | ||
|
|
ad4d71d8c0 | ||
|
|
b3226dc308 | ||
|
|
9ef8903309 | ||
|
|
c46c9bfe90 | ||
|
|
583571247c | ||
|
|
05655a92ca | ||
|
|
6fb51db6ac | ||
|
|
407b0518b3 | ||
|
|
8436025ff5 | ||
|
|
4bea5a09c6 | ||
|
|
9b25f81b61 | ||
|
|
0c3ca5c267 | ||
|
|
2f85b8ad6b | ||
|
|
480bd48680 | ||
|
|
0e022b013c | ||
|
|
70b8a38b95 | ||
|
|
94eae71675 | ||
|
|
6f595b16ba | ||
|
|
0871e29f69 | ||
|
|
513a01fc74 | ||
|
|
b35532c07f | ||
|
|
2ae197b66d | ||
|
|
647ba8c19c | ||
|
|
e5ca77eb93 | ||
|
|
44191e79a1 | ||
|
|
be40e4b4b0 | ||
|
|
000f39b9c8 | ||
|
|
4be693341b | ||
|
|
6a96531da5 | ||
|
|
8a6b37104f | ||
|
|
d180e14013 | ||
|
|
44e5002369 | ||
|
|
f1aed2d8b7 | ||
|
|
b0e81e6b7b | ||
|
|
8bbc05b1e5 | ||
|
|
19f76e86d9 | ||
|
|
e731c33235 | ||
|
|
5059723ad1 | ||
|
|
611e109c28 | ||
|
|
3b1cca2505 | ||
|
|
759af31838 | ||
|
|
1e6b4861d9 | ||
|
|
617cf9c538 | ||
|
|
171827f547 | ||
|
|
8cfb9902ff | ||
|
|
79779e5166 | ||
|
|
6a975194bb | ||
|
|
a9f74b68f5 | ||
|
|
15692374c6 | ||
|
|
723b754703 | ||
|
|
bddec9d1de | ||
|
|
42494c7c2b | ||
|
|
852d5b3954 | ||
|
|
5f718bd9f7 | ||
|
|
b9ced0ecf8 | ||
|
|
04ed36cfcf | ||
|
|
467cacb8da | ||
|
|
b799fdcb7e | ||
|
|
d8017e4d2d | ||
|
|
8384c3f9ff | ||
|
|
66d47974cb | ||
|
|
3556dda252 | ||
|
|
c2e91b8378 | ||
|
|
4fe69b5864 | ||
|
|
95ce3b215d | ||
|
|
007f3319a0 | ||
|
|
9441da9d4e | ||
|
|
1510740cb2 | ||
|
|
8da68cc1fe | ||
|
|
6dbc8dd820 | ||
|
|
4ada428bcb | ||
|
|
6adaf0d3e8 | ||
|
|
c9e6acfa69 | ||
|
|
2272da5ee7 | ||
|
|
79b3875af3 | ||
|
|
d16b6fce8f | ||
|
|
40b35bdbe4 | ||
|
|
0af76ec5ab | ||
|
|
f7b20da640 | ||
|
|
eedf6913b8 | ||
|
|
78393a2b8b | ||
|
|
b3d64104b0 | ||
|
|
9266ba1e48 | ||
|
|
ec212e4317 | ||
|
|
02d4f0542a | ||
|
|
0aae07ae2f | ||
|
|
80ab6c9c98 | ||
|
|
86ba0766fc | ||
|
|
0adde17f38 | ||
|
|
9e4700f9df | ||
|
|
f4a87c66b3 | ||
|
|
0c06f1f6cb | ||
|
|
b731dfd421 | ||
|
|
0b704b9ed4 | ||
|
|
a0ece0d77e | ||
|
|
7839381911 | ||
|
|
6a976f5b42 | ||
|
|
8718905474 | ||
|
|
ff968d3867 | ||
|
|
8f5e6370b0 | ||
|
|
0a0c400333 | ||
|
|
0eb05aa540 | ||
|
|
efe18ebdcd | ||
|
|
ce097b89ba | ||
|
|
bb0e6389fa | ||
|
|
19a7cd1a50 | ||
|
|
49bcd5fec7 | ||
|
|
000a6e0d35 | ||
|
|
52936f60b4 | ||
|
|
2d6eebcd8a | ||
|
|
cfa6a5b996 | ||
|
|
7413ba6aa0 | ||
|
|
e9cbe5d353 | ||
|
|
d8bc9acbbc | ||
|
|
4f0e843ac6 | ||
|
|
02f1c50bb2 | ||
|
|
5952a38ab9 | ||
|
|
e5fb19163e | ||
|
|
79f34a4fc7 | ||
|
|
3e90c08d55 | ||
|
|
697d8eca43 | ||
|
|
5cecf3104f | ||
|
|
a3a6caa6dd | ||
|
|
d3586c9039 | ||
|
|
71eafa287a | ||
|
|
828ef8580e | ||
|
|
a348c3a20a | ||
|
|
3c68c7f7cb | ||
|
|
820b2da16d | ||
|
|
6bdb2b94eb | ||
|
|
3c3db0b3a6 | ||
|
|
4748641c22 | ||
|
|
804787f2a3 | ||
|
|
b7c3b31e0c | ||
|
|
ab6781cd72 | ||
|
|
b070cfb227 | ||
|
|
5acb3a09e5 | ||
|
|
69c9f690cb | ||
|
|
d2a12ea834 | ||
|
|
16f24b4831 | ||
|
|
a3eeeab259 | ||
|
|
f69c254fbc | ||
|
|
f0f6bd4846 | ||
|
|
8816a18854 | ||
|
|
4e529e5c6c | ||
|
|
e521bf6f0b | ||
|
|
a91a0f86e0 | ||
|
|
f654003f95 | ||
|
|
4cc8a6fbf4 | ||
|
|
5167608404 | ||
|
|
9c93685431 | ||
|
|
a380087c9b | ||
|
|
3559e0d082 | ||
|
|
4323b0c2d1 | ||
|
|
3a800f7511 | ||
|
|
c203e5a38b | ||
|
|
3f5b3f06a2 | ||
|
|
d52635efbb | ||
|
|
4add4df08e | ||
|
|
df6d7e8df7 | ||
|
|
dc61640118 | ||
|
|
361108246e | ||
|
|
a5e25b9b28 | ||
|
|
ccff528765 | ||
|
|
24a1ef6721 | ||
|
|
b89919129e | ||
|
|
cbf3edefc7 | ||
|
|
909ae5f2fb | ||
|
|
0bbe5173dc | ||
|
|
84ad133077 | ||
|
|
43647a797f | ||
|
|
f469a9c127 | ||
|
|
212f7a374f | ||
|
|
1929d5c7d4 | ||
|
|
8ae54cd7cc | ||
|
|
bb13009e64 | ||
|
|
648dd862f5 | ||
|
|
8eccd3658e | ||
|
|
ed65b2a3ab | ||
|
|
0930121680 | ||
|
|
1068873f27 | ||
|
|
a1ece0be63 | ||
|
|
c550b4c8fc | ||
|
|
e95650637c | ||
|
|
0f3e0a1554 | ||
|
|
761dfac033 | ||
|
|
9fda4acc5b | ||
|
|
0470708aad | ||
|
|
bee392ba1a | ||
|
|
448f57442b | ||
|
|
856b090b4b | ||
|
|
b99661a411 | ||
|
|
d96a26eb3c | ||
|
|
9d08f5c7c8 | ||
|
|
4fe5f6ca6f | ||
|
|
af505935bd | ||
|
|
4a30f60622 | ||
|
|
24ed04e6c7 | ||
|
|
bed3595314 | ||
|
|
74671263bf | ||
|
|
bdc5e9c7e9 | ||
|
|
8fa1c70a97 | ||
|
|
1343c8fa8d | ||
|
|
5c0f9510a1 | ||
|
|
b2801170fa | ||
|
|
6ac19e8307 | ||
|
|
344241cfbf | ||
|
|
66ebf18fa9 | ||
|
|
e6d6f3317e | ||
|
|
55f66e6bcd | ||
|
|
22e2b0a6a9 | ||
|
|
e24f3828cb | ||
|
|
70f5bdc499 | ||
|
|
8731a39c3c | ||
|
|
e2d9e75475 | ||
|
|
55c7707b94 | ||
|
|
98944d4815 | ||
|
|
faba25acbd | ||
|
|
aab5ee344c | ||
|
|
6880f88dbe | ||
|
|
9177fe86dc | ||
|
|
bf3f8acbc0 | ||
|
|
b4fa9826f2 | ||
|
|
955de3133c | ||
|
|
faac3d0f57 | ||
|
|
3fb1ede770 | ||
|
|
53224886e3 | ||
|
|
e513180d7e | ||
|
|
3116d6df1a | ||
|
|
4284a4d16d | ||
|
|
eadca3b2c0 | ||
|
|
68125af29f | ||
|
|
d436e628fe | ||
|
|
c5483c81de | ||
|
|
9db4a04cd9 | ||
|
|
8943e8c2f6 | ||
|
|
f17fa55426 | ||
|
|
9ff83075d6 | ||
|
|
6b6043afdc | ||
|
|
fd9162e70f | ||
|
|
8331059b7c | ||
|
|
f0992b4914 | ||
|
|
e081a4cfc2 | ||
|
|
895b3ca7c9 | ||
|
|
4d30264950 | ||
|
|
3a352a94f6 | ||
|
|
8623175612 | ||
|
|
ea531777c4 | ||
|
|
2639ef7e13 | ||
|
|
0e76a19c96 | ||
|
|
3ec9eb377d | ||
|
|
44b7000509 | ||
|
|
251307f1e6 | ||
|
|
75e638ec4d | ||
|
|
443ccdd6c0 | ||
|
|
dbf11cc04b | ||
|
|
a5c9729f5f | ||
|
|
a6e2983a22 | ||
|
|
a21f0c46c7 | ||
|
|
ed4a81e852 | ||
|
|
9231218350 | ||
|
|
7d1ea4ee2b | ||
|
|
0c68e51930 |
+33
-1
@@ -5,6 +5,7 @@
|
||||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
# - ORGANIZATIONS
|
||||
|
||||
# - LICENSE (DEPRECATED) ************************************************************************************
|
||||
# https://github.com/calcom/cal.com/blob/main/LICENSE
|
||||
@@ -22,6 +23,8 @@ CALCOM_LICENSE_KEY=
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
|
||||
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
|
||||
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
|
||||
@@ -32,6 +35,8 @@ PRISMA_GENERATE_DATAPROXY=
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - SHARED **************************************************************************************************
|
||||
# Set this to http://app.cal.local:3000 if you want to enable organizations, and
|
||||
# check variable ORGANIZATIONS_ENABLED at the bottom of this file
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||
@@ -54,6 +59,11 @@ SAML_CLIENT_SECRET_VERIFIER=
|
||||
# PGSSLMODE='no-verify'
|
||||
PGSSLMODE=
|
||||
|
||||
# Define which hostnames are expected for the app to work on
|
||||
ALLOWED_HOSTNAMES='"cal.com","cal.dev","cal-staging.com","cal.community","cal.local:3000","localhost:3000"'
|
||||
# Reserved orgs subdomains for our own usage
|
||||
RESERVED_SUBDOMAINS='"app","auth","docs","design","console","go","status","api","saml","www","matrix","developer","cal","my","team","support","security","blog","learn","admin"'
|
||||
|
||||
# - NEXTAUTH
|
||||
# @see: https://github.com/calendso/calendso/issues/263
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_url
|
||||
@@ -71,6 +81,10 @@ CALCOM_TELEMETRY_DISABLED=
|
||||
# ApiKey for cronjobs
|
||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||
|
||||
# Whether to automatically keep app metadata in the database in sync with the metadata/config files. When disabled, the
|
||||
# sync runs in a reporting-only dry-run mode.
|
||||
CRON_ENABLE_APP_SYNC=false
|
||||
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
@@ -107,6 +121,7 @@ TWILIO_SID=
|
||||
TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
@@ -121,6 +136,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRIVATE_KEY=
|
||||
STRIPE_CLIENT_ID=
|
||||
@@ -167,7 +183,6 @@ CLOSECOM_API_KEY=
|
||||
# Sendgrid internal sync service
|
||||
SENDGRID_SYNC_API_KEY=
|
||||
|
||||
|
||||
# Change your Brand
|
||||
NEXT_PUBLIC_APP_NAME="Cal.com"
|
||||
NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS="help@cal.com"
|
||||
@@ -183,3 +198,20 @@ CSP_POLICY=
|
||||
EDGE_CONFIG=
|
||||
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
|
||||
# - ORGANIZATIONS *******************************************************************************************
|
||||
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
||||
# This is mainly needed locally, because for orgs to work a full domain name needs to point
|
||||
# to the app, i.e. app.cal.local instead of using localhost, which is very disruptive
|
||||
#
|
||||
# This variable should only be set to 1 or true if you are in a non-prod environment and you want to
|
||||
# use organizations
|
||||
ORGANIZATIONS_ENABLED=
|
||||
|
||||
# Vercel Config to create subdomains for organizations
|
||||
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
||||
PROJECT_ID_VERCEL=
|
||||
# Get it from: https://vercel.com/teams/<TEAM_SLUG>/settings
|
||||
TEAM_ID_VERCEL=
|
||||
# Get it from: https://vercel.com/account/tokens
|
||||
AUTH_BEARER_TOKEN_VERCEL=
|
||||
|
||||
@@ -20,8 +20,20 @@ A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
|
||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||
|
||||
### Actual Results
|
||||
|
||||
- What's happening right now that is different from what is expected
|
||||
|
||||
### Expected Results
|
||||
|
||||
- This is an ideal result that the system should get after the tests are performed
|
||||
|
||||
### Technical details
|
||||
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
|
||||
### Evidence
|
||||
|
||||
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://cal.com/slack
|
||||
about: Ask a general question about the project on our Slack workspace
|
||||
url: https://go.cal.com/discord
|
||||
about: Ask a general question about the project on our discord community
|
||||
|
||||
@@ -39,3 +39,11 @@ assignees: ""
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Requirement/Document
|
||||
|
||||
<!--
|
||||
Is there any type of document that could support that feature?
|
||||
-->
|
||||
|
||||
(Share it here.)
|
||||
|
||||
@@ -8,29 +8,39 @@ Fixes # (issue)
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Requirement/Documentation
|
||||
|
||||
<!-- Please provide all documents that are important to understand the reason of that PR. -->
|
||||
- If there is a requirement document, please, share it here.
|
||||
- If there is ab UI/UX design document, please, share it here.
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- Bug fix (non-breaking change which fixes an issue)
|
||||
- Chore (refactoring code, technical debt, workflow improvements)
|
||||
- New feature (non-breaking change which adds functionality)
|
||||
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- This change requires a documentation update
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->
|
||||
|
||||
- [ ] Test A
|
||||
- [ ] Test B
|
||||
- Are there environment variables that should be set?
|
||||
- What are the minimal test data to have?
|
||||
- What is expected (happy path) to have (input and output)?
|
||||
- Any other important info that could help to test that PR
|
||||
|
||||
## Mandatory Tasks
|
||||
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
|
||||
|
||||
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Please remove all the irrelevant bullets to your PR -->
|
||||
|
||||
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
- My code doesn't follow the style guidelines of this project
|
||||
- I haven't commented my code, particularly in hard-to-understand areas
|
||||
|
||||
@@ -2,15 +2,16 @@ name: "Set environment variables"
|
||||
description: "Configures environment variables for a workflow"
|
||||
runs:
|
||||
using: "composite"
|
||||
|
||||
steps:
|
||||
- name: Create env file
|
||||
- name: Read env file
|
||||
uses: buildjet/cache@v3
|
||||
id: env-cache
|
||||
with:
|
||||
path: gh.env
|
||||
key: env-cache-${{ hashFiles('gh.env') }}
|
||||
path: gh.env-${{ github.sha }}
|
||||
key: env-cache
|
||||
restore-keys: env-cache-
|
||||
- name: Set Environment Variables
|
||||
uses: tw3lveparsecs/github-actions-setvars@latest
|
||||
with:
|
||||
envFilePath: gh.env
|
||||
envFilePath: gh.env-${{ github.sha }}
|
||||
|
||||
@@ -40,6 +40,14 @@ runs:
|
||||
restore-keys: |
|
||||
yarn-download-cache-
|
||||
|
||||
# Invalidated on yarn.lock changes
|
||||
- name: Restore node_modules
|
||||
id: yarn-nm-cache
|
||||
uses: buildjet/cache@v3
|
||||
with:
|
||||
path: "**/node_modules/"
|
||||
key: ${{ runner.os }}-yarn-nm-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
|
||||
|
||||
# Invalidated on yarn.lock changes
|
||||
- name: Restore yarn install state
|
||||
id: yarn-install-state-cache
|
||||
@@ -58,5 +66,6 @@ runs:
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS: "false" # So it doesn't try to remove our private submodule deps
|
||||
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
|
||||
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
|
||||
YARN_NM_MODE: "hardlinks-local" # Reduce node_modules size
|
||||
# Other environment variables
|
||||
HUSKY: "0" # By default do not run HUSKY install
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Add PRs to project Reviewing PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-PR-to-project:
|
||||
name: Add PRs to project Reviewing PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/11
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - scheduleWhatsappReminders
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
jobs:
|
||||
cron-scheduleWhatsappReminders:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/workflows/scheduleWhatsappReminders \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
schedule:
|
||||
# Runs every day (see https://crontab.guru)
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -22,3 +23,4 @@ jobs:
|
||||
days-before-pr-close: 7
|
||||
stale-pr-message: "This PR is being marked as stale due to inactivity."
|
||||
close-pr-message: "This PR is being closed due to inactivity. Please reopen if work is intended to be continued."
|
||||
operations-per-run: 100
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Cron - syncAppMeta
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “Every month at 1st (see https://crontab.guru)
|
||||
- cron: "0 0 1 * *"
|
||||
jobs:
|
||||
cron-syncAppMeta:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/syncAppMeta \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
uses: crowdin/github-action@1.5.1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
push_translations: true
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
ENV_FILE_NAME: gh.env-${{ github.sha }}
|
||||
INPUT_ENV_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
INPUT_ENV_NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
INPUT_ENV_NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
@@ -11,6 +12,7 @@ env:
|
||||
INPUT_ENV_GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
INPUT_ENV_GOOGLE_LOGIN_ENABLED: true
|
||||
# INPUT_ENV_CRON_API_KEY: xxx
|
||||
# INPUT_ENV_CRON_ENABLE_APP_SYNC: true|false
|
||||
INPUT_ENV_CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
INPUT_ENV_NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
INPUT_ENV_STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
@@ -31,6 +33,8 @@ env:
|
||||
# INPUT_ENV_MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# INPUT_ENV_ZOOM_CLIENT_ID: xxx
|
||||
# INPUT_ENV_ZOOM_CLIENT_SECRET: xxx
|
||||
INPUT_ENV_SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
INPUT_ENV_SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
INPUT_ENV_TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
INPUT_ENV_TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
@@ -41,10 +45,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ozaytsev86/create-env-file@v1
|
||||
|
||||
with:
|
||||
file-name: ${{ github.workspace }}/gh.env
|
||||
file-name: ${{ github.workspace }}/${{ env.ENV_FILE_NAME }}
|
||||
- uses: buildjet/cache@v3
|
||||
id: env-cache
|
||||
with:
|
||||
path: gh.env
|
||||
key: env-cache-${{ hashFiles('gh.env') }}
|
||||
path: ${{ env.ENV_FILE_NAME }}
|
||||
key: env-cache-${{ hashFiles(env.ENV_FILE_NAME) }}
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- name: Save Code Linting Reports
|
||||
- name: Run Linting with Reports
|
||||
run: yarn lint:report
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Assign PR team labels
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
team-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: equitybee/team-label-action@main
|
||||
with:
|
||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "app-store, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
@@ -16,6 +16,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
pull-requests: read
|
||||
@@ -33,12 +34,15 @@ jobs:
|
||||
app-store:
|
||||
- 'apps/web/**'
|
||||
- 'packages/app-store/**'
|
||||
- 'playwright.config.ts'
|
||||
embed:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
embed-react:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
|
||||
env:
|
||||
name: Create env file
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
validate-pr:
|
||||
@@ -17,5 +17,31 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@v2
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
# condition you can continue the execution with the populated error message.
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||
|
||||
Details:
|
||||
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions! 🙏
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: "Welcome new contributors"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: opened
|
||||
pull_request:
|
||||
types: opened
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
welcome-message:
|
||||
name: Welcoming New Users
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: semantics
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: |-
|
||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
||||
issue-message: |
|
||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||
Vendored
+2
-1
@@ -8,6 +8,7 @@
|
||||
"ban.spellright", // Spell check for docs
|
||||
"stripe.vscode-stripe", // stripe VSCode extension
|
||||
"Prisma.prisma", // syntax|format|completion for prisma
|
||||
"rebornix.project-snippets" // Share useful snippets between collaborators
|
||||
"rebornix.project-snippets", // Share useful snippets between collaborators
|
||||
"inlang.vs-code-extension" // improved i18n DX
|
||||
]
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ To develop locally:
|
||||
|
||||
5. Set up your `.env` file:
|
||||
|
||||
- Duplicate `.env.example` to `.env`.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ Copyright (c) 2020-present Cal.com, Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
|
||||
* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee and https://github.com/calcom/cal.com/tree/main/apps/api/ directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
|
||||
* All third party components incorporated into the Cal.com Software are licensed under the original license provided by the owner of the applicable component.
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
+34
-16
@@ -1,39 +1,57 @@
|
||||
# Security
|
||||
|
||||
Contact: security@cal.com
|
||||
Contact: [security@cal.com](mailto: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.
|
||||
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.
|
||||
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:
|
||||
## 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.
|
||||
- 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:
|
||||
## 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 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.
|
||||
- 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:
|
||||
## 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 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.
|
||||
- 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.
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"description": "ApiKey for cronjobs",
|
||||
"value": ""
|
||||
},
|
||||
"CRON_ENABLE_APP_SYNC": {
|
||||
"description": "Whether to automatically keep app metadata in the database in sync with the metadata/config files. When disabled, the sync runs in a reporting-only dry-run mode.",
|
||||
"value": "false"
|
||||
},
|
||||
"SEND_FEEDBACK_EMAIL": {
|
||||
"description": "Send feedback email",
|
||||
"value": ""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
The Cal.com Commercial License (the “Commercial License”)
|
||||
Copyright (c) 2020-present Cal.com, Inc
|
||||
|
||||
With regard to the Cal.com Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Cal.com Subscription Terms available
|
||||
at https://cal.com/terms, or other agreements governing
|
||||
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
|
||||
and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription")
|
||||
for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence,
|
||||
you are free to modify this Software and publish patches to the Software. You agree
|
||||
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
|
||||
and to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Commercial Subscription for the correct number of hosts.
|
||||
Notwithstanding the foregoing, you may copy and modify the Software for development
|
||||
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all such
|
||||
modifications. You are not granted any other rights beyond what is expressly stated herein.
|
||||
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
This Commercial License applies only to the part of this Software that is not distributed under
|
||||
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
|
||||
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
|
||||
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
|
||||
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
|
||||
be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Cal.com Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<a href="https://cal.com/docs/enterprise-features/api#api-server-specifications">Read the API docs</a>
|
||||
</div>
|
||||
|
||||
# Cal.com Public API
|
||||
# Commercial Cal.com Public API
|
||||
|
||||
Welcome to the Public API ("/apps/api") of the Cal.com.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const dateNotInPast = function (date: Date) {
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const { prisma, isCustomPrisma, isAdmin } = req;
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense)
|
||||
if (!hasValidLicense && process.env.NODE_ENV !== "development")
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
// If the user is an admin and using a license key (from customPrisma), skip the apiKey check.
|
||||
if (isCustomPrisma && isAdmin) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
||||
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -58,7 +59,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
const schemaEventTypeCreateParams = z
|
||||
.object({
|
||||
title: z.string(),
|
||||
slug: z.string(),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
description: z.string().optional().nullable(),
|
||||
length: z.number().int(),
|
||||
metadata: z.any().optional(),
|
||||
@@ -76,7 +77,10 @@ export const schemaEventTypeCreateBodyParams = schemaEventTypeBaseBodyParams
|
||||
const schemaEventTypeEditParams = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.transform((s) => slugify(s))
|
||||
.optional(),
|
||||
length: z.number().int().optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
"@sentry/nextjs": "^7.20.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "~13.2.1",
|
||||
"next": "^13.4.6",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.16.0",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^4.9.4",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { availabilityUserSelect } from "@calcom/prisma";
|
||||
@@ -119,8 +120,10 @@ const availabilitySchema = z
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { prisma, isAdmin, userId: reqUserId } = req;
|
||||
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
if (!teamId)
|
||||
return getUserAvailability({
|
||||
orgSlug: isValidOrgDomain ? currentOrgDomain || undefined : undefined,
|
||||
username,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
|
||||
@@ -35,6 +35,9 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* status:
|
||||
* type: string
|
||||
* description: 'Acceptable values one of ["ACCEPTED", "PENDING", "CANCELLED", "REJECTED"]'
|
||||
* description:
|
||||
* type: string
|
||||
* description: 'Description of the meeting'
|
||||
* examples:
|
||||
* editBooking:
|
||||
* value:
|
||||
@@ -79,7 +82,15 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* "startTime": "2023-05-24T13:00:00.000Z",
|
||||
* "endTime": "2023-05-24T13:30:00.000Z",
|
||||
* "metadata": {},
|
||||
* "status": "CANCELLED"
|
||||
* "status": "CANCELLED",
|
||||
* "responses": {
|
||||
* "email": "john.doe@example.com",
|
||||
* "name": "John Doe",
|
||||
* "location": {
|
||||
* "optionValue": "",
|
||||
* "value": "inPerson"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* 400:
|
||||
|
||||
@@ -26,13 +26,10 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
* required:
|
||||
* - eventTypeId
|
||||
* - start
|
||||
* - name
|
||||
* - email
|
||||
* - responses
|
||||
* - timeZone
|
||||
* - language
|
||||
* - metadata
|
||||
* - customInputs
|
||||
* - location
|
||||
* properties:
|
||||
* eventTypeId:
|
||||
* type: integer
|
||||
@@ -45,29 +42,33 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 'End time of the Event'
|
||||
* name:
|
||||
* type: string
|
||||
* description: 'Name of the Attendee'
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 'Email ID of the Attendee'
|
||||
* responses:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - email
|
||||
* - location
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: 'Attendee full name'
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 'Attendee email address'
|
||||
* location:
|
||||
* type: string
|
||||
* description: 'Meeting location'
|
||||
* metadata:
|
||||
* type: object
|
||||
* properties: {}
|
||||
* description: 'Any metadata associated with the booking'
|
||||
* timeZone:
|
||||
* type: string
|
||||
* description: 'TimeZone of the Attendee'
|
||||
* language:
|
||||
* type: string
|
||||
* description: 'Language of the Attendee'
|
||||
* metadata:
|
||||
* type: object
|
||||
* properties: {}
|
||||
* description: 'Any metadata associated with the booking'
|
||||
* customInputs:
|
||||
* type: array
|
||||
* items: {}
|
||||
* location:
|
||||
* type: string
|
||||
* description: 'Meeting location'
|
||||
* title:
|
||||
* type: string
|
||||
* description: 'Booking event title'
|
||||
@@ -96,13 +97,14 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
* "eventTypeId": 2323232,
|
||||
* "start": "2023-05-24T13:00:00.000Z",
|
||||
* "end": "2023-05-24T13:30:00.000Z",
|
||||
* "name": "Hello Hello",
|
||||
* "email": "hello@gmail.com",
|
||||
* "responses":{
|
||||
* "name": "Hello Hello",
|
||||
* "email": "hello@gmail.com",
|
||||
* "metadata": {},
|
||||
* "location": "Calcom HQ",
|
||||
* },
|
||||
* "timeZone": "Europe/London",
|
||||
* "language": "en",
|
||||
* "metadata": {},
|
||||
* "customInputs": [],
|
||||
* "location": "Calcom HQ",
|
||||
* "title": "Debugging between Syed Ali Shahbaz and Hello Hello",
|
||||
* "description": null,
|
||||
* "status": "PENDING",
|
||||
@@ -117,53 +119,50 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
* content:
|
||||
* application/json:
|
||||
* examples:
|
||||
* bookings:
|
||||
* booking created successfully example:
|
||||
* value:
|
||||
* {
|
||||
* "id": 11223344,
|
||||
* "uid": "5yUjmAYTDF6MXo98re8SkX",
|
||||
* "userId": 123,
|
||||
* "eventTypeId": 2323232,
|
||||
* "title": "Debugging between Syed Ali Shahbaz and Hello Hello",
|
||||
* "description": null,
|
||||
* "customInputs": {},
|
||||
* "responses": null,
|
||||
* "startTime": "2023-05-24T13:00:00.000Z",
|
||||
* "endTime": "2023-05-24T13:30:00.000Z",
|
||||
* "location": "Calcom HQ",
|
||||
* "createdAt": "2023-04-19T10:17:58.580Z",
|
||||
* "updatedAt": null,
|
||||
* "status": "PENDING",
|
||||
* "paid": false,
|
||||
* "destinationCalendarId": 2180,
|
||||
* "cancellationReason": null,
|
||||
* "rejectionReason": null,
|
||||
* "dynamicEventSlugRef": null,
|
||||
* "dynamicGroupSlugRef": null,
|
||||
* "rescheduled": null,
|
||||
* "fromReschedule": null,
|
||||
* "recurringEventId": null,
|
||||
* "smsReminderNumber": null,
|
||||
* "scheduledJobs": [],
|
||||
* "metadata": {},
|
||||
* "isRecorded": false,
|
||||
* "user": {
|
||||
* "email": "test@cal.com",
|
||||
* "name": "Syed Ali Shahbaz",
|
||||
* "timeZone": "Asia/Calcutta"
|
||||
* },
|
||||
* "attendees": [
|
||||
* {
|
||||
* "id": 12345,
|
||||
* "email": "hello@gmail.com",
|
||||
* "name": "Hello Hello",
|
||||
* "timeZone": "Europe/London",
|
||||
* "locale": "en",
|
||||
* "bookingId": 11223344
|
||||
* "booking": {
|
||||
* "id": 91,
|
||||
* "userId": 5,
|
||||
* "description": "",
|
||||
* "eventTypeId": 7,
|
||||
* "uid": "bFJeNb2uX8ANpT3JL5EfXw",
|
||||
* "title": "60min between Pro Example and John Doe",
|
||||
* "startTime": "2023-05-25T09:30:00.000Z",
|
||||
* "endTime": "2023-05-25T10:30:00.000Z",
|
||||
* "attendees": [
|
||||
* {
|
||||
* "email": "john.doe@example.com",
|
||||
* "name": "John Doe",
|
||||
* "timeZone": "Asia/Kolkata",
|
||||
* "locale": "en"
|
||||
* }
|
||||
* ],
|
||||
* "user": {
|
||||
* "email": "pro@example.com",
|
||||
* "name": "Pro Example",
|
||||
* "timeZone": "Asia/Kolkata",
|
||||
* "locale": "en"
|
||||
* },
|
||||
* "payment": [
|
||||
* {
|
||||
* "id": 1,
|
||||
* "success": true,
|
||||
* "paymentOption": "ON_BOOKING"
|
||||
* }
|
||||
* ],
|
||||
* "metadata": {},
|
||||
* "status": "ACCEPTED",
|
||||
* "responses": {
|
||||
* "email": "john.doe@example.com",
|
||||
* "name": "John Doe",
|
||||
* "location": {
|
||||
* "optionValue": "",
|
||||
* "value": "inPerson"
|
||||
* }
|
||||
* }
|
||||
* ],
|
||||
* "payment": [],
|
||||
* "references": []
|
||||
* }
|
||||
* }
|
||||
* 400:
|
||||
* description: |
|
||||
|
||||
@@ -47,7 +47,6 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* slug:
|
||||
* type: string
|
||||
* description: Unique slug for the event type
|
||||
* example: my-event
|
||||
* hosts:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -168,9 +167,9 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* type: string
|
||||
* displayLocationPublicly:
|
||||
* type: boolean
|
||||
* example:
|
||||
* examples:
|
||||
* event-type:
|
||||
* summary: An example of event type POST request
|
||||
* summary: An example of an individual event type POST request
|
||||
* value:
|
||||
* title: Hello World
|
||||
* slug: hello-world
|
||||
@@ -205,6 +204,42 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* team-event-type:
|
||||
* summary: An example of a team event type POST request
|
||||
* value:
|
||||
* title: "Tennis class"
|
||||
* slug: "tennis-class-{{$guid}}"
|
||||
* length: 60
|
||||
* hidden: false
|
||||
* position: 0
|
||||
* teamId: 3
|
||||
* eventName: null
|
||||
* timeZone: null
|
||||
* periodType: "UNLIMITED"
|
||||
* periodStartDate: null
|
||||
* periodEndDate: null
|
||||
* periodDays: null
|
||||
* periodCountCalendarDays: null
|
||||
* requiresConfirmation: true
|
||||
* recurringEvent:
|
||||
* interval: 2
|
||||
* count: 10
|
||||
* freq: 2
|
||||
* disableGuests: false
|
||||
* hideCalendarNotes: false
|
||||
* minimumBookingNotice: 120
|
||||
* beforeEventBuffer: 0
|
||||
* afterEventBuffer: 0
|
||||
* schedulingType: "COLLECTIVE"
|
||||
* price: 0
|
||||
* currency: "usd"
|
||||
* slotInterval: null
|
||||
* successRedirectUrl: null
|
||||
* description: null
|
||||
* locations:
|
||||
* - address: "London"
|
||||
* type: "inPerson"
|
||||
* metadata: {}
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
||||
|
||||
export default function getCalLink(eventType: {
|
||||
team?: { slug: string | null } | null;
|
||||
owner: { username: string | null } | null;
|
||||
owner?: { username: string | null } | null;
|
||||
users?: { username: string | null }[];
|
||||
slug: string;
|
||||
}) {
|
||||
|
||||
@@ -2,29 +2,36 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { publishHandler } from "@calcom/trpc/server/routers/viewer/teams/publish.handler";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId";
|
||||
|
||||
import authMiddleware, { checkPermissions } from "./_auth-middleware";
|
||||
|
||||
const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await checkPermissions(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN] });
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res });
|
||||
const user = ctx.user;
|
||||
if (!user) {
|
||||
throw new Error("Internal Error.");
|
||||
async function sessionGetter() {
|
||||
return {
|
||||
user: {
|
||||
id: req.userId,
|
||||
username: "" /* Not used in this context */,
|
||||
role: req.isAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER,
|
||||
},
|
||||
hasValidLicense: true /* To comply with TS signature */,
|
||||
expires: "" /* Not used in this context */,
|
||||
};
|
||||
}
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
||||
return await publishHandler({ input: { teamId }, ctx: { ...ctx, user } });
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.publish(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
if (cause instanceof TRPCError) {
|
||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||
|
||||
@@ -26,15 +26,15 @@ export function VariantsTable({
|
||||
<table>
|
||||
<RowTitles titles={titles} />
|
||||
{columns.map((column) => (
|
||||
<tr className="p-2 pr-6 pb-6" key={column.props.variant}>
|
||||
<tr className="p-2 pb-6 pr-6" key={column.props.variant}>
|
||||
<th
|
||||
className="p-2 pr-6 pb-6 text-left text-sm font-normal text-[#8F8F8F]"
|
||||
className="p-2 pb-6 pr-6 text-left text-sm font-normal text-[#8F8F8F]"
|
||||
key={column.props.variant}>
|
||||
{column.props.variant}
|
||||
</th>
|
||||
{React.Children.count(column.props.children) &&
|
||||
React.Children.map(column.props.children, (cell) => (
|
||||
<td className="p-2 pr-6 pb-6" style={{ minWidth: `${columnMinWidth}px` }}>
|
||||
<td className="p-2 pb-6 pr-6" style={{ minWidth: `${columnMinWidth}px` }}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
@@ -70,9 +70,9 @@ export function VariantRow({ children }: RowProps) {
|
||||
export function RowTitles({ titles }: { titles: string[] }) {
|
||||
return (
|
||||
<tr>
|
||||
<th className="p-2 pr-6 pb-6 text-left text-sm font-normal text-[#8F8F8F]" />
|
||||
<th className="p-2 pb-6 pr-6 text-left text-sm font-normal text-[#8F8F8F]" />
|
||||
{titles.map((title) => (
|
||||
<th className="p-2 pr-6 pb-6 text-left text-sm font-normal text-[#8F8F8F]" key={title}>
|
||||
<th className="p-2 pb-6 pr-6 text-left text-sm font-normal text-[#8F8F8F]" key={title}>
|
||||
{title}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
"@calcom/ui": "*",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-id": "^1.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.2",
|
||||
"@radix-ui/react-radio-group": "^1.0.0",
|
||||
"@radix-ui/react-slider": "^1.0.0",
|
||||
"@radix-ui/react-switch": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"next": "^13.2.1",
|
||||
"next": "^13.4.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"storybook-addon-rtl-direction": "^0.0.19"
|
||||
@@ -37,8 +37,8 @@
|
||||
"@storybook/react": "^6.5.13",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.9",
|
||||
"@vitejs/plugin-react": "^2.1.0",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"@vitejs/plugin-react": "^2.2.0",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"babel-loader": "^8.2.5",
|
||||
"fs": "^0.0.1-security",
|
||||
@@ -48,8 +48,8 @@
|
||||
"storybook-addon-designs": "^6.3.1",
|
||||
"storybook-addon-next": "^1.6.9",
|
||||
"storybook-react-i18next": "^1.1.2",
|
||||
"tailwindcss": "^3.2.1",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^2.9.15"
|
||||
"vite": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
--cal-bg-success: #E2FBE8;
|
||||
--cal-bg-attention: #FCEED8;
|
||||
--cal-bg-error: #F9E3E2;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
|
||||
/* Borders */
|
||||
@@ -216,6 +217,7 @@
|
||||
--cal-border: #D1D5DB;
|
||||
--cal-border-subtle:#E5E7EB;
|
||||
--cal-border-muted:#F3F4F6;
|
||||
--cal-border-error: #aa2e26;
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #111827;
|
||||
@@ -235,6 +237,7 @@
|
||||
*/
|
||||
--cal-brand:#111827;
|
||||
--cal-brand-emphasis:#101010;
|
||||
--cal-brand-text: white;
|
||||
}
|
||||
.dark {
|
||||
/* background */
|
||||
@@ -250,6 +253,7 @@
|
||||
--cal-bg-success: #E2FBE8;
|
||||
--cal-bg-attention: #FCEED8;
|
||||
--cal-bg-error: #F9E3E2;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
|
||||
/* Borders */
|
||||
@@ -257,6 +261,7 @@
|
||||
--cal-border: #444444;
|
||||
--cal-border-subtle: #2B2B2B;
|
||||
--cal-border-muted: #1C1C1C;
|
||||
--cal-border-error: #aa2e26;
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #F3F4F6;
|
||||
@@ -274,7 +279,8 @@
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: #111827;
|
||||
--cal-brand-emphasis: #101010;
|
||||
--cal-brand: white;
|
||||
--cal-brand-emphasis: #e1e1e1;
|
||||
--cal-brand-text: black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.6.0",
|
||||
"isarray": "2.0.5",
|
||||
"next": "^13.2.1",
|
||||
"next": "^13.4.6",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "16.9.1",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.9",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { Badge, ListItemText } from "@calcom/ui";
|
||||
import { Badge, ListItemText, Avatar } from "@calcom/ui";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
|
||||
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
||||
@@ -19,6 +20,7 @@ type AppListCardProps = {
|
||||
isTemplate?: boolean;
|
||||
invalidCredential?: boolean;
|
||||
children?: ReactNode;
|
||||
credentialOwner?: CredentialOwner;
|
||||
} & ShouldHighlight;
|
||||
|
||||
const schema = z.object({ hl: z.string().optional() });
|
||||
@@ -36,6 +38,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
isTemplate,
|
||||
invalidCredential,
|
||||
children,
|
||||
credentialOwner,
|
||||
} = props;
|
||||
const {
|
||||
data: { hl },
|
||||
@@ -65,7 +68,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex gap-x-3 px-5 py-4">
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
@@ -85,6 +88,21 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{credentialOwner && (
|
||||
<div>
|
||||
<Badge variant="gray">
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
className="mr-2"
|
||||
alt={credentialOwner.name || "Nameless"}
|
||||
size="xs"
|
||||
imageSrc={credentialOwner.avatar}
|
||||
/>
|
||||
{credentialOwner.name}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
+130
-22
@@ -8,12 +8,16 @@ import { createRef, forwardRef, useRef, useState } from "react";
|
||||
import type { ControlProps } from "react-select";
|
||||
import { components } from "react-select";
|
||||
|
||||
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { APP_NAME, EMBED_LIB_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
DialogContent,
|
||||
HorizontalTabs,
|
||||
Label,
|
||||
@@ -35,18 +39,27 @@ const enum Theme {
|
||||
dark = "dark",
|
||||
}
|
||||
|
||||
const EMBED_CAL_ORIGIN = WEBAPP_URL;
|
||||
const EMBED_CAL_JS_URL = `${WEBAPP_URL}/embed/embed.js`;
|
||||
|
||||
type PreviewState = {
|
||||
inline: {
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
theme: Theme;
|
||||
floatingPopup: Record<string, string>;
|
||||
floatingPopup: {
|
||||
config?: {
|
||||
layout: BookerLayouts;
|
||||
};
|
||||
[key: string]: string | boolean | undefined | Record<string, string>;
|
||||
};
|
||||
elementClick: Record<string, string>;
|
||||
palette: {
|
||||
brandColor: string;
|
||||
};
|
||||
hideEventTypeDetails: boolean;
|
||||
layout: BookerLayouts;
|
||||
};
|
||||
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
||||
|
||||
@@ -129,11 +142,13 @@ const getEmbedUIInstructionString = ({
|
||||
theme,
|
||||
brandColor,
|
||||
hideEventTypeDetails,
|
||||
layout,
|
||||
}: {
|
||||
apiName: string;
|
||||
theme?: string;
|
||||
brandColor: string;
|
||||
hideEventTypeDetails: boolean;
|
||||
layout?: string;
|
||||
}) => {
|
||||
theme = theme !== "auto" ? theme : undefined;
|
||||
return getInstructionString({
|
||||
@@ -147,6 +162,7 @@ const getEmbedUIInstructionString = ({
|
||||
},
|
||||
},
|
||||
hideEventTypeDetails: hideEventTypeDetails,
|
||||
layout,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -168,14 +184,24 @@ const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
import { useEffect } from "react";
|
||||
function MyComponent() {
|
||||
export default function MyApp() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
${uiInstructionCode}
|
||||
})();
|
||||
}, [])
|
||||
return <Cal calLink="${calLink}" style={{width:"${width}",height:"${height}",overflow:"scroll"}} />;
|
||||
return <Cal
|
||||
calLink="${calLink}"
|
||||
style={{width:"${width}",height:"${height}",overflow:"scroll"}}
|
||||
${previewState.layout ? "config={{layout: '" + previewState.layout + "'}}" : ""}${
|
||||
IS_SELF_HOSTED
|
||||
? `
|
||||
calOrigin="${EMBED_CAL_ORIGIN}"
|
||||
calJsUrl="${EMBED_CAL_JS_URL}"`
|
||||
: ""
|
||||
}
|
||||
/>;
|
||||
};`;
|
||||
},
|
||||
"floating-popup": ({
|
||||
@@ -186,38 +212,60 @@ function MyComponent() {
|
||||
uiInstructionCode: string;
|
||||
}) => {
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
import { getCalApi } from "@calcom/embed-react";
|
||||
import { useEffect } from "react";
|
||||
function MyComponent() {
|
||||
export default function App() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
|
||||
cal("floatingButton", ${floatingButtonArg});
|
||||
${uiInstructionCode}
|
||||
})();
|
||||
}, [])
|
||||
};`;
|
||||
},
|
||||
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
||||
"element-click": ({
|
||||
calLink,
|
||||
uiInstructionCode,
|
||||
previewState,
|
||||
}: {
|
||||
calLink: string;
|
||||
uiInstructionCode: string;
|
||||
previewState: PreviewState;
|
||||
}) => {
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
import { getCalApi } from "@calcom/embed-react";
|
||||
import { useEffect } from "react";
|
||||
function MyComponent() {
|
||||
export default function App() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
|
||||
${uiInstructionCode}
|
||||
})();
|
||||
}, [])
|
||||
return <button data-cal-link="${calLink}" />;
|
||||
return <button
|
||||
data-cal-link="${calLink}"${IS_SELF_HOSTED ? `\ndata-cal-origin="${EMBED_CAL_ORIGIN}"` : ""}
|
||||
${`data-cal-config='${JSON.stringify({
|
||||
layout: previewState.layout,
|
||||
})}'`}
|
||||
>Click me</button>;
|
||||
};`;
|
||||
},
|
||||
},
|
||||
HTML: {
|
||||
inline: ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
||||
inline: ({
|
||||
calLink,
|
||||
uiInstructionCode,
|
||||
previewState,
|
||||
}: {
|
||||
calLink: string;
|
||||
uiInstructionCode: string;
|
||||
previewState: PreviewState;
|
||||
}) => {
|
||||
return code`Cal("inline", {
|
||||
elementOrSelector:"#my-cal-inline",
|
||||
calLink: "${calLink}"
|
||||
calLink: "${calLink}",
|
||||
layout: "${previewState.layout}"
|
||||
});
|
||||
|
||||
${uiInstructionCode}`;
|
||||
@@ -233,8 +281,22 @@ ${uiInstructionCode}`;
|
||||
return code`Cal("floatingButton", ${floatingButtonArg});
|
||||
${uiInstructionCode}`;
|
||||
},
|
||||
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
||||
return code`// Important: Make sure to add \`data-cal-link="${calLink}"\` attribute to the element you want to open Cal on click
|
||||
"element-click": ({
|
||||
calLink,
|
||||
uiInstructionCode,
|
||||
previewState,
|
||||
}: {
|
||||
calLink: string;
|
||||
uiInstructionCode: string;
|
||||
previewState: PreviewState;
|
||||
}) => {
|
||||
return code`
|
||||
// Important: Please add following attributes to the element you want to open Cal on click
|
||||
// \`data-cal-link="${calLink}"\`
|
||||
// \`data-cal-config='${JSON.stringify({
|
||||
layout: previewState.layout,
|
||||
})}'\`
|
||||
|
||||
${uiInstructionCode}`;
|
||||
},
|
||||
},
|
||||
@@ -260,6 +322,7 @@ const getEmbedTypeSpecificString = ({
|
||||
theme: PreviewState["theme"];
|
||||
brandColor: string;
|
||||
hideEventTypeDetails: boolean;
|
||||
layout?: BookerLayout;
|
||||
};
|
||||
if (embedFramework === "react") {
|
||||
uiInstructionStringArg = {
|
||||
@@ -267,6 +330,7 @@ const getEmbedTypeSpecificString = ({
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
layout: previewState.layout,
|
||||
};
|
||||
} else {
|
||||
uiInstructionStringArg = {
|
||||
@@ -274,6 +338,7 @@ const getEmbedTypeSpecificString = ({
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
layout: previewState.layout,
|
||||
};
|
||||
}
|
||||
if (!frameworkCodes[embedType]) {
|
||||
@@ -288,6 +353,7 @@ const getEmbedTypeSpecificString = ({
|
||||
} else if (embedType === "floating-popup") {
|
||||
const floatingButtonArg = {
|
||||
calLink,
|
||||
...(IS_SELF_HOSTED ? { calOrigin: EMBED_CAL_ORIGIN } : null),
|
||||
...previewState.floatingPopup,
|
||||
};
|
||||
return frameworkCodes[embedType]({
|
||||
@@ -626,6 +692,7 @@ const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme;
|
||||
const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DialogContent className="rounded-lg p-10" type="creation" size="lg">
|
||||
<div className="mb-2">
|
||||
@@ -671,6 +738,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const dialogContentRef = useRef<HTMLDivElement>(null);
|
||||
const flags = useFlagMap();
|
||||
const isBookerLayoutsEnabled = flags["booker-layouts"] === true;
|
||||
|
||||
const s = (href: string) => {
|
||||
const searchParams = new URLSearchParams(router.asPath.split("?")[1] || "");
|
||||
@@ -691,12 +760,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
|
||||
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true);
|
||||
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true);
|
||||
const [previewState, setPreviewState] = useState({
|
||||
const [previewState, setPreviewState] = useState<PreviewState>({
|
||||
inline: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
theme: Theme.auto,
|
||||
layout: BookerLayouts.MONTH_VIEW,
|
||||
floatingPopup: {},
|
||||
elementClick: {},
|
||||
hideEventTypeDetails: false,
|
||||
@@ -764,6 +834,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
name: "ui",
|
||||
arg: {
|
||||
theme: previewState.theme,
|
||||
layout: previewState.layout,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
styles: {
|
||||
branding: {
|
||||
@@ -798,6 +869,12 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
{ value: Theme.light, label: "Light Theme" },
|
||||
];
|
||||
|
||||
const layoutOptions = [
|
||||
{ value: BookerLayouts.MONTH_VIEW, label: t("bookerlayout_month_view") },
|
||||
{ value: BookerLayouts.WEEK_VIEW, label: t("bookerlayout_week_view") },
|
||||
{ value: BookerLayouts.COLUMN_VIEW, label: t("bookerlayout_column_view") },
|
||||
];
|
||||
|
||||
const FloatingPopupPositionOptions = [
|
||||
{
|
||||
value: "bottom-right",
|
||||
@@ -979,7 +1056,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div className="whitespace-nowrap">Text color</div>
|
||||
<div className="mt-2 mb-6 w-40 xl:mt-0 xl:w-full">
|
||||
<div className="mb-6 mt-2 w-40 xl:mt-0 xl:w-full">
|
||||
<ColorPicker
|
||||
className="w-[130px]"
|
||||
popoverAlign="start"
|
||||
@@ -1070,6 +1147,34 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
{isBookerLayoutsEnabled && (
|
||||
<Label className="mb-6">
|
||||
<div className="mb-2">{t("layout")}</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={layoutOptions[0]}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
setPreviewState((previewState) => {
|
||||
const config = {
|
||||
...(previewState.floatingPopup.config ?? {}),
|
||||
layout: option.value,
|
||||
};
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
config,
|
||||
},
|
||||
layout: option.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={layoutOptions}
|
||||
/>
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
@@ -1102,8 +1207,12 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={router.query.embedTabName == "embed-preview" ? "block" : "hidden"} />
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<div className={router.query.embedTabName == "embed-preview" ? "mt-2 block" : "hidden"} />
|
||||
<DialogFooter
|
||||
className="mt-10 flex flex-row-reverse gap-x-2"
|
||||
showDivider
|
||||
customDividerClassNames="w-2/3">
|
||||
<DialogClose />
|
||||
{tab.type === "code" ? (
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -1118,8 +1227,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
{t("copy_code")}
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogClose />
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -71,7 +71,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
isCurrent
|
||||
? "text-emphasis border-gray-900"
|
||||
: "hover:border-default hover:text-default text-subtle border-transparent",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium",
|
||||
"group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
@@ -52,18 +53,20 @@ function PageWrapper(props: AppProps) {
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
// Canonical: Check if the URL is from cal.com so that we set the canonical conditionally
|
||||
const isCalcom =
|
||||
WEBAPP_URL &&
|
||||
(new URL(WEBAPP_URL).hostname.endsWith("cal.com") || new URL(WEBAPP_URL).hostname.endsWith("cal.dev"));
|
||||
const path = router.asPath;
|
||||
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
/>
|
||||
</Head>
|
||||
<DefaultSeo
|
||||
// Set canonical to https://cal.com or self-hosted URL
|
||||
canonical={
|
||||
isCalcom
|
||||
IS_CALCOM
|
||||
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
|
||||
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
|
||||
}
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App as AppType } from "@calcom/types/App";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import type { AppFrontendPayload as App } from "@calcom/types/App";
|
||||
import { Button, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuLabel,
|
||||
DropdownItem,
|
||||
Avatar,
|
||||
} from "@calcom/ui";
|
||||
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Plus, Shield } from "@calcom/ui/components/icon";
|
||||
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
|
||||
|
||||
|
||||
/* These app slugs all require Google Cal to be installed */
|
||||
|
||||
@@ -60,11 +77,14 @@ const Component = ({
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const appCredentials = trpc.viewer.appCredentialsByType.useQuery(
|
||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
onSuccess(data) {
|
||||
setExistingCredentials(data);
|
||||
onSettled(data) {
|
||||
const credentialsCount = data?.credentials.length || 0
|
||||
setShowDisconnectIntegration(data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0);
|
||||
setExistingCredentials(data?.credentials.map(credential => credential.id) || []);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -85,7 +105,7 @@ const Component = ({
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasDescriptionItems && (
|
||||
<div className="align-center bg-subtle mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
<div className="align-center bg-subtle -ml-4 -mr-4 mb-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap p-4 md:-ml-8 md:-mr-8 md:mb-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
{descriptionItems ? (
|
||||
descriptionItems.map((descriptionItem, index) =>
|
||||
typeof descriptionItem === "object" ? (
|
||||
@@ -141,7 +161,7 @@ const Component = ({
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
{!appCredentials.isLoading ? (
|
||||
{!appDbQuery.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
@@ -166,28 +186,19 @@ const Component = ({
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
StartIcon={Plus}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base"
|
||||
data-testid="install-app-button">
|
||||
{t("install_another")}
|
||||
</Button>
|
||||
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{ type, variant, slug }} multiInstall {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
) : showDisconnectIntegration ? (
|
||||
<DisconnectIntegration
|
||||
buttonProps={{ color: "secondary" }}
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
appCredentials.refetch();
|
||||
appDbQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -206,15 +217,7 @@ const Component = ({
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{type, variant, slug}} credentials={appDbQuery.data?.credentials} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -260,7 +263,7 @@ const Component = ({
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h4 className="text-emphasis mt-8 mb-2 font-semibold ">{t("contact")}</h4>
|
||||
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
@@ -269,7 +272,7 @@ const Component = ({
|
||||
rel="noreferrer"
|
||||
className="text-emphasis text-sm font-normal no-underline hover:underline"
|
||||
href={docs}>
|
||||
<BookOpen className="text-subtle mr-1 -mt-1 inline h-4 w-4" />
|
||||
<BookOpen className="text-subtle -mt-1 mr-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -281,7 +284,7 @@ const Component = ({
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={website}>
|
||||
<ExternalLink className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
<ExternalLink className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -293,7 +296,7 @@ const Component = ({
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<Mail className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
<Mail className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
@@ -306,7 +309,7 @@ const Component = ({
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={tos}>
|
||||
<File className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
<File className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -318,7 +321,7 @@ const Component = ({
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Shield className="text-subtle mr-1 -mt-px inline h-4 w-4" />
|
||||
<Shield className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -385,3 +388,107 @@ export default function App(props: {
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const InstallAppButtonChild = ({
|
||||
userAdminTeams,
|
||||
addAppMutationInput,
|
||||
appCategories,
|
||||
multiInstall,
|
||||
credentials,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
addAppMutationInput: { type: App["type"]; variant: string; slug: string };
|
||||
appCategories: string[];
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (!userAdminTeams?.length || appCategories.some((category) => category === "calendar")) {
|
||||
return <Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
onInteractOutside={(event) => {
|
||||
if (mutation.isLoading) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{mutation.isLoading && (
|
||||
<div className="z-1 fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
|
||||
{userAdminTeams.map((team) => {
|
||||
|
||||
const isInstalled = credentials &&
|
||||
credentials.some((credential) =>
|
||||
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||
key={team.id}
|
||||
disabled={
|
||||
isInstalled
|
||||
}
|
||||
StartIcon={(props) => (
|
||||
<Avatar
|
||||
alt={team.logo || ""}
|
||||
imageSrc={team.logo || `${CAL_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
mutation.mutate(
|
||||
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
|
||||
);
|
||||
}}>
|
||||
<p>{team.name}{" "}
|
||||
{isInstalled &&
|
||||
`(${t("installed")})`}</p>
|
||||
</DropdownItem>
|
||||
)})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,8 +28,8 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
{emptyStore ? (
|
||||
<EmptyScreen
|
||||
Icon={AlertCircle}
|
||||
headline={t("no_apps")}
|
||||
description={isAdmin ? "You can enable apps in the settings" : ""}
|
||||
headline={isAdmin ? t("no_apps") : t("no_apps_configured")}
|
||||
description={isAdmin ? t("enable_in_settings") : t("please_contact_admin")}
|
||||
buttonText={isAdmin ? t("apps_settings") : ""}
|
||||
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
||||
/>
|
||||
|
||||
@@ -1,66 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||
import type {
|
||||
DefaultEventLocationType,
|
||||
EventLocationTypeFromApp,
|
||||
LocationObject,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, getTranslatedLocation } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import { Link } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { Props } from "./pages/AvailabilityPage";
|
||||
|
||||
const excludeNullValues = (value: unknown) => !!value;
|
||||
|
||||
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
||||
function RenderIcon({
|
||||
eventLocationType,
|
||||
isTooltip,
|
||||
}: {
|
||||
eventLocationType: DefaultEventLocationType | EventLocationTypeFromApp;
|
||||
isTooltip: boolean;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"me-[10px] h-4 w-4 opacity-70 dark:opacity-100",
|
||||
!eventLocationType.iconUrl?.startsWith("/app-store") ? "dark:invert-[.65]" : "",
|
||||
!eventLocationType.iconUrl?.startsWith("/app-store") && isTooltip && "invert"
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderLocationTooltip({ locations }: { locations: LocationObject[] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const renderLocations = locations.map((location, index) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
return (
|
||||
<div className="my-2 me-2 flex w-full flex-col space-y-3 break-words">
|
||||
<p>{t("select_on_next_step")}</p>
|
||||
{locations.map(
|
||||
(
|
||||
location: Pick<Partial<LocationObject>, "link" | "address"> &
|
||||
Omit<LocationObject, "link" | "address">,
|
||||
index: number
|
||||
) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
return null;
|
||||
}
|
||||
const translatedLocation = getTranslatedLocation(location, eventLocationType, t);
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="font-sm flex flex-row items-center">
|
||||
<RenderIcon eventLocationType={eventLocationType} isTooltip />
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvailableEventLocations({ locations }: { locations: LocationObject[] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const renderLocations = locations.map(
|
||||
(
|
||||
location: Pick<Partial<LocationObject>, "link" | "address"> & Omit<LocationObject, "link" | "address">,
|
||||
index: number
|
||||
) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
if (eventLocationType.variable === "hostDefault") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translatedLocation = getTranslatedLocation(location, eventLocationType, t);
|
||||
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Link className="text-default ml-[2px] h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
) : (
|
||||
<RenderIcon eventLocationType={eventLocationType} isTooltip={false} />
|
||||
)}
|
||||
<Tooltip content={translatedLocation}>
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (eventLocationType.variable === "hostDefault") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translateAbleKeys = [
|
||||
"in_person_attendee_address",
|
||||
"in_person",
|
||||
"attendee_phone_number",
|
||||
"link_meeting",
|
||||
"organizer_phone_number",
|
||||
];
|
||||
|
||||
const locationKey = z.string().default("").parse(locationKeyToString(location));
|
||||
const translatedLocation = location.type.startsWith("integrations:")
|
||||
? eventLocationType.label
|
||||
: translateAbleKeys.includes(locationKey)
|
||||
? t(locationKey)
|
||||
: locationKey;
|
||||
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Link className="text-default ml-[2px] h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.startsWith("/app-store") ? "dark:invert-[.65]" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={translatedLocation}>
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
|
||||
const filteredLocations = renderLocations.filter(excludeNullValues) as JSX.Element[];
|
||||
return filteredLocations.length ? (
|
||||
|
||||
return filteredLocations.length > 1 ? (
|
||||
<div className="flex flex-row items-center text-sm font-medium">
|
||||
<img
|
||||
src="/map-pin.svg"
|
||||
className={classNames("me-[10px] h-4 w-4 opacity-70 dark:invert")}
|
||||
alt="map-pin"
|
||||
/>
|
||||
<Tooltip content={<RenderLocationTooltip locations={locations} />}>
|
||||
<p className="line-clamp-1">
|
||||
{t("location_options", {
|
||||
locationCount: filteredLocations.length,
|
||||
})}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : filteredLocations.length === 1 ? (
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm">
|
||||
{filteredLocations}
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
);
|
||||
|
||||
const reserveSlot = (slot: Slot) => {
|
||||
// Prevent double clicking
|
||||
if (reserveSlotMutation.isLoading || reserveSlotMutation.isSuccess) {
|
||||
return;
|
||||
}
|
||||
reserveSlotMutation.mutate({
|
||||
slotUtcStartDate: slot.time,
|
||||
eventTypeId,
|
||||
@@ -132,8 +136,20 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
let slotFull, notEnoughSeats;
|
||||
|
||||
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot) {
|
||||
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
|
||||
}
|
||||
|
||||
const isHalfFull =
|
||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
||||
const isNearlyFull =
|
||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
||||
|
||||
const colorClass = isNearlyFull
|
||||
? "text-rose-600"
|
||||
: isHalfFull
|
||||
? "text-yellow-500"
|
||||
: "text-emerald-400";
|
||||
|
||||
return (
|
||||
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||
@@ -144,7 +160,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
className={classNames(
|
||||
"text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}>
|
||||
)}
|
||||
data-testid="time"
|
||||
data-disabled="true">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{notEnoughSeats ? (
|
||||
<p className="text-sm">{t("not_enough_seats")}</p>
|
||||
@@ -165,14 +183,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
data-disabled="false">
|
||||
{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`}>
|
||||
<p className={`${colorClass} text-sm`}>
|
||||
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
||||
{seatsPerTimeSlot}{" "}
|
||||
{t("seats_available", {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Badge } from "@calcom/ui";
|
||||
import { CheckSquare, Clock } from "@calcom/ui/components/icon";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../pages/[user]/[type]";
|
||||
import type { BookPageProps } from "../../pages/[user]/book";
|
||||
import type { DynamicAvailabilityPageProps } from "../../pages/d/[link]/[slug]";
|
||||
import type { HashLinkPageProps } from "../../pages/d/[link]/book";
|
||||
import type { AvailabilityTeamPageProps } from "../../pages/team/[slug]/[type]";
|
||||
import type { TeamBookingPageProps } from "../../pages/team/[slug]/book";
|
||||
import { AvailableEventLocations } from "./AvailableEventLocations";
|
||||
|
||||
interface Props {
|
||||
profile:
|
||||
| AvailabilityPageProps["profile"]
|
||||
| HashLinkPageProps["profile"]
|
||||
| TeamBookingPageProps["profile"]
|
||||
| BookPageProps["profile"]
|
||||
| AvailabilityTeamPageProps["profile"]
|
||||
| DynamicAvailabilityPageProps["profile"];
|
||||
eventType:
|
||||
| AvailabilityPageProps["eventType"]
|
||||
| HashLinkPageProps["eventType"]
|
||||
| TeamBookingPageProps["eventType"]
|
||||
| BookPageProps["eventType"]
|
||||
| AvailabilityTeamPageProps["eventType"]
|
||||
| DynamicAvailabilityPageProps["eventType"];
|
||||
isBookingPage?: boolean;
|
||||
children: ReactNode;
|
||||
isMobile?: boolean;
|
||||
rescheduleUid?: string;
|
||||
}
|
||||
|
||||
const BookingDescription: FC<Props> = (props) => {
|
||||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { date: bookingDate } = useRouterQuery("date");
|
||||
const { t } = useLocale();
|
||||
const { duration, setQuery: setDuration } = useRouterQuery("duration");
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!duration ||
|
||||
isNaN(Number(duration)) ||
|
||||
(eventType.metadata?.multipleDuration &&
|
||||
!eventType.metadata?.multipleDuration.includes(Number(duration)))
|
||||
) {
|
||||
setDuration(eventType.length);
|
||||
}
|
||||
}, [duration, setDuration, eventType.length, eventType.metadata?.multipleDuration]);
|
||||
|
||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||
let requiresConfirmationText = t("requires_confirmation");
|
||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||
if (rcThreshold) {
|
||||
if (isBookingPage) {
|
||||
if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
||||
requiresConfirmation = false;
|
||||
}
|
||||
} else {
|
||||
requiresConfirmationText = t("requires_confirmation_threshold", {
|
||||
...rcThreshold,
|
||||
unit: rcThreshold.unit.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size="sm"
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="text-default mt-1 mb-2 break-words text-sm font-medium ">{profile.name}</h2>
|
||||
<h1 className="font-cal text-emphasis mb-6 break-words text-2xl font-semibold leading-none">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className=" text-default flex flex-col space-y-4 text-sm font-medium">
|
||||
{eventType?.description && (
|
||||
<div
|
||||
className={classNames(
|
||||
"scroll-bar scrollbar-track-w-20 -mx-5 flex max-h-[180px] overflow-y-scroll px-5 ",
|
||||
isBookingPage && "text-default text-sm font-medium"
|
||||
)}>
|
||||
{/* TODO: Fix colors when token is introdcued to DS */}
|
||||
<div className="max-w-full flex-shrink break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requiresConfirmation && (
|
||||
<div className={classNames("items-top flex", isBookingPage && "text-default text-sm font-medium")}>
|
||||
<div>
|
||||
<CheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
</div>
|
||||
{requiresConfirmationText}
|
||||
</div>
|
||||
)}
|
||||
<AvailableEventLocations
|
||||
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap text-sm font-medium",
|
||||
isBookingPage && "text-default",
|
||||
!eventType.metadata?.multipleDuration && "items-center"
|
||||
)}>
|
||||
<Clock
|
||||
className={classNames(
|
||||
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "mt-[2px]"
|
||||
)}
|
||||
/>
|
||||
{eventType.metadata?.multipleDuration !== undefined ? (
|
||||
!isBookingPage ? (
|
||||
<ul className="-mt-1 flex flex-wrap gap-1">
|
||||
{eventType.metadata.multipleDuration.map((dur, idx) => (
|
||||
<li key={idx}>
|
||||
<Badge
|
||||
variant="gray"
|
||||
className={classNames(
|
||||
duration === dur.toString()
|
||||
? "bg-emphasis border-emphasis text-emphasis "
|
||||
: "bg-subtle text-default border-transparent ",
|
||||
"cursor-pointer border"
|
||||
)}
|
||||
onClick={() => {
|
||||
setDuration(dur);
|
||||
}}>
|
||||
{dur} {t("minute_timeUnit")}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
`${duration} ${t("minutes")}`
|
||||
)
|
||||
) : (
|
||||
`${eventType.length} ${t("minutes")}`
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDescription;
|
||||
@@ -19,7 +19,7 @@ const BookingDescriptionPayment = (props: {
|
||||
|
||||
return (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<CreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<CreditCard className="-mt-1 ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>{props.t("no_show_fee_amount", params)}</>
|
||||
) : (
|
||||
|
||||
@@ -10,6 +10,7 @@ import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -49,6 +50,8 @@ type BookingItemProps = BookingItem & {
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const bookerUrl = useBookerUrl();
|
||||
|
||||
const user = query.data;
|
||||
const {
|
||||
t,
|
||||
@@ -170,7 +173,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
id: "reschedule",
|
||||
icon: Clock,
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}${
|
||||
href: `${bookerUrl}/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
}`,
|
||||
},
|
||||
@@ -374,7 +377,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</td>
|
||||
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pt-4 pb-2 sm:hidden">
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
@@ -423,7 +426,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<span> </span>
|
||||
|
||||
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
|
||||
<Badge className="ms-2 me-2 hidden sm:inline-flex" variant="orange">
|
||||
<Badge className="me-2 ms-2 hidden sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function CancelBooking(props: Props) {
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mt-2 mb-4 w-full "
|
||||
className="mb-4 mt-2 w-full "
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
@@ -93,7 +93,7 @@ export default function CancelBooking(props: Props) {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
|
||||
@@ -25,7 +25,7 @@ function SkeletonItem() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText className="h-6 w-16" />
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedStyles,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { HeadSeo, NumberInput, useCalcomTheme } from "@calcom/ui";
|
||||
import { CreditCard, User, RefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
|
||||
import BookingDescription from "@components/booking/BookingDescription";
|
||||
import { SlotPicker } from "@components/booking/SlotPicker";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
|
||||
|
||||
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
|
||||
/*const SlotPicker = dynamic(() => import("../SlotPicker").then((mod) => mod.SlotPicker), {
|
||||
ssr: false,
|
||||
loading: () => <div className="mt-8 px-4 pb-4 sm:mt-0 sm:p-4 md:min-w-[300px] md:px-5 lg:min-w-[455px]" />,
|
||||
});*/
|
||||
const TimezoneDropdown = dynamic(() => import("../TimezoneDropdown").then((mod) => mod.TimezoneDropdown), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const dateQuerySchema = z.object({
|
||||
rescheduleUid: z.string().optional().default(""),
|
||||
date: z.string().optional().default(""),
|
||||
timeZone: z.string().optional().default(""),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor: string; darkBrandColor: string }) => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
};
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const query = dateQuerySchema.parse(router.query);
|
||||
const { rescheduleUid } = query;
|
||||
useTheme(profile.theme);
|
||||
useBrandColors({
|
||||
brandColor: profile.brandColor,
|
||||
darkBrandColor: profile.darkBrandColor,
|
||||
});
|
||||
const { t, i18n } = useLocale();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
const [timeZone, setTimeZone] = useState<string>();
|
||||
const [timeFormat, setTimeFormat] = useState<TimeFormat>(detectBrowserTimeFormat);
|
||||
|
||||
const onTimeFormatChange = (is24Hours: boolean) => {
|
||||
setTimeFormat(is24Hours ? TimeFormat.TWENTY_FOUR_HOUR : TimeFormat.TWELVE_HOUR);
|
||||
setIs24hClockInLocalStorage(is24Hours);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
/*
|
||||
const telemetry = useTelemetry();
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
telemetryEventTypes.embedView,
|
||||
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
);
|
||||
}
|
||||
}, [telemetry]); */
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
// get dynamic user list here
|
||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||
|
||||
const timezoneDropdown = useMemo(
|
||||
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
||||
[timeZone]
|
||||
);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
|
||||
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
|
||||
{
|
||||
seatReferenceUid: rescheduleUid,
|
||||
},
|
||||
{
|
||||
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||
meeting={{
|
||||
title: eventType.title,
|
||||
profile: { name: `${profile.name}`, image: profile.image },
|
||||
users: [
|
||||
...(eventType.users || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
}}
|
||||
nextSeoProps={{
|
||||
nofollow: eventType.hidden,
|
||||
noindex: eventType.hidden,
|
||||
}}
|
||||
isBrandingHidden={restProps.isBrandingHidden}
|
||||
/>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<div>
|
||||
<main
|
||||
className={classNames(
|
||||
"flex flex-col md:mx-4",
|
||||
shouldAlignCentrally ? "items-center" : "items-start",
|
||||
!isEmbed && classNames("bg-subtle dark:bg-default mx-auto my-0 ease-in-out md:my-24")
|
||||
)}>
|
||||
<div>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-default dark:bg-muted pb-4 md:pb-0",
|
||||
"border-booker md:border-booker-width md:rounded-md",
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="md:flex">
|
||||
{showEventTypeDetails && (
|
||||
<div
|
||||
className={classNames(
|
||||
" border-subtle flex flex-col p-5 sm:border-r",
|
||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap items-center text-sm font-medium",
|
||||
" text-default",
|
||||
"ltr:mr-[10px] rtl:ml-[10px]"
|
||||
)}>
|
||||
<User
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
"mt-[2px]"
|
||||
)}
|
||||
/>{" "}
|
||||
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
|
||||
</div>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<RefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
|
||||
<NumberInput
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
isFullWidth={false}
|
||||
className="me-2 inline w-16"
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<CreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>
|
||||
{t("no_show_fee_amount", {
|
||||
amount: paymentAppData.price / 100.0,
|
||||
formatParams: { amount: { currency: paymentAppData.currency } },
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: paymentAppData.currency,
|
||||
}).format(paymentAppData.price / 100)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</BookingDescription>
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-default"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-subtle line-through ">
|
||||
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-subtle" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
onTimeFormatChange={onTimeFormatChange}
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
bookingAttendees={bookingAttendees || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* FIXME: We don't show branding in Embed yet because we need to place branding on top of the main content. Keeping it outside the main content would have visibility issues because outside main content background is transparent */}
|
||||
{!restProps.isBrandingHidden && !isEmbed && <PoweredBy />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailabilityPage;
|
||||
@@ -1,704 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { createBooking, createRecurringBooking } from "@calcom/features/bookings/lib";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import {
|
||||
getBookingFieldsWithSystemFields,
|
||||
SystemField,
|
||||
} from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import getBookingResponsesSchema, {
|
||||
getBookingResponsesPartialSchema,
|
||||
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, MINUTES_TO_BOOK } from "@calcom/lib/constants";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { parseDate, parseDateTimeWithTimeZone, parseRecurringDates } from "@calcom/lib/parse-dates";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, Form, Tooltip, useCalcomTheme } from "@calcom/ui";
|
||||
import { AlertTriangle, Calendar, RefreshCw, User } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import BookingDescription from "@components/booking/BookingDescription";
|
||||
|
||||
import type { BookPageProps } from "../../../pages/[user]/book";
|
||||
import type { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import type { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
const BookingDescriptionPayment = dynamic(
|
||||
() => import("@components/booking/BookingDescriptionPayment")
|
||||
) as unknown as typeof import("@components/booking/BookingDescriptionPayment").default;
|
||||
|
||||
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor?: string; darkBrandColor?: string }) => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
};
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
|
||||
const BookingFields = ({
|
||||
fields,
|
||||
locations,
|
||||
rescheduleUid,
|
||||
isDynamicGroupBooking,
|
||||
}: {
|
||||
fields: BookingPageProps["eventType"]["bookingFields"];
|
||||
locations: LocationObject[];
|
||||
rescheduleUid?: string;
|
||||
isDynamicGroupBooking: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { watch, setValue } = useFormContext();
|
||||
const locationResponse = watch("responses.location");
|
||||
const currentView = rescheduleUid ? "reschedule" : "";
|
||||
|
||||
return (
|
||||
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
|
||||
<div>
|
||||
{fields.map((field, index) => {
|
||||
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
|
||||
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
||||
let readOnly =
|
||||
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
||||
|
||||
let noLabel = false;
|
||||
let hidden = !!field.hidden;
|
||||
const fieldViews = field.views;
|
||||
|
||||
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.rescheduleReason) {
|
||||
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
||||
readOnly = false;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.smsReminderNumber) {
|
||||
// `smsReminderNumber` and location.optionValue when location.value===phone are the same data point. We should solve it in a better way in the Form Builder itself.
|
||||
// I think we should have a way to connect 2 fields together and have them share the same value in Form Builder
|
||||
if (locationResponse?.value === "phone") {
|
||||
setValue(`responses.${SystemField.Enum.smsReminderNumber}`, locationResponse?.optionValue);
|
||||
// Just don't render the field now, as the value is already connected to attendee phone location
|
||||
return null;
|
||||
}
|
||||
// `smsReminderNumber` can be edited during reschedule even though it's a system field
|
||||
readOnly = false;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.guests) {
|
||||
// No matter what user configured for Guests field, we don't show it for dynamic group booking as that doesn't support guests
|
||||
hidden = isDynamicGroupBooking ? true : !!field.hidden;
|
||||
}
|
||||
|
||||
// We don't show `notes` field during reschedule
|
||||
if (
|
||||
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
|
||||
!!rescheduleUid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dynamically populate location field options
|
||||
if (field.name === SystemField.Enum.location && field.type === "radioInput") {
|
||||
if (!field.optionsInputs) {
|
||||
throw new Error("radioInput must have optionsInputs");
|
||||
}
|
||||
const optionsInputs = field.optionsInputs;
|
||||
|
||||
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
|
||||
const options = getLocationOptionsForSelect(locations, t);
|
||||
options.forEach((option) => {
|
||||
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
|
||||
if (optionInput) {
|
||||
optionInput.placeholder = option.inputPlaceholder;
|
||||
}
|
||||
});
|
||||
field.options = options.filter(
|
||||
(location): location is NonNullable<(typeof options)[number]> => !!location
|
||||
);
|
||||
// If we have only one option and it has an input, we don't show the field label because Option name acts as label.
|
||||
// e.g. If it's just Attendee Phone Number option then we don't show `Location` label
|
||||
if (field.options.length === 1) {
|
||||
if (field.optionsInputs[field.options[0].value]) {
|
||||
noLabel = true;
|
||||
} else {
|
||||
// If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar
|
||||
hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const label = noLabel ? "" : field.label || t(field.defaultLabel || "");
|
||||
const placeholder = field.placeholder || t(field.defaultPlaceholder || "");
|
||||
|
||||
return (
|
||||
<FormBuilderField
|
||||
className="mb-4"
|
||||
field={{ ...field, label, placeholder, hidden }}
|
||||
readOnly={readOnly}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const routerQuerySchema = z
|
||||
.object({
|
||||
timeFormat: z.nativeEnum(TimeFormat),
|
||||
rescheduleUid: z.string().optional(),
|
||||
date: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((date) => {
|
||||
if (date === undefined) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const BookingPage = ({
|
||||
eventType,
|
||||
booking,
|
||||
currentSlotBooking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
...restProps
|
||||
}: BookingPageProps) => {
|
||||
const removeSelectedSlotMarkMutation = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation();
|
||||
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
|
||||
const { t, i18n } = useLocale();
|
||||
const { duration: queryDuration } = useRouterQuery("duration");
|
||||
const { date: queryDate } = useRouterQuery("date");
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { timezone } = useTimePreferences();
|
||||
|
||||
const reserveSlot = () => {
|
||||
if (queryDuration) {
|
||||
reserveSlotMutation.mutate({
|
||||
eventTypeId: eventType.id,
|
||||
slotUtcStartDate: dayjs(queryDate).utc().format(),
|
||||
slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(),
|
||||
bookingUid: currentSlotBooking?.uid,
|
||||
});
|
||||
}
|
||||
};
|
||||
// Define duration now that we support multiple duration eventTypes
|
||||
let duration = eventType.length;
|
||||
if (
|
||||
queryDuration &&
|
||||
!isNaN(Number(queryDuration)) &&
|
||||
eventType.metadata?.multipleDuration &&
|
||||
eventType.metadata?.multipleDuration.includes(Number(queryDuration))
|
||||
) {
|
||||
duration = Number(queryDuration);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
/* if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
telemetryEventTypes.embedView,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
);
|
||||
} */
|
||||
reserveSlot();
|
||||
const interval = setInterval(reserveSlot, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
removeSelectedSlotMarkMutation.mutate();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { uid } = responseData;
|
||||
|
||||
if ("paymentUid" in responseData && !!responseData.paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
paymentUid: responseData.paymentUid,
|
||||
date,
|
||||
name: bookingForm.getValues("responses.name"),
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
absolute: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
|
||||
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||
};
|
||||
|
||||
return bookingSuccessRedirect({
|
||||
router,
|
||||
successRedirectUrl: eventType.successRedirectUrl,
|
||||
query,
|
||||
bookingUid: uid,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const recurringMutation = useMutation(createRecurringBooking, {
|
||||
onSuccess: async (responseData = []) => {
|
||||
const { uid } = responseData[0] || {};
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
formerTime: booking?.startTime.toString(),
|
||||
};
|
||||
return bookingSuccessRedirect({
|
||||
router,
|
||||
successRedirectUrl: eventType.successRedirectUrl,
|
||||
query,
|
||||
bookingUid: uid,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: { timeFormat, rescheduleUid, date },
|
||||
} = useTypedQuery(routerQuerySchema);
|
||||
|
||||
useTheme(profile.theme);
|
||||
useBrandColors({
|
||||
brandColor: profile.brandColor,
|
||||
darkBrandColor: profile.darkBrandColor,
|
||||
});
|
||||
|
||||
const querySchema = getBookingResponsesPartialSchema({
|
||||
eventType: {
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
},
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
|
||||
const parsedQuery = querySchema.parse({
|
||||
...router.query,
|
||||
// `guest` because we need to support legacy URL with `guest` query param support
|
||||
// `guests` because the `name` of the corresponding bookingField is `guests`
|
||||
guests: router.query.guests || router.query.guest,
|
||||
});
|
||||
|
||||
// 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: LocationObject[] = useMemo(
|
||||
() => (eventType.locations as LocationObject[]) || [],
|
||||
[eventType.locations]
|
||||
);
|
||||
|
||||
const [isClientTimezoneAvailable, setIsClientTimezoneAvailable] = useState(false);
|
||||
useEffect(() => {
|
||||
// THis is to fix hydration error that comes because of different timezone on server and client
|
||||
setIsClientTimezoneAvailable(true);
|
||||
}, []);
|
||||
|
||||
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
||||
|
||||
// There should only exists one default userData variable for primaryAttendee.
|
||||
const defaultUserValues = {
|
||||
email: rescheduleUid ? booking?.attendees[0].email : parsedQuery["email"],
|
||||
name: rescheduleUid ? booking?.attendees[0].name : parsedQuery["name"],
|
||||
};
|
||||
|
||||
const defaultValues = () => {
|
||||
if (!rescheduleUid) {
|
||||
const defaults = {
|
||||
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
|
||||
};
|
||||
|
||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||
return {
|
||||
...responses,
|
||||
[field.name]: parsedQuery[field.name],
|
||||
};
|
||||
}, {});
|
||||
defaults.responses = {
|
||||
...responses,
|
||||
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
||||
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
||||
};
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (!booking || !booking.attendees.length) {
|
||||
return {};
|
||||
}
|
||||
const primaryAttendee = booking.attendees[0];
|
||||
if (!primaryAttendee) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
|
||||
};
|
||||
|
||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||
return {
|
||||
...responses,
|
||||
[field.name]: booking.responses[field.name],
|
||||
};
|
||||
}, {});
|
||||
defaults.responses = {
|
||||
...responses,
|
||||
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
||||
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
||||
};
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
responses: getBookingResponsesSchema({
|
||||
eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type BookingFormValues = {
|
||||
locationType?: EventLocationType["type"];
|
||||
responses: z.infer<typeof bookingFormSchema>["responses"];
|
||||
};
|
||||
|
||||
const bookingForm = useForm<BookingFormValues>({
|
||||
defaultValues: defaultValues(),
|
||||
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||
});
|
||||
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [],
|
||||
recurringDates: Date[] = [];
|
||||
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
selectedTimeFormat: timeFormat,
|
||||
},
|
||||
i18n.language
|
||||
);
|
||||
}
|
||||
|
||||
const bookEvent = (bookingValues: 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.
|
||||
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
|
||||
|
||||
// @TODO: move to metadata
|
||||
const metadata = Object.keys(router.query)
|
||||
.filter((key) => key.startsWith("metadata"))
|
||||
.reduce(
|
||||
(metadata, key) => ({
|
||||
...metadata,
|
||||
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
if (recurringDates.length) {
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...bookingValues,
|
||||
start: dayjs(recurringDate).utc().format(),
|
||||
end: dayjs(recurringDate).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
// Added to track down the number of actual occurrences selected by the user
|
||||
recurringCount: recurringDates.length,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
metadata,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}));
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...bookingValues,
|
||||
start: dayjs(date).utc().format(),
|
||||
end: dayjs(date).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
bookingUid: (router.query.bookingUid as string) || booking?.uid,
|
||||
user: router.query.user,
|
||||
metadata,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
seatReferenceUid: router.query.seatReferenceUid as string,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid
|
||||
? t("booking_reschedule_confirmation", {
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})
|
||||
: t("booking_confirmation", {
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})}{" "}
|
||||
| {APP_NAME}
|
||||
</title>
|
||||
<link rel="icon" href="/favico.ico" />
|
||||
</Head>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "" : "sm:my-24",
|
||||
"my-0 max-w-3xl"
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"main",
|
||||
isBackgroundTransparent ? "" : "bg-default dark:bg-muted",
|
||||
"border-booker sm:border-booker-width rounded-md"
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:border-subtle text-default flex flex-col px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
<BookingDescriptionPayment eventType={eventType} t={t} i18n={i18n} />
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="text-default items-start text-sm font-medium">
|
||||
<RefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Calendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<div className="text-sm font-medium">
|
||||
{isClientTimezoneAvailable &&
|
||||
(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||
`${parseDate(date, i18n.language, { selectedTimeFormat: timeFormat })}`}
|
||||
{isClientTimezoneAvailable &&
|
||||
!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||
return <p key={key}>{timeFormatted}</p>;
|
||||
})}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className=" text-sm">
|
||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<Calendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{isClientTimezoneAvailable &&
|
||||
typeof booking.startTime === "string" &&
|
||||
parseDateTimeWithTimeZone(booking.startTime, i18n.language, timezone, {
|
||||
selectedTimeFormat: timeFormat,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<User
|
||||
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{currentSlotBooking
|
||||
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot}{" "}
|
||||
{t("seats_available", {
|
||||
count: currentSlotBooking
|
||||
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
|
||||
: eventType.seatsPerTimeSlot,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</BookingDescription>
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames("p-6", showEventTypeDetails ? "sm:w-1/2" : "w-full")}>
|
||||
<Form form={bookingForm} noValidate handleSubmit={bookEvent}>
|
||||
<BookingFields
|
||||
isDynamicGroupBooking={isDynamicGroupBooking}
|
||||
fields={eventType.bookingFields}
|
||||
locations={locations}
|
||||
rescheduleUid={rescheduleUid}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"flex justify-end space-x-2 rtl:space-x-reverse",
|
||||
// HACK: If the last field is guests, we need to move Cancel, Submit buttons up because "Add Guests" being present on the left and the buttons on the right, spacing is not required
|
||||
eventType.bookingFields[eventType.bookingFields.length - 1].name ===
|
||||
SystemField.Enum.guests
|
||||
? "-mt-4"
|
||||
: ""
|
||||
)}>
|
||||
<Button color="minimal" type="button" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
{mutation.isError || recurringMutation.isError ? (
|
||||
<ErrorMessage error={mutation.error || recurringMutation.error} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Toaster position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingPage;
|
||||
|
||||
function ErrorMessage({ error }: { error: unknown }) {
|
||||
const { t } = useLocale();
|
||||
const { query: { rescheduleUid } = {} } = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div data-testid="booking-fail" className="mt-2 border-l-4 border-blue-400 bg-blue-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ms-3">
|
||||
<p className="text-sm text-blue-700">
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||
{error instanceof HttpError || error instanceof Error ? (
|
||||
<>
|
||||
{t("can_you_try_again")}{" "}
|
||||
<span className="cursor-pointer underline" onClick={() => router.back()}>
|
||||
{t("go_back")}
|
||||
</span>
|
||||
.
|
||||
</> /* t(error.message) */
|
||||
) : (
|
||||
"Unknown error"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getHumanReadableLocationValue,
|
||||
getMessageForOrganizer,
|
||||
LocationType,
|
||||
OrganizerDefaultConferencingAppType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -33,12 +34,12 @@ interface ISetLocationDialog {
|
||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||
selection?: LocationOption;
|
||||
booking?: BookingItem;
|
||||
isTeamEvent?: boolean;
|
||||
defaultValues?: LocationObject[];
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
||||
setEditingLocationType?: (param: string) => void;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
const LocationInput = (props: {
|
||||
@@ -57,12 +58,15 @@ const LocationInput = (props: {
|
||||
<Input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={eventLocationType.variable}
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return <PhoneInput onChange={onChange} value={value} {...remainingProps} />;
|
||||
return <PhoneInput onChange={onChange} value={value} {...rest} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -75,19 +79,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
saveLocation,
|
||||
selection,
|
||||
booking,
|
||||
isTeamEvent,
|
||||
setShowLocationModal,
|
||||
isOpenDialog,
|
||||
defaultValues,
|
||||
setSelectedLocation,
|
||||
setEditingLocationType,
|
||||
teamId,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery();
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery({ teamId });
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
locationFormMethods.setValue("locationType", selection?.value);
|
||||
if (selection?.address) {
|
||||
locationFormMethods.setValue("locationAddress", selection?.address);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selection]);
|
||||
@@ -96,6 +103,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
locationType: z.string(),
|
||||
phone: z.string().optional().nullable(),
|
||||
locationAddress: z.string().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
locationLink: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -149,10 +157,21 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
name: "locationType",
|
||||
});
|
||||
|
||||
const selectedAddrValue = useWatch({
|
||||
control: locationFormMethods.control,
|
||||
name: "locationAddress",
|
||||
});
|
||||
|
||||
const eventLocationType = getEventLocationType(selectedLocation);
|
||||
|
||||
const defaultLocation = defaultValues?.find(
|
||||
(location: { type: EventLocationType["type"] }) => location.type === eventLocationType?.type
|
||||
(location: { type: EventLocationType["type"]; address?: string }) => {
|
||||
if (location.type === LocationType.InPerson) {
|
||||
return location.type === eventLocationType?.type && location.address === selectedAddrValue;
|
||||
} else {
|
||||
return location.type === eventLocationType?.type;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const LocationOptions = (() => {
|
||||
@@ -240,7 +259,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
|
||||
{booking && (
|
||||
<>
|
||||
<p className="text-emphasis mt-6 mb-2 ml-1 text-sm font-bold">{t("current_location")}:</p>
|
||||
<p className="text-emphasis mb-2 ml-1 mt-6 text-sm font-bold">{t("current_location")}:</p>
|
||||
<p className="text-emphasis mb-2 ml-1 text-sm">
|
||||
{getHumanReadableLocationValue(booking.location, t)}
|
||||
</p>
|
||||
@@ -278,6 +297,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (values.credentialId) {
|
||||
details = { ...details, credentialId: values.credentialId };
|
||||
}
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
@@ -292,8 +314,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
query={locationsQuery}
|
||||
success={({ data }) => {
|
||||
if (!data.length) return null;
|
||||
const locationOptions = [...data].filter((option) => {
|
||||
return !isTeamEvent ? option.label !== "Conferencing" : true;
|
||||
const locationOptions = [...data].map((option) => {
|
||||
if (teamId) {
|
||||
// Let host's Default conferencing App option show for Team Event
|
||||
return option;
|
||||
}
|
||||
return {
|
||||
...option,
|
||||
options: option.options.filter((o) => o.value !== OrganizerDefaultConferencingAppType),
|
||||
};
|
||||
});
|
||||
if (booking) {
|
||||
locationOptions.map((location) =>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("send_reschedule_request")} />
|
||||
<p className="text-subtle text-sm">{t("reschedule_modal_description")}</p>
|
||||
<p className="text-emphasis mt-6 mb-2 text-sm font-bold">
|
||||
<p className="text-emphasis mb-2 mt-6 text-sm font-bold">
|
||||
{t("reason_for_reschedule_request")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,7 @@ const ErrorDebugPanel: React.FC<{ error: Props["error"]; children?: never }> = (
|
||||
{debugMap.map(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
return (
|
||||
<div key={key} className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6">
|
||||
<div key={key} className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
||||
<dt className="text-emphasis text-sm font-bold">{key}</dt>
|
||||
<dd className="text-emphasis mt-1 text-sm sm:col-span-2 sm:mt-0">{value}</dd>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
backgroundImage: "url(/calendar-preview.svg)",
|
||||
}}>
|
||||
<div className="m-auto flex items-center justify-center self-stretch">
|
||||
<div className="bg-subtle mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black text-[12px] leading-3">
|
||||
<div className="bg-subtle ml-11 mt-3 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black text-[12px] leading-3">
|
||||
<p className="text-emphasis overflow-hidden text-ellipsis p-1.5 font-medium">{previewText}</p>
|
||||
<p className="text-default ml-1.5 text-[10px] font-normal">8 - 10 AM</p>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema"
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -38,6 +40,7 @@ const generateHashedLink = (id: number) => {
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -49,6 +52,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
|
||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
||||
const selectedThemeIsDark =
|
||||
user?.theme === "dark" ||
|
||||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
|
||||
|
||||
eventType.bookingFields.forEach(({ name }) => {
|
||||
bookingFields[name] = name + " input";
|
||||
@@ -81,6 +87,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
return {
|
||||
...field,
|
||||
hidden: !enabled,
|
||||
editable: (!enabled ? "system-but-hidden" : "system-but-optional") as z.infer<typeof EditableSchema>
|
||||
};
|
||||
}
|
||||
return field;
|
||||
@@ -153,7 +160,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 !py-0 hover:bg-transparent"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -162,7 +169,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</div>
|
||||
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
|
||||
<div>
|
||||
<BookerLayoutSelector fallbackToUserSettings />
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||
</div>
|
||||
<hr className="border-subtle" />
|
||||
<FormBuilder
|
||||
@@ -216,7 +223,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
onChange(e ? value : "");
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
||||
<div className="lg:-ml-2 lg:-mb-2">
|
||||
<div className="lg:-mb-2 lg:-ml-2">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label={t("redirect_success_booking")}
|
||||
@@ -268,7 +275,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 !py-0 hover:bg-transparent"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
aria-label="copy link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
|
||||
@@ -16,12 +16,16 @@ export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||
|
||||
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
const { t } = useLocale();
|
||||
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
|
||||
const { data: eventTypeApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||
extendsFeature: "EventType",
|
||||
teamId: eventType.team?.id || eventType.parent?.teamId,
|
||||
});
|
||||
|
||||
const methods = useFormContext<FormValues>();
|
||||
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
||||
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
||||
const installedApps =
|
||||
eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || [];
|
||||
const notInstalledApps =
|
||||
eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || [];
|
||||
const allAppsData = methods.watch("metadata")?.apps || {};
|
||||
|
||||
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
||||
@@ -62,11 +66,54 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
const appsWithTeamCredentials = eventTypeApps?.items.filter((app) => app.teams.length) || [];
|
||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||
const appCards = [];
|
||||
|
||||
if (app.userCredentialIds.length) {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
for (const team of app.teams) {
|
||||
if (team) {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug + team?.credentialId}
|
||||
app={{
|
||||
...app,
|
||||
// credentialIds: team?.credentialId ? [team.credentialId] : [],
|
||||
credentialOwner: {
|
||||
name: team.name,
|
||||
avatar: team.logo,
|
||||
teamId: team.teamId,
|
||||
credentialId: team.credentialId,
|
||||
},
|
||||
}}
|
||||
eventType={eventType}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return appCards;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="before:border-0">
|
||||
{!installedApps?.length && isManagedEventType && (
|
||||
{isManagedEventType && (
|
||||
<Alert
|
||||
severity="neutral"
|
||||
className="mb-2"
|
||||
@@ -92,15 +139,20 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{installedApps?.map((app) => (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
/>
|
||||
))}
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{installedApps.map((app) => {
|
||||
if (!app.teams.length)
|
||||
return (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!shouldLockDisableProps("apps").disabled && (
|
||||
|
||||
@@ -171,7 +171,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4 lg:space-y-8">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
{t("before_event")}
|
||||
@@ -245,7 +245,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">
|
||||
{t("minimum_booking_notice")}
|
||||
@@ -416,7 +416,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="ms-2 me-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
@@ -437,7 +437,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? <span className="ms-2 me-2"> {period.suffix}</span> : null}
|
||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -117,8 +117,16 @@ export const EventSetupTab = (
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
||||
|
||||
const locationOptions = props.locationOptions.filter((option) => {
|
||||
return !team ? option.label !== "Conferencing" : true;
|
||||
const locationOptions = props.locationOptions.map((locationOption) => {
|
||||
const options = locationOption.options.filter((option) => {
|
||||
// Skip "Organizer's Default App" for non-team members
|
||||
return !team ? option.label !== t("organizer_default_conferencing_app") : true;
|
||||
});
|
||||
|
||||
return {
|
||||
...locationOption,
|
||||
options,
|
||||
};
|
||||
});
|
||||
|
||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||
@@ -136,9 +144,17 @@ export const EventSetupTab = (
|
||||
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
|
||||
);
|
||||
|
||||
const openLocationModal = (type: EventLocationType["type"]) => {
|
||||
const openLocationModal = (type: EventLocationType["type"], address = "") => {
|
||||
const option = getLocationFromType(type, locationOptions);
|
||||
setSelectedLocation(option);
|
||||
if (option && option.value === LocationType.InPerson) {
|
||||
const inPersonOption = {
|
||||
...option,
|
||||
address,
|
||||
};
|
||||
setSelectedLocation(inPersonOption);
|
||||
} else {
|
||||
setSelectedLocation(option);
|
||||
}
|
||||
setShowLocationModal(true);
|
||||
};
|
||||
|
||||
@@ -276,7 +292,7 @@ export const EventSetupTab = (
|
||||
return (
|
||||
<li
|
||||
key={`${location.type}${index}`}
|
||||
className="border-default text-default mb-2 h-9 rounded-md border py-1.5 px-2 hover:cursor-pointer">
|
||||
className="border-default text-default mb-2 h-9 rounded-md border px-2 py-1.5 hover:cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
@@ -290,7 +306,7 @@ export const EventSetupTab = (
|
||||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="line-clamp-1 ms-1 text-sm">{eventLabel}</span>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{eventLabel}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
@@ -298,10 +314,14 @@ export const EventSetupTab = (
|
||||
onClick={() => {
|
||||
locationFormMethods.setValue("locationType", location.type);
|
||||
locationFormMethods.unregister("locationLink");
|
||||
locationFormMethods.unregister("locationAddress");
|
||||
if (location.type === LocationType.InPerson) {
|
||||
locationFormMethods.setValue("locationAddress", location.address);
|
||||
} else {
|
||||
locationFormMethods.unregister("locationAddress");
|
||||
}
|
||||
locationFormMethods.unregister("locationPhoneNumber");
|
||||
setEditingLocationType(location.type);
|
||||
openLocationModal(location.type);
|
||||
openLocationModal(location.type, location.address);
|
||||
}}
|
||||
aria-label={t("edit")}
|
||||
className="hover:text-emphasis text-subtle mr-1 p-1">
|
||||
@@ -320,7 +340,7 @@ export const EventSetupTab = (
|
||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="text-default flex text-sm">
|
||||
<Check className="mt-0.5 mr-1.5 h-2 w-2.5" />
|
||||
<Check className="mr-1.5 mt-0.5 h-2 w-2.5" />
|
||||
<Trans i18nKey="event_type_requres_google_cal">
|
||||
<p>
|
||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||
@@ -510,18 +530,29 @@ export const EventSetupTab = (
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isTeamEvent={!!team}
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? { value: selectedLocation.value, label: t(selectedLocation.label), icon: selectedLocation.icon }
|
||||
? selectedLocation.address
|
||||
? {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
address: selectedLocation.address,
|
||||
}
|
||||
: {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
teamId={eventType.team?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMemo, useState, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -210,7 +211,7 @@ function EventTypeSingleLayout({
|
||||
}
|
||||
if (isManagedEventType || isChildrenManagedEventType) {
|
||||
// Removing apps and workflows for manageg event types by admins v1
|
||||
navigation.splice(-2, 1);
|
||||
navigation.splice(0, 1);
|
||||
} else {
|
||||
navigation.push({
|
||||
name: "webhooks",
|
||||
@@ -233,9 +234,11 @@ function EventTypeSingleLayout({
|
||||
formMethods,
|
||||
]);
|
||||
|
||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
}`;
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
const isOrgEvent = orgBranding?.fullDomain;
|
||||
const permalink = `${orgBranding?.fullDomain ?? CAL_URL}/${
|
||||
team ? `${!isOrgEvent ? "team/" : ""}${team.slug}` : eventType.users[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
|
||||
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
||||
@@ -369,7 +372,7 @@ function EventTypeSingleLayout({
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between py-2 px-4">
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Trans } from "next-i18next";
|
||||
|
||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ConfirmationDialogContent, Dialog } from "@calcom/ui";
|
||||
|
||||
interface ManagedEventDialogProps {
|
||||
slugExistsChildrenDialogOpen: ChildrenEventType[];
|
||||
slug: string;
|
||||
onOpenChange: () => void;
|
||||
isLoading: boolean;
|
||||
onConfirm: (e: { preventDefault: () => void }) => void;
|
||||
}
|
||||
|
||||
export default function ManagedEventDialog(props: ManagedEventDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const { slugExistsChildrenDialogOpen, slug, onOpenChange, isLoading, onConfirm } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={slugExistsChildrenDialogOpen.length > 0} onOpenChange={onOpenChange}>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={isLoading}
|
||||
variety="warning"
|
||||
title={t("managed_event_dialog_title", {
|
||||
slug,
|
||||
count: slugExistsChildrenDialogOpen.length,
|
||||
})}
|
||||
confirmBtnText={t("managed_event_dialog_confirm_button", {
|
||||
count: slugExistsChildrenDialogOpen.length,
|
||||
})}
|
||||
cancelBtnText={t("go_back")}
|
||||
onConfirm={onConfirm}>
|
||||
<p className="mt-5">
|
||||
<Trans
|
||||
i18nKey="managed_event_dialog_information"
|
||||
values={{
|
||||
names: `${slugExistsChildrenDialogOpen
|
||||
.map((ch) => ch.owner.name)
|
||||
.slice(0, -1)
|
||||
.join(", ")} ${
|
||||
slugExistsChildrenDialogOpen.length > 1 ? t("and") : ""
|
||||
} ${slugExistsChildrenDialogOpen.map((ch) => ch.owner.name).slice(-1)}`,
|
||||
slug,
|
||||
}}
|
||||
count={slugExistsChildrenDialogOpen.length}
|
||||
/>
|
||||
</p>{" "}
|
||||
<p className="mt-5">{t("managed_event_dialog_clarification")}</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -32,11 +32,11 @@ function SkeletonItem() {
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Clock className="text-subtle mt-0.5 mr-1.5 inline h-4 w-4" />
|
||||
<Clock className="text-subtle mr-1.5 mt-0.5 inline h-4 w-4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<User className="text-subtle mt-0.5 mr-1.5 inline h-4 w-4" />
|
||||
<User className="text-subtle mr-1.5 mt-0.5 inline h-4 w-4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -28,6 +28,7 @@ const AppConnectionItem = (props: IAppConnectionItem) => {
|
||||
color="secondary"
|
||||
disabled={installed}
|
||||
type="button"
|
||||
loading={buttonProps?.isLoading}
|
||||
onClick={(event) => {
|
||||
// Save cookie key to return url step
|
||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||
|
||||
@@ -17,7 +17,11 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||
const { nextStep } = props;
|
||||
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
|
||||
const { t } = useLocale();
|
||||
const queryIntegrations = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||
const queryIntegrations = trpc.viewer.integrations.useQuery({
|
||||
variant: "calendar",
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
});
|
||||
|
||||
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
|
||||
(item) => item.calendars && item.calendars?.length > 0
|
||||
|
||||
@@ -16,11 +16,12 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { data: queryConnectedVideoApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
});
|
||||
const { t } = useLocale();
|
||||
|
||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||
(item) => item.credentialIds.length > 0
|
||||
(item) => item.userCredentialIds.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -38,7 +39,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
logo={item.logo}
|
||||
installed={item.credentialIds.length > 0}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -22,15 +22,13 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
const { nextStep } = props;
|
||||
|
||||
const router = useRouter();
|
||||
let queryAvailability;
|
||||
if (defaultScheduleId) {
|
||||
queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId },
|
||||
{
|
||||
enabled: router.isReady,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId! },
|
||||
{
|
||||
enabled: router.isReady && !!defaultScheduleId,
|
||||
}
|
||||
);
|
||||
|
||||
const availabilityForm = useForm({
|
||||
defaultValues: {
|
||||
|
||||
@@ -8,21 +8,15 @@ import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
interface IUserProfileProps {
|
||||
user: IOnboardingPageProps["user"];
|
||||
}
|
||||
|
||||
const UserProfile = (props: IUserProfileProps) => {
|
||||
const { user } = props;
|
||||
const UserProfile = () => {
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||
|
||||
@@ -13,15 +13,13 @@ import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||
|
||||
interface IUserSettingsProps {
|
||||
user: IOnboardingPageProps["user"];
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { user, nextStep } = props;
|
||||
const { nextStep } = props;
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
||||
const telemetry = useTelemetry();
|
||||
@@ -69,7 +67,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-6">
|
||||
{/* Username textfield */}
|
||||
<UsernameAvailabilityField user={user} />
|
||||
<UsernameAvailabilityField />
|
||||
|
||||
{/* Full name textfield */}
|
||||
<div className="w-full">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -75,14 +75,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
{...form.register("password")}
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
<Label className="mt-4"> {t("2fa_code")}</Label>
|
||||
|
||||
<TwoFactor center={false} />
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="ms-2 me-2" disabled={isDisabling}>
|
||||
<Button type="submit" className="me-2 ms-2" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
|
||||
@@ -19,7 +19,7 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between pt-9 pl-2 sm:flex-row">
|
||||
<div className="flex flex-col justify-between pl-2 pt-9 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { BaseSyntheticEvent } from "react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form } from "@calcom/ui";
|
||||
|
||||
@@ -127,6 +128,17 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnableRef = useCallbackRef(handleEnable);
|
||||
|
||||
const totpCode = form.watch("totpCode");
|
||||
|
||||
// auto submit 2FA if all inputs have a value
|
||||
useEffect(() => {
|
||||
if (totpCode?.trim().length === 6) {
|
||||
form.handleSubmit(handleEnableRef.current)();
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent>
|
||||
@@ -178,19 +190,19 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ms-2 me-2"
|
||||
className="me-2 ms-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button type="submit" className="ms-2 me-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
<Button type="submit" className="me-2 ms-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="ms-2 me-2" disabled={isSubmitting}>
|
||||
<Button type="submit" className="me-2 ms-2" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
|
||||
@@ -14,7 +14,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between pt-9 pl-2 sm:flex-row">
|
||||
<div className="flex flex-col justify-between pl-2 pt-9 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">{t("2fa")}</h2>
|
||||
|
||||
@@ -6,7 +6,7 @@ const TwoFactorModalHeader = ({ title, description }: { title: string; descripti
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Shield className="text-inverted h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 className="font-cal text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -74,7 +74,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||
<Form form={form} handleSubmit={handleDisable}>
|
||||
<div className="mb-4">
|
||||
<div className="mb-8">
|
||||
<PasswordField
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
@@ -82,21 +82,19 @@ const DisableTwoFactorAuthModal = ({
|
||||
{...form.register("password")}
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
<Label className="mt-4"> {t("2fa_code")}</Label>
|
||||
|
||||
<TwoFactor center={false} />
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="ms-2 me-2" data-testid="disable-2fa" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<DialogFooter showDivider className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="submit" className="me-2 ms-2" data-testid="disable-2fa" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { BaseSyntheticEvent } from "react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form, TextField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -129,6 +130,17 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnableRef = useCallbackRef(handleEnable);
|
||||
|
||||
const totpCode = form.watch("totpCode");
|
||||
|
||||
// auto submit 2FA if all inputs have a value
|
||||
useEffect(() => {
|
||||
if (totpCode?.trim().length === 6) {
|
||||
form.handleSubmit(handleEnableRef.current)();
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||
@@ -150,20 +162,20 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<div className="-mt-3 flex justify-center">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={dataUri} alt="" />
|
||||
}
|
||||
</div>
|
||||
<p data-testid="two-factor-secret" className="text-center font-mono text-xs">
|
||||
<p data-testid="two-factor-secret" className="mb-4 text-center font-mono text-xs">
|
||||
{secret}
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="mb-4">
|
||||
<div className="-mt-4 pb-2">
|
||||
<TwoFactor center />
|
||||
|
||||
{errorMessage && (
|
||||
@@ -173,11 +185,14 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
)}
|
||||
</div>
|
||||
</WithStep>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogFooter className="mt-8 sm:flex sm:flex-row-reverse" showDivider>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ms-2 me-2"
|
||||
className="me-2 ms-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
@@ -187,20 +202,17 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="goto-otp-screen"
|
||||
className="ms-2 me-2"
|
||||
className="me-2 ms-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="ms-2 me-2" data-testid="enable-2fa" disabled={isSubmitting}>
|
||||
<Button type="submit" className="me-2 ms-2" data-testid="enable-2fa" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -111,7 +111,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<Label htmlFor="username" className={classNames(longWebsiteUrl && "mb-0")}>
|
||||
<span className="block">{t("username")}</span>
|
||||
{longWebsiteUrl && (
|
||||
<small className="items-centerpx-3 bg-subtle border-default text-subtle mt-2 inline-flex rounded-t-md border border-b-0 py-1 px-3">
|
||||
<small className="items-centerpx-3 bg-subtle border-default text-subtle mt-2 inline-flex rounded-t-md border border-b-0 px-3 py-1">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}
|
||||
</small>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
|
||||
|
||||
const ChooseLicense = (
|
||||
props: {
|
||||
value: string;
|
||||
@@ -50,23 +49,19 @@ const ChooseLicense = (
|
||||
</ul>
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
<RadioGroup.Item value="EE">
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
|
||||
value === "EE" && "ring-2 ring-black"
|
||||
)}>
|
||||
<h2 className="font-cal text-emphasis text-xl">{t("ee_enterprise_license")}</h2>
|
||||
<p className="font-medium text-green-800">
|
||||
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
|
||||
</p>
|
||||
<p className="text-subtle">{t("enterprise_license_includes")}</p>
|
||||
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
||||
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("repackage_rebrand_resell")}</li>
|
||||
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<RadioGroup.Item value="EE" disabled>
|
||||
<Link href="https://cal.com/sales" target="_blank">
|
||||
<div className={classNames("bg-default h-full cursor-pointer space-y-2 rounded-md border p-4")}>
|
||||
<h2 className="font-cal text-emphasis text-xl">{t("custom_plan")}</h2>
|
||||
<p className="font-medium text-green-800">{t("contact_sales")}</p>
|
||||
<p className="text-subtle">Build on top of Cal.com</p>
|
||||
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
||||
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("repackage_rebrand_resell")}</li>
|
||||
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Link>
|
||||
</RadioGroup.Item>
|
||||
</RadioGroup.Root>
|
||||
</form>
|
||||
|
||||
@@ -27,7 +27,7 @@ const StepDone = (props: {
|
||||
<Check className="text-inverted dark:bg-default dark:text-default inline-block h-10 w-10" />
|
||||
</div>
|
||||
<div className="max-w-[420px] text-center">
|
||||
<h2 className="mt-6 mb-1 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
|
||||
<h2 className="mb-1 mt-6 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
@@ -12,27 +11,23 @@ type TeamType = NonNullable<TeamWithMembers>;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number] & { safeBio: string | null };
|
||||
|
||||
type TeamTypeWithSafeHtml = Omit<TeamType, "members"> & { members: MemberType[] };
|
||||
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
|
||||
|
||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const isBioEmpty = !member.bio || !member.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
// slug is a route parameter, we don't want to forward it to the next route
|
||||
const { slug: _slug, ...queryParamsToForward } = router.query;
|
||||
// We don't want to forward orgSlug and user which are route params to the next route
|
||||
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = router.query;
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={member.name || ""}
|
||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||
/>
|
||||
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
||||
<Avatar size="md" alt={member.name || ""} imageSrc={"/" + member.username + "/avatar.png"} />
|
||||
<section className="mt-2 line-clamp-4 w-full space-y-1">
|
||||
<p className="text-default font-medium">{member.name}</p>
|
||||
<div className="line-clamp-3 text-subtle overflow-ellipsis text-sm font-normal">
|
||||
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
|
||||
{!isBioEmpty ? (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -23,7 +23,7 @@ const EditableHeading = function EditableHeading({
|
||||
<label className="min-w-8 relative inline-block">
|
||||
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
||||
{!isEditing && isReady && (
|
||||
<Edit2 className=" text-subtle group-hover:text-subtle ml-1 -mt-px inline h-3 w-3" />
|
||||
<Edit2 className=" text-subtle group-hover:text-subtle -mt-px ml-1 inline h-3 w-3" />
|
||||
)}
|
||||
<input
|
||||
{...passThroughProps}
|
||||
@@ -31,7 +31,7 @@ const EditableHeading = function EditableHeading({
|
||||
value={value}
|
||||
required
|
||||
className={classNames(
|
||||
"text-emphasis hover:text-default focus:text-emphasis absolute top-0 left-0 w-full cursor-pointer truncate border-none bg-transparent p-0 align-top text-xl focus:outline-none focus:ring-0"
|
||||
"text-emphasis hover:text-default focus:text-emphasis absolute left-0 top-0 w-full cursor-pointer truncate border-none bg-transparent p-0 align-top text-xl focus:outline-none focus:ring-0"
|
||||
)}
|
||||
onFocus={(e) => {
|
||||
setIsEditing(true);
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function InfoBadge({ content }: { content: string }) {
|
||||
<>
|
||||
<Tooltip side="top" content={content}>
|
||||
<span title={content}>
|
||||
<Info className="text-subtle relative top-px left-1 right-1 mt-px h-4 w-4" />
|
||||
<Info className="text-subtle relative left-1 right-1 top-px mt-px h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ModalContainer(
|
||||
}>
|
||||
) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent>
|
||||
<div
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import classNames from "classnames";
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Dialog, DialogClose, DialogContent, Input, Label } from "@calcom/ui";
|
||||
import { Star as StarSolid } from "@calcom/ui/components/icon";
|
||||
import { Check, Edit2, ExternalLink } from "@calcom/ui/components/icon";
|
||||
import { Check, Edit2, ExternalLink, Star as StarSolid } from "@calcom/ui/components/icon";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
UPGRADE = "UPGRADE",
|
||||
@@ -29,7 +29,6 @@ interface ICustomUsernameProps {
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
user: Pick<User, "username" | "metadata">;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
@@ -48,6 +47,7 @@ const obtainNewUsernameChangeCondition = ({
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
@@ -57,8 +57,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
readonly: disabled,
|
||||
user,
|
||||
} = props;
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -96,6 +96,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
await update();
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -119,7 +120,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
|
||||
inputUsernameValue || usernameFromStripe
|
||||
}&action=${usernameChangeCondition}&callbackUrl=${router.asPath}`;
|
||||
}&action=${usernameChangeCondition}&callbackUrl=${WEBAPP_URL}${router.asPath}`;
|
||||
|
||||
const ActionButtons = () => {
|
||||
if (paymentRequired) {
|
||||
@@ -227,7 +228,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
}}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
<div className="absolute top-0 right-2 flex flex-row">
|
||||
<div className="absolute right-2 top-0 flex flex-row">
|
||||
<span
|
||||
className={classNames(
|
||||
"mx-2 py-2",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import classNames from "classnames";
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -21,8 +22,9 @@ interface ICustomUsernameProps {
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
}
|
||||
|
||||
const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
@@ -31,6 +33,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
usernameRef,
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
...rest
|
||||
} = props;
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
@@ -68,6 +71,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setOpenDialogSaveUsername(false);
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
await update();
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
@@ -79,7 +83,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
const ActionButtons = () => {
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="ms-2 me-2 flex flex-row space-x-2">
|
||||
<div className="me-2 ms-2 flex flex-row space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
@@ -116,9 +120,6 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
ref={usernameRef}
|
||||
name="username"
|
||||
value={inputUsernameValue}
|
||||
addOnLeading={
|
||||
<>{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/</>
|
||||
}
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
@@ -133,10 +134,11 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
{...rest}
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-6 flex flex-row">
|
||||
<span className={classNames("mx-2 py-2.5")}>
|
||||
<span className={classNames("mx-2 py-3.5")}>
|
||||
{usernameIsAvailable ? <Check className="h-4 w-4" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
@@ -157,23 +159,23 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
<DialogContent type="confirmation" Icon={Edit2} title={t("confirm_username_change_dialog_title")}>
|
||||
<div className="flex flex-row">
|
||||
<div className="mb-4 w-full pt-1">
|
||||
<div className="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<div className="bg-subtle flex w-full flex-wrap gap-6 rounded-sm px-2 py-3 text-sm">
|
||||
<div>
|
||||
<p className="text-subtle">{t("current_username")}</p>
|
||||
<p className="text-emphasis mt-1" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<div>
|
||||
<p className="text-subtle" data-testid="new-username">
|
||||
{t("new_username")}
|
||||
</p>
|
||||
<p className="text-emphasis">{inputUsernameValue}</p>
|
||||
<p className="text-emphasis mt-1">{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
<div className="ml-4 mt-4 flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsernameMutation.isLoading}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
@@ -17,14 +19,24 @@ export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : Premium
|
||||
interface UsernameAvailabilityFieldProps {
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
user: Pick<User, "username" | "metadata">;
|
||||
}
|
||||
|
||||
function useUserNamePrefix(organization: RouterOutputs["viewer"]["me"]["organization"]): string {
|
||||
return organization
|
||||
? organization.slug
|
||||
? getOrgFullDomain(organization.slug, { protocol: false })
|
||||
: organization.metadata && organization.metadata.requestedSlug
|
||||
? getOrgFullDomain(organization.metadata.requestedSlug, { protocol: false })
|
||||
: process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")
|
||||
: process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "");
|
||||
}
|
||||
|
||||
export const UsernameAvailabilityField = ({
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
user,
|
||||
}: UsernameAvailabilityFieldProps) => {
|
||||
const router = useRouter();
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const [currentUsernameState, setCurrentUsernameState] = useState(user.username || "");
|
||||
const { username: usernameFromQuery, setQuery: setUsernameFromQuery } = useRouterQuery("username");
|
||||
const { username: currentUsername, setQuery: setCurrentUsername } =
|
||||
@@ -37,6 +49,8 @@ export const UsernameAvailabilityField = ({
|
||||
},
|
||||
});
|
||||
|
||||
const usernamePrefix = useUserNamePrefix(user.organization);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
@@ -51,7 +65,7 @@ export const UsernameAvailabilityField = ({
|
||||
setInputUsernameValue={onChange}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
user={user}
|
||||
addOnLeading={usernamePrefix}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type LocationOption = {
|
||||
value: EventLocationType["type"];
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export type SingleValueLocationOption = SingleValue<LocationOption>;
|
||||
@@ -39,11 +40,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
name="location"
|
||||
id="location-select"
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.Option>
|
||||
),
|
||||
Option: (props) => {
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.Option>
|
||||
);
|
||||
},
|
||||
SingleValue: (props) => (
|
||||
<components.SingleValue {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GroupBase, Props, InputProps, SingleValue, MultiValue } from "reac
|
||||
import ReactSelect, { components } from "react-select";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
|
||||
export type SelectProps<
|
||||
Option,
|
||||
@@ -30,7 +30,7 @@ function Select<
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
const { resolvedTheme, forcedTheme } = useTheme();
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
||||
const darkThemeColors = {
|
||||
/** Dark Theme starts */
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const getDefaultSubdomain = (url) => {
|
||||
if (!url.startsWith("http:") && !url.startsWith("https:")) {
|
||||
// Make it a valid URL. Mabe we can simply return null and opt-out from orgs support till the use a URL scheme.
|
||||
url = `https://${url}`;
|
||||
}
|
||||
const _url = new URL(url);
|
||||
const regex = new RegExp(/^([a-z]+\:\/{2})?((?<subdomain>[\w-.]+)\.[\w-]+\.\w+)$/);
|
||||
//console.log(_url.hostname, _url.hostname.match(regex));
|
||||
return _url.hostname.match(regex)?.groups?.subdomain || null;
|
||||
};
|
||||
exports.getSubdomainRegExp = (url) => {
|
||||
const defaultSubdomain = getDefaultSubdomain(url);
|
||||
const subdomain = defaultSubdomain ? `(?!${defaultSubdomain})[^.]+` : "[^.]+";
|
||||
return subdomain;
|
||||
};
|
||||
@@ -9,6 +9,8 @@ import type { NextRouter } from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
@@ -46,6 +48,12 @@ type AppPropsWithChildren = AppProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const getEmbedNamespace = (query: ReturnType<typeof useRouter>["query"]) => {
|
||||
// Mostly embed query param should be available on server. Use that there.
|
||||
// Use the most reliable detection on client
|
||||
return typeof window !== "undefined" ? window.getEmbedNamespace() : (query.embed as string) || null;
|
||||
};
|
||||
|
||||
const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
@@ -85,7 +93,7 @@ const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
// One such example is our Embeds Demo and Testing page at http://localhost:3100
|
||||
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
|
||||
const embedNamespace = typeof window !== "undefined" ? window.getEmbedNamespace() : null;
|
||||
const embedNamespace = getEmbedNamespace(router.query);
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
return (
|
||||
@@ -156,10 +164,10 @@ function getThemeProviderProps({
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
|
||||
const isBookingPageThemSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
const themeBasis = props.themeBasis;
|
||||
|
||||
if ((isBookingPageThemSupportRequired || isEmbedMode) && !themeBasis) {
|
||||
if ((isBookingPageThemeSupportRequired || isEmbedMode) && !themeBasis) {
|
||||
console.warn(
|
||||
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
|
||||
);
|
||||
@@ -182,7 +190,7 @@ function getThemeProviderProps({
|
||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
: themeSupport === ThemeSupport.App
|
||||
? "app-theme"
|
||||
: isBookingPageThemSupportRequired
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
|
||||
@@ -205,6 +213,11 @@ function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
|
||||
}
|
||||
|
||||
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
const orgBrand = useOrgBrandingValues();
|
||||
return <OrgBrandingProvider value={orgBrand}>{children}</OrgBrandingProvider>;
|
||||
}
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const session = trpc.viewer.public.session.useQuery().data;
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
@@ -222,7 +235,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
<OrgBrandProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</OrgBrandProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { Maybe } from "@calcom/trpc/server";
|
||||
|
||||
import { i18n } from "../../../next-i18next.config";
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { i18n } = require("@calcom/config/next-i18next.config");
|
||||
|
||||
export function getLocaleFromHeaders(req: IncomingMessage): string {
|
||||
let preferredLocale: string | null | undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
||||
return async (context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<EmbedProps>> => {
|
||||
const ssrResponse = await getServerSideProps(context);
|
||||
const embed = context.query.embed;
|
||||
const layout = context.query.layout;
|
||||
|
||||
if ("redirect" in ssrResponse) {
|
||||
// Use a dummy URL https://base as the fallback base URL so that URL parsing works for relative URLs as well.
|
||||
@@ -18,6 +19,8 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
||||
destinationUrlObj.pathname +
|
||||
"/embed?" +
|
||||
destinationUrlObj.searchParams.toString() +
|
||||
"&layout=" +
|
||||
layout +
|
||||
"&embed=" +
|
||||
embed;
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ const middleware: NextMiddleware = async (req) => {
|
||||
const url = req.nextUrl;
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
|
||||
if (isIpInBanlist(req) && url.pathname !== "/api/nope") {
|
||||
// DDOS Prevention: Immediately end request with no response - Avoids a redirect as well initiated by NextAuth on invalid callback
|
||||
req.nextUrl.pathname = "/api/nope";
|
||||
return NextResponse.redirect(req.nextUrl);
|
||||
}
|
||||
|
||||
if (!url.pathname.startsWith("/api")) {
|
||||
//
|
||||
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||
@@ -48,13 +54,6 @@ const middleware: NextMiddleware = async (req) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that embed query param is there in when /embed is added.
|
||||
// query param is the way in which client side code knows that it is in embed mode.
|
||||
if (url.pathname.endsWith("/embed") && typeof url.searchParams.get("embed") !== "string") {
|
||||
url.searchParams.set("embed", "");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// Don't 404 old routing_forms links
|
||||
if (url.pathname.startsWith("/apps/routing_forms")) {
|
||||
url.pathname = url.pathname.replace("/apps/routing_forms", "/apps/routing-forms");
|
||||
@@ -79,6 +78,7 @@ const middleware: NextMiddleware = async (req) => {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/:path*",
|
||||
"/api/collect-events/:path*",
|
||||
"/api/auth/:path*",
|
||||
"/apps/routing_forms/:path*",
|
||||
|
||||
+161
-53
@@ -1,10 +1,15 @@
|
||||
require("dotenv").config({ path: "../../.env" });
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const os = require("os");
|
||||
const glob = require("glob");
|
||||
const englishTranslation = require("./public/static/locales/en/common.json");
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { i18n } = require("./next-i18next.config");
|
||||
const {
|
||||
orgHostPath,
|
||||
orgUserRoutePath,
|
||||
orgUserTypeRoutePath,
|
||||
orgUserTypeEmbedRoutePath,
|
||||
} = require("./pagesAndRewritePaths");
|
||||
|
||||
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
||||
@@ -70,7 +75,6 @@ const informAboutDuplicateTranslations = () => {
|
||||
};
|
||||
|
||||
informAboutDuplicateTranslations();
|
||||
|
||||
const plugins = [];
|
||||
if (process.env.ANALYZE === "true") {
|
||||
// only load dependency if env `ANALYZE` was set
|
||||
@@ -81,17 +85,45 @@ if (process.env.ANALYZE === "true") {
|
||||
}
|
||||
|
||||
plugins.push(withAxiom);
|
||||
const matcherConfigRootPath = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: "/",
|
||||
};
|
||||
|
||||
/** Needed to rewrite public booking page, gets all static pages but [user] */
|
||||
const pages = glob
|
||||
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
|
||||
.map((filename) =>
|
||||
filename
|
||||
.substr(6)
|
||||
.replace(/(\.tsx|\.js|\.ts)/, "")
|
||||
.replace(/\/.*/, "")
|
||||
)
|
||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
|
||||
const matcherConfigUserRoute = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: orgUserRoutePath,
|
||||
};
|
||||
|
||||
const matcherConfigUserTypeRoute = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: orgUserTypeRoutePath,
|
||||
};
|
||||
|
||||
const matcherConfigUserTypeEmbedRoute = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: orgUserTypeEmbedRoutePath,
|
||||
};
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
@@ -183,58 +215,81 @@ const nextConfig = {
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
const beforeFiles = [
|
||||
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||
...(process.env.ORGANIZATIONS_ENABLED
|
||||
? [
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
destination: "/team/:orgSlug",
|
||||
},
|
||||
{
|
||||
...matcherConfigUserRoute,
|
||||
destination: "/org/:orgSlug/:user",
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeRoute,
|
||||
destination: "/org/:orgSlug/:user/:type",
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
destination: "/org/:orgSlug/:user/:type/embed",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
let afterFiles = [
|
||||
{
|
||||
source: "/:user/avatar.png",
|
||||
destination: "/api/user/avatar?username=:user",
|
||||
source: "/org/:slug",
|
||||
destination: "/team/:slug",
|
||||
},
|
||||
{
|
||||
source: "/team/:teamname/avatar.png",
|
||||
destination: "/api/user/avatar?teamname=:teamname",
|
||||
},
|
||||
{
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: "/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
{
|
||||
type: "query",
|
||||
key: "uid",
|
||||
value: "(?<uid>.*)",
|
||||
},
|
||||
],
|
||||
destination: "/booking/:uid/:path*",
|
||||
},
|
||||
{
|
||||
source: "/cancel/:path*",
|
||||
destination: "/booking/:path*",
|
||||
},
|
||||
|
||||
// When updating this also update pagesAndRewritePaths.js
|
||||
...[
|
||||
{
|
||||
source: "/:user/avatar.png",
|
||||
destination: "/api/user/avatar?username=:user",
|
||||
},
|
||||
{
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: "/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
{
|
||||
type: "query",
|
||||
key: "uid",
|
||||
value: "(?<uid>.*)",
|
||||
},
|
||||
],
|
||||
destination: "/booking/:uid/:path*",
|
||||
},
|
||||
{
|
||||
source: "/cancel/:path*",
|
||||
destination: "/booking/:path*",
|
||||
},
|
||||
],
|
||||
|
||||
/* TODO: have these files being served from another deployment or CDN {
|
||||
source: "/embed/embed.js",
|
||||
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
|
||||
}, */
|
||||
{
|
||||
source: `/:user((?!${pages.join("|")}).*)/:type`,
|
||||
destination: "/new-booker/:user/:type",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: "/team/:slug/:type",
|
||||
destination: "/new-booker/team/:slug/:type",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: "/d/:link/:slug",
|
||||
destination: "/new-booker/d/:link/:slug",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
beforeFiles,
|
||||
afterFiles,
|
||||
};
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
@@ -269,6 +324,44 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
...[
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/team/:orgSlug",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
@@ -293,6 +386,11 @@ const nextConfig = {
|
||||
destination: "/teams",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/admin",
|
||||
destination: "/settings/admin/flags",
|
||||
permanent: true,
|
||||
},
|
||||
/* V2 testers get redirected to the new settings */
|
||||
{
|
||||
source: "/settings/profile",
|
||||
@@ -338,6 +436,16 @@ const nextConfig = {
|
||||
destination: "/event-types?openIntercom=true",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/apps/categories/video",
|
||||
destination: "/apps/categories/conferencing",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/apps/installed/video",
|
||||
destination: "/apps/installed/conferencing",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||
|
||||
+12
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.9.8",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -47,8 +47,8 @@
|
||||
"@next/bundle-analyzer": "^13.1.6",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-id": "^1.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.2",
|
||||
"@radix-ui/react-radio-group": "^1.0.0",
|
||||
@@ -58,9 +58,12 @@
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanem/react-nprogress": "^5.0.44",
|
||||
"@tanstack/react-query": "^4.3.9",
|
||||
"@tremor/react": "^2.0.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@upstash/ratelimit": "^0.4.3",
|
||||
"@upstash/redis": "^1.21.0",
|
||||
"@vercel/edge-config": "^0.1.1",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
"@vercel/og": "^0.5.0",
|
||||
@@ -86,12 +89,12 @@
|
||||
"memory-cache": "^0.2.0",
|
||||
"micro": "^10.0.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "^13.2.1",
|
||||
"next-auth": "^4.20.1",
|
||||
"next-axiom": "^0.16.0",
|
||||
"next": "^13.4.6",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-collect": "^0.2.1",
|
||||
"next-i18next": "^11.3.0",
|
||||
"next-seo": "^4.26.0",
|
||||
"next-i18next": "^13.2.2",
|
||||
"next-seo": "^6.0.0",
|
||||
"next-themes": "^0.2.0",
|
||||
"nodemailer": "^6.7.8",
|
||||
"otplib": "^12.0.1",
|
||||
@@ -107,7 +110,7 @@
|
||||
"react-hook-form": "^7.43.3",
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-intl": "^5.25.1",
|
||||
"react-live-chat-loader": "^2.7.3",
|
||||
"react-live-chat-loader": "^2.8.1",
|
||||
"react-multi-email": "^0.5.3",
|
||||
"react-phone-input-2": "^2.15.1",
|
||||
"react-phone-number-input": "^3.2.7",
|
||||
@@ -160,7 +163,6 @@
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"detect-port": "^1.3.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"module-alias": "^2.2.2",
|
||||
"msw": "^0.42.3",
|
||||
"postcss": "^8.4.18",
|
||||
|
||||
+118
-52
@@ -3,22 +3,37 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, JOIN_DISCORD, WEBSITE_URL, IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { BookOpen, Check, ChevronRight, FileText, Slack } from "@calcom/ui/components/icon";
|
||||
import { BookOpen, Check, ChevronRight, FileText, Shield } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssgInit } from "@server/lib/ssg";
|
||||
|
||||
enum pageType {
|
||||
ORG = "org",
|
||||
TEAM = "team",
|
||||
USER = "user",
|
||||
OTHER = "other",
|
||||
}
|
||||
|
||||
export default function Custom404() {
|
||||
const { t } = useLocale();
|
||||
|
||||
const router = useRouter();
|
||||
const [username] = router.asPath.replace("%20", "-").split(/[?#]/);
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const [currentPageType, setCurrentPageType] = useState<pageType>(pageType.USER);
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: "Enterprise",
|
||||
description: "Learn more about organizations and subdomains in our enterprise plan.",
|
||||
icon: Shield,
|
||||
href: `${WEBSITE_URL}/enterprise`,
|
||||
},
|
||||
{
|
||||
title: t("documentation"),
|
||||
description: t("documentation_description"),
|
||||
@@ -33,15 +48,38 @@ export default function Custom404() {
|
||||
},
|
||||
];
|
||||
|
||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup?username=`);
|
||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
|
||||
useEffect(() => {
|
||||
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
|
||||
}, [username]);
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
|
||||
const [routerUsername] = router.asPath.replace("%20", "-").split(/[?#]/);
|
||||
if (!isValidOrgDomain || !currentOrgDomain) {
|
||||
const splitPath = routerUsername.split("/");
|
||||
if (splitPath[1] === "team" && splitPath.length === 3) {
|
||||
// Accessing a non-existent team
|
||||
setUsername(splitPath[2]);
|
||||
setCurrentPageType(pageType.TEAM);
|
||||
setUrl(
|
||||
`${WEBSITE_URL}/signup?callbackUrl=settings/teams/new%3Fslug%3D${splitPath[2].replace("/", "")}`
|
||||
);
|
||||
} else {
|
||||
setUsername(routerUsername);
|
||||
setUrl(`${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`);
|
||||
}
|
||||
} else {
|
||||
setUsername(currentOrgDomain);
|
||||
setCurrentPageType(pageType.ORG);
|
||||
setUrl(
|
||||
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain.replace(
|
||||
"/",
|
||||
""
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isSuccessPage = router.asPath.startsWith("/booking");
|
||||
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
||||
const isSignup = router.asPath.startsWith("/signup");
|
||||
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
|
||||
/**
|
||||
* If we're on 404 and the route is insights it means it is disabled
|
||||
* TODO: Abstract this for all disabled features
|
||||
@@ -59,7 +97,7 @@ export default function Custom404() {
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-white px-4" data-testid="404-page">
|
||||
<main className="mx-auto max-w-xl pt-16 pb-6 sm:pt-24">
|
||||
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-black">{t("error_404")}</p>
|
||||
<h1 className="font-cal mt-2 text-4xl font-extrabold text-gray-900 sm:text-5xl">
|
||||
@@ -80,6 +118,8 @@ export default function Custom404() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!username) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
@@ -91,7 +131,7 @@ export default function Custom404() {
|
||||
}}
|
||||
/>
|
||||
<div className="bg-default min-h-screen px-4" data-testid="404-page">
|
||||
<main className="mx-auto max-w-xl pt-16 pb-6 sm:pt-24">
|
||||
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
|
||||
{isSignup && process.env.NEXT_PUBLIC_WEBAPP_URL !== "https://app.cal.com" ? (
|
||||
<div>
|
||||
<div>
|
||||
@@ -161,18 +201,25 @@ export default function Custom404() {
|
||||
</li>
|
||||
<li className="px-4 py-2">
|
||||
<a
|
||||
href={JOIN_SLACK}
|
||||
href={JOIN_DISCORD}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Slack strokeWidth="1.5" fill="currentColor" className="h-6 w-6" />
|
||||
<svg
|
||||
className="text-default h-6 w-6"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 127.14 96.36">
|
||||
<title>Discord Logo</title>
|
||||
<path d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Slack
|
||||
Discord
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
||||
@@ -202,13 +249,13 @@ export default function Custom404() {
|
||||
<h1 className="font-cal text-emphasis mt-2 text-4xl font-extrabold sm:text-5xl">
|
||||
{isSuccessPage ? "Booking not found" : t("page_doesnt_exist")}
|
||||
</h1>
|
||||
{isSubpage ? (
|
||||
{isSubpage && currentPageType !== pageType.TEAM ? (
|
||||
<span className="mt-2 inline-block text-lg ">
|
||||
{t("check_spelling_mistakes_or_go_back")}
|
||||
</span>
|
||||
) : isCalcom ? (
|
||||
) : IS_CALCOM ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t("the_username")}{" "}
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
<strong className="text-blue-500">
|
||||
{new URL(WEBSITE_URL).hostname}
|
||||
{username}
|
||||
@@ -217,18 +264,17 @@ export default function Custom404() {
|
||||
</a>
|
||||
) : (
|
||||
<span className="mt-2 inline-block text-lg">
|
||||
{t("the_username")}{" "}
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
<strong className="text-lgtext-green-500 mt-2 inline-block">{username}</strong>{" "}
|
||||
{t("is_still_available")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-12">
|
||||
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">
|
||||
{t("popular_pages")}
|
||||
</h2>
|
||||
{!isSubpage && isCalcom && (
|
||||
<ul role="list" className="mt-4">
|
||||
{((!isSubpage && IS_CALCOM) ||
|
||||
currentPageType === pageType.ORG ||
|
||||
currentPageType === pageType.TEAM) && (
|
||||
<ul role="list" className="my-4">
|
||||
<li className="border-2 border-green-500 px-4 py-2">
|
||||
<a
|
||||
href={url}
|
||||
@@ -245,11 +291,20 @@ export default function Custom404() {
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="focus:outline-none">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{t("register")} <strong className="text-green-500">{username}</strong>
|
||||
{t("register")}{" "}
|
||||
<strong className="text-green-500">{`${
|
||||
currentPageType === pageType.TEAM
|
||||
? `${new URL(WEBSITE_URL).host}/team/`
|
||||
: ""
|
||||
}${username}${
|
||||
currentPageType === pageType.ORG ? `.${subdomainSuffix()}` : ""
|
||||
}`}</strong>
|
||||
</span>
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("claim_username_and_schedule_events")}</p>
|
||||
<p className="text-subtle text-base">
|
||||
{t(`404_claim_entity_${currentPageType.toLowerCase()}`)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
@@ -258,47 +313,58 @@ export default function Custom404() {
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ul role="list" className="border-subtle divide-subtle mt-4 divide-y">
|
||||
{links.map((link, linkIdx) => (
|
||||
<li key={linkIdx} className="px-4 py-2">
|
||||
<Link
|
||||
href={link.href}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<link.icon className="text-default h-6 w-6" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{link.title}
|
||||
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">
|
||||
{t("popular_pages")}
|
||||
</h2>
|
||||
<ul role="list" className="border-subtle divide-subtle divide-y">
|
||||
{links
|
||||
.filter((_, idx) => currentPageType === pageType.ORG || idx !== 0)
|
||||
.map((link, linkIdx) => (
|
||||
<li key={linkIdx} className="px-4 py-2">
|
||||
<a
|
||||
href={link.href}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<link.icon className="text-default h-6 w-6" aria-hidden="true" />
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{link.description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{link.title}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{link.description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li className="px-4 py-2">
|
||||
<a
|
||||
href={JOIN_SLACK}
|
||||
href={JOIN_DISCORD}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Slack strokeWidth="1.5" fill="currentColor" className="h-6 w-6" />
|
||||
<svg
|
||||
className="text-default h-6 w-6"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 127.14 96.36">
|
||||
<title>Discord Logo</title>
|
||||
<path d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Slack
|
||||
Discord
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Error500() {
|
||||
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
||||
<h1 className="font-cal text-emphasis text-6xl">500</h1>
|
||||
<h2 className="text-emphasis mt-6 text-2xl font-medium">It's not you, it's us.</h2>
|
||||
<p className="text-default mt-4 mb-6 max-w-2xl text-sm">
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
|
||||
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
||||
away for you.
|
||||
</p>
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import defaultEvents, {
|
||||
getDynamicEventDescription,
|
||||
getGroupName,
|
||||
@@ -36,7 +36,8 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
|
||||
export type UserPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
export function UserPage(props: UserPageProps) {
|
||||
const {
|
||||
users,
|
||||
profile,
|
||||
@@ -101,6 +102,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const query = { ...router.query };
|
||||
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
||||
delete query.orgSlug;
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
|
||||
/*
|
||||
@@ -131,7 +133,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
|
||||
"max-w-3xl py-24 px-4"
|
||||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
{isSingleUser && ( // When we deal with a single user, not dynamic group
|
||||
<div className="mb-8 text-center">
|
||||
@@ -206,8 +208,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
);
|
||||
}
|
||||
|
||||
User.isBookingPage = true;
|
||||
User.PageWrapper = PageWrapper;
|
||||
UserPage.isBookingPage = true;
|
||||
UserPage.PageWrapper = PageWrapper;
|
||||
|
||||
const getEventTypesWithHiddenFromDB = async (userId: number) => {
|
||||
return (
|
||||
@@ -255,6 +257,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number) => {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const crypto = await import("crypto");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const dataFetchStart = Date.now();
|
||||
@@ -263,6 +266,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
username: {
|
||||
in: usernameList,
|
||||
},
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -272,6 +280,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
bio: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
organizationId: true,
|
||||
theme: true,
|
||||
away: true,
|
||||
verified: true,
|
||||
@@ -281,10 +290,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const users = usersWithoutAvatar.map((user) => ({
|
||||
...user,
|
||||
avatar: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
||||
avatar: `/${user.username}/avatar.png`,
|
||||
}));
|
||||
|
||||
if (!users.length) {
|
||||
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
@@ -352,6 +361,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const safeBio = markdownToSafeHTML(user.bio) || "";
|
||||
|
||||
const markdownStrippedBio = stripMarkdown(user?.bio || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
users,
|
||||
@@ -377,3 +387,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default UserPage;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user