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 |
No files matched your search
+11
-7
@@ -59,6 +59,11 @@ SAML_CLIENT_SECRET_VERIFIER=
|
|||||||
# PGSSLMODE='no-verify'
|
# PGSSLMODE='no-verify'
|
||||||
PGSSLMODE=
|
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
|
# - NEXTAUTH
|
||||||
# @see: https://github.com/calendso/calendso/issues/263
|
# @see: https://github.com/calendso/calendso/issues/263
|
||||||
# @see: https://next-auth.js.org/configuration/options#nextauth_url
|
# @see: https://next-auth.js.org/configuration/options#nextauth_url
|
||||||
@@ -76,6 +81,10 @@ CALCOM_TELEMETRY_DISABLED=
|
|||||||
# ApiKey for cronjobs
|
# ApiKey for cronjobs
|
||||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
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
|
# Application Key for symmetric encryption and decryption
|
||||||
# must be 32 bytes for AES256 encryption algorithm
|
# must be 32 bytes for AES256 encryption algorithm
|
||||||
# You can use: `openssl rand -base64 24` to generate one
|
# You can use: `openssl rand -base64 24` to generate one
|
||||||
@@ -112,6 +121,7 @@ TWILIO_SID=
|
|||||||
TWILIO_TOKEN=
|
TWILIO_TOKEN=
|
||||||
TWILIO_MESSAGING_SID=
|
TWILIO_MESSAGING_SID=
|
||||||
TWILIO_PHONE_NUMBER=
|
TWILIO_PHONE_NUMBER=
|
||||||
|
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
@@ -126,6 +136,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
|||||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_PRIVATE_KEY=
|
STRIPE_PRIVATE_KEY=
|
||||||
STRIPE_CLIENT_ID=
|
STRIPE_CLIENT_ID=
|
||||||
@@ -172,7 +183,6 @@ CLOSECOM_API_KEY=
|
|||||||
# Sendgrid internal sync service
|
# Sendgrid internal sync service
|
||||||
SENDGRID_SYNC_API_KEY=
|
SENDGRID_SYNC_API_KEY=
|
||||||
|
|
||||||
|
|
||||||
# Change your Brand
|
# Change your Brand
|
||||||
NEXT_PUBLIC_APP_NAME="Cal.com"
|
NEXT_PUBLIC_APP_NAME="Cal.com"
|
||||||
NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS="help@cal.com"
|
NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS="help@cal.com"
|
||||||
@@ -205,9 +215,3 @@ PROJECT_ID_VERCEL=
|
|||||||
TEAM_ID_VERCEL=
|
TEAM_ID_VERCEL=
|
||||||
# Get it from: https://vercel.com/account/tokens
|
# Get it from: https://vercel.com/account/tokens
|
||||||
AUTH_BEARER_TOKEN_VERCEL=
|
AUTH_BEARER_TOKEN_VERCEL=
|
||||||
|
|
||||||
#Enables New booker for Embed only
|
|
||||||
NEW_BOOKER_ENABLED_FOR_EMBED=0
|
|
||||||
|
|
||||||
#Enables New booker for All but Embed requests
|
|
||||||
NEW_BOOKER_ENABLED_FOR_NON_EMBED=0
|
|
||||||
@@ -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?
|
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
|
### Technical details
|
||||||
|
|
||||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||||
- Node.js version
|
- Node.js version
|
||||||
- Anything else that you think could be an issue.
|
- 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
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Questions
|
- name: Questions
|
||||||
url: https://cal.com/slack
|
url: https://go.cal.com/discord
|
||||||
about: Ask a general question about the project on our Slack workspace
|
about: Ask a general question about the project on our discord community
|
||||||
@@ -39,3 +39,11 @@ assignees: ""
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
(Write your answer here.)
|
(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/
|
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
|
## Type of change
|
||||||
|
|
||||||
<!-- Please delete bullets that are not relevant. -->
|
<!-- Please delete bullets that are not relevant. -->
|
||||||
|
|
||||||
- Bug fix (non-breaking change which fixes an issue)
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
- Chore (refactoring code, technical debt, workflow improvements)
|
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||||
- New feature (non-breaking change which adds functionality)
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
- This change requires a documentation update
|
- [ ] This change requires a documentation update
|
||||||
|
|
||||||
## How should this be tested?
|
## 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
|
- Are there environment variables that should be set?
|
||||||
- [ ] Test B
|
- 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
|
## 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
|
## Checklist
|
||||||
|
|
||||||
<!-- Please remove all the irrelevant bullets to your PR -->
|
<!-- 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)
|
- 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
|
- My code doesn't follow the style guidelines of this project
|
||||||
- I haven't commented my code, particularly in hard-to-understand areas
|
- 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"
|
description: "Configures environment variables for a workflow"
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Create env file
|
- name: Read env file
|
||||||
uses: buildjet/cache@v3
|
uses: buildjet/cache@v3
|
||||||
id: env-cache
|
id: env-cache
|
||||||
with:
|
with:
|
||||||
path: gh.env
|
path: gh.env-${{ github.sha }}
|
||||||
key: env-cache-${{ hashFiles('gh.env') }}
|
key: env-cache
|
||||||
restore-keys: env-cache-
|
restore-keys: env-cache-
|
||||||
- name: Set Environment Variables
|
- name: Set Environment Variables
|
||||||
uses: tw3lveparsecs/github-actions-setvars@latest
|
uses: tw3lveparsecs/github-actions-setvars@latest
|
||||||
with:
|
with:
|
||||||
envFilePath: gh.env
|
envFilePath: gh.env-${{ github.sha }}
|
||||||
@@ -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:
|
schedule:
|
||||||
# Runs every day (see https://crontab.guru)
|
# Runs every day (see https://crontab.guru)
|
||||||
- cron: "0 0 * * *"
|
- cron: "0 0 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -22,3 +23,4 @@ jobs:
|
|||||||
days-before-pr-close: 7
|
days-before-pr-close: 7
|
||||||
stale-pr-message: "This PR is being marked as stale due to inactivity."
|
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."
|
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
|
uses: crowdin/github-action@1.5.1
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
|
upload_translations: true
|
||||||
download_translations: true
|
download_translations: true
|
||||||
push_translations: true
|
push_translations: true
|
||||||
commit_message: "New Crowdin translations by Github Action"
|
commit_message: "New Crowdin translations by Github Action"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
ENV_FILE_NAME: gh.env-${{ github.sha }}
|
||||||
INPUT_ENV_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
INPUT_ENV_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
INPUT_ENV_NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
INPUT_ENV_NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||||
INPUT_ENV_NEXT_PUBLIC_WEBSITE_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_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
INPUT_ENV_GOOGLE_LOGIN_ENABLED: true
|
INPUT_ENV_GOOGLE_LOGIN_ENABLED: true
|
||||||
# INPUT_ENV_CRON_API_KEY: xxx
|
# 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_CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
INPUT_ENV_NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_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 }}
|
INPUT_ENV_STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
@@ -31,6 +33,8 @@ env:
|
|||||||
# INPUT_ENV_MS_GRAPH_CLIENT_SECRET: xxx
|
# INPUT_ENV_MS_GRAPH_CLIENT_SECRET: xxx
|
||||||
# INPUT_ENV_ZOOM_CLIENT_ID: xxx
|
# INPUT_ENV_ZOOM_CLIENT_ID: xxx
|
||||||
# INPUT_ENV_ZOOM_CLIENT_SECRET: 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_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
INPUT_ENV_TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
INPUT_ENV_TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
|
||||||
@@ -41,10 +45,11 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ozaytsev86/create-env-file@v1
|
- uses: ozaytsev86/create-env-file@v1
|
||||||
|
|
||||||
with:
|
with:
|
||||||
file-name: ${{ github.workspace }}/gh.env
|
file-name: ${{ github.workspace }}/${{ env.ENV_FILE_NAME }}
|
||||||
- uses: buildjet/cache@v3
|
- uses: buildjet/cache@v3
|
||||||
id: env-cache
|
id: env-cache
|
||||||
with:
|
with:
|
||||||
path: gh.env
|
path: ${{ env.ENV_FILE_NAME }}
|
||||||
key: env-cache-${{ hashFiles('gh.env') }}
|
key: env-cache-${{ hashFiles(env.ENV_FILE_NAME) }}
|
||||||
@@ -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"
|
||||||
@@ -34,12 +34,15 @@ jobs:
|
|||||||
app-store:
|
app-store:
|
||||||
- 'apps/web/**'
|
- 'apps/web/**'
|
||||||
- 'packages/app-store/**'
|
- 'packages/app-store/**'
|
||||||
|
- 'playwright.config.ts'
|
||||||
embed:
|
embed:
|
||||||
- 'apps/web/**'
|
- 'apps/web/**'
|
||||||
- 'packages/embeds/**'
|
- 'packages/embeds/**'
|
||||||
|
- 'playwright.config.ts'
|
||||||
embed-react:
|
embed-react:
|
||||||
- 'apps/web/**'
|
- 'apps/web/**'
|
||||||
- 'packages/embeds/**'
|
- 'packages/embeds/**'
|
||||||
|
- 'playwright.config.ts'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
name: Create env file
|
name: Create env file
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
welcome-message:
|
welcome-message:
|
||||||
name: Welcoming New Users
|
name: Welcoming New Users
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
needs: semantics
|
needs: semantics
|
||||||
if: github.event.action == 'opened'
|
if: github.event.action == 'opened'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/first-interaction@v1
|
- uses: actions/first-interaction@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
pr-message: |-
|
pr-message: |-
|
||||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||||
Feel free to join the conversation at [slack](https://calendso.slack.com/)
|
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
||||||
issue-message: |
|
issue-message: |
|
||||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||||
@@ -88,6 +88,7 @@ To develop locally:
|
|||||||
|
|
||||||
5. Set up your `.env` file:
|
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 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="https://cal.com"><strong>Learn more »</strong></a>
|
<a href="https://cal.com"><strong>Learn more »</strong></a>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://cal.com/slack">Slack</a>
|
<a href="https://go.cal.com/discord">Discord</a>
|
||||||
·
|
·
|
||||||
<a href="https://cal.com">Website</a>
|
<a href="https://cal.com">Website</a>
|
||||||
·
|
·
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cal.com/slack"><img src="https://img.shields.io/badge/Slack-calendso.slack.com-%234A154B" alt="Join Cal.com Slack"></a>
|
<a href="https://go.cal.com/discord"><img src="https://img.shields.io/badge/Discord-go.cal.com%2Fdiscord-%234A154B" alt="Join Cal.com Discord"></a>
|
||||||
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||||
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
<a href="https://discord.com/channels/1125390093386010654/1125398231585661040"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||||
<a href="https://contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
<a href="https://contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||||
<a href="https://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
|
<a href="https://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
## About the Project
|
## About the Project
|
||||||
|
|
||||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/176390354-f1bc7069-0341-437a-9bb8-eb41092b4016.gif">
|
<img width="100%" alt="booking-screen" src="https://github.com/calcom/cal.com/assets/8019099/407e727e-ff19-4ca4-bcae-049dca05cf02">
|
||||||
|
|
||||||
# Scheduling infrastructure for absolutely everyone
|
# Scheduling infrastructure for absolutely everyone
|
||||||
|
|
||||||
@@ -90,9 +90,16 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
|||||||
- [Prisma.io](https://prisma.io/?ref=cal.com)
|
- [Prisma.io](https://prisma.io/?ref=cal.com)
|
||||||
- [Daily.co](https://go.cal.com/daily)
|
- [Daily.co](https://go.cal.com/daily)
|
||||||
|
|
||||||
|
|
||||||
|
## Contact us
|
||||||
|
|
||||||
|
Meet our sales team for any commercial inquiries.
|
||||||
|
|
||||||
|
<a href="https://cal.com/sales"><img src="https://cal.com/book-with-cal-dark.svg" alt="Book us with Cal.com"></a>
|
||||||
|
|
||||||
## Stay Up-to-Date
|
## Stay Up-to-Date
|
||||||
|
|
||||||
Cal.com officially launched as v.1.0 on 15th of September, however a lot of new features are coming. Watch **releases** of this repository to be notified for future updates:
|
Cal.com officially launched as v.1.0 on 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -121,9 +128,9 @@ Here is what you need to be able to run Cal.com.
|
|||||||
```sh
|
```sh
|
||||||
git clone https://github.com/calcom/cal.com.git
|
git clone https://github.com/calcom/cal.com.git
|
||||||
```
|
```
|
||||||
> If you are on windows, run the following command on `gitbash` with admin privileges: <br>
|
|
||||||
```git clone -c core.symlinks=true https://github.com/calcom/cal.com.git``` <br>
|
> If you are on windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||||
See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
||||||
|
|
||||||
1. Go to the project folder
|
1. Go to the project folder
|
||||||
|
|
||||||
@@ -158,6 +165,7 @@ yarn dx
|
|||||||
```sh
|
```sh
|
||||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Gitpod Setup
|
#### Gitpod Setup
|
||||||
|
|
||||||
1. Click the button below to open this project in Gitpod.
|
1. Click the button below to open this project in Gitpod.
|
||||||
@@ -166,8 +174,6 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
|
|
||||||
[](https://gitpod.io/#https://github.com/calcom/cal.com)
|
[](https://gitpod.io/#https://github.com/calcom/cal.com)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### Manual setup
|
#### Manual setup
|
||||||
|
|
||||||
1. Configure environment variables in the `.env` file. Replace `<user>`, `<pass>`, `<db-host>`, and `<db-port>` with their applicable values
|
1. Configure environment variables in the `.env` file. Replace `<user>`, `<pass>`, `<db-host>`, and `<db-port>` with their applicable values
|
||||||
@@ -175,6 +181,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
```
|
```
|
||||||
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick local DB</summary>
|
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick local DB</summary>
|
||||||
|
|
||||||
@@ -188,11 +195,12 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||

|

|
||||||
|
|
||||||
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
|
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
|
||||||
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`.
|
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
If you don't want to create a local DB. Then you can also consider using services like railway.app or render.
|
If you don't want to create a local DB. Then you can also consider using services like railway.app or render.
|
||||||
|
|
||||||
- [Setup postgres DB with railway.app](https://arctype.com/postgres/setup/railway-postgres)
|
- [Setup postgres DB with railway.app](https://arctype.com/postgres/setup/railway-postgres)
|
||||||
- [Setup postgres DB with render](https://render.com/docs/databases)
|
- [Setup postgres DB with render](https://render.com/docs/databases)
|
||||||
|
|
||||||
@@ -212,6 +220,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
```sh
|
```sh
|
||||||
yarn workspace @calcom/prisma db-deploy
|
yarn workspace @calcom/prisma db-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -316,12 +325,6 @@ Issues with Docker? Find your answer or open a new discussion [here](https://git
|
|||||||
|
|
||||||
Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
||||||
|
|
||||||
### Heroku
|
|
||||||
|
|
||||||
<a href="https://heroku.com/deploy?template=https://github.com/calcom/cal.com">
|
|
||||||
<img width="185px" height="auto" src="https://herokucdn.com/deploy/button.svg" alt="Deploy">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### Railway
|
### Railway
|
||||||
|
|
||||||
[](https://railway.app/new/template/cal)
|
[](https://railway.app/new/template/cal)
|
||||||
@@ -385,7 +388,7 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
|
|||||||
|
|
||||||
### Translations
|
### Translations
|
||||||
|
|
||||||
Don't code but still want to contribute? Join our [slack](https://cal.com/slack) and join the [#i18n channel](https://calendso.slack.com/archives/C02BY67GMMW) and let us know what language you want to translate.
|
Don't code but still want to contribute? Join our [discord](https://go.cal.com/discord) and join the [#Translate channel](https://discord.com/channels/1125390093386010654/1125398231585661040) and let us know what language you want to translate.
|
||||||
|
|
||||||
                                            
|
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
### Setting up SendGrid for Email reminders
|
### Setting up SendGrid for Email reminders
|
||||||
|
|||||||
+34
-16
@@ -1,39 +1,57 @@
|
|||||||
# Security
|
# 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)
|
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.
|
- Clickjacking on pages with no sensitive actions.
|
||||||
- Unauthenticated/logout/login CSRF.
|
- Unauthenticated/logout/login CSRF.
|
||||||
- Attacks requiring MITM or physical access to a user's device.
|
- Attacks requiring MITM or physical access to a user's device.
|
||||||
- Any activity that could lead to the disruption of our service (DoS).
|
- 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
|
- Email spoofing
|
||||||
- Missing DNSSEC, CAA, CSP headers
|
- Missing DNSSEC, CAA, CSP headers
|
||||||
- Lack of Secure or HTTP only flag on non-sensitive cookies
|
- Lack of Secure or HTTP only flag on non-sensitive cookies
|
||||||
- Deadlinks
|
- Deadlinks
|
||||||
|
|
||||||
## Please do the following:
|
## Please do the following
|
||||||
|
|
||||||
- E-mail your findings to [security@cal.com](mailto:security@cal.com).
|
- 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 run automated scanners on our infrastructure or dashboard. If you wish
|
||||||
- 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,
|
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 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 not use attacks on physical security, social engineering, distributed
|
||||||
- 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.
|
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,
|
- We will respond to your report within 3 business days with our evaluation of
|
||||||
- If you have followed the instructions above, we will not take any legal action against you in regard to the report,
|
the report and an expected resolution date,
|
||||||
- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission,
|
- 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,
|
- 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
|
- In the public information concerning the problem reported, we will give your
|
||||||
- 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.
|
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",
|
"description": "ApiKey for cronjobs",
|
||||||
"value": ""
|
"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": {
|
"SEND_FEEDBACK_EMAIL": {
|
||||||
"description": "Send feedback email",
|
"description": "Send feedback email",
|
||||||
"value": ""
|
"value": ""
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<a href="https://cal.com/docs/enterprise-features/api#api-server-specifications">Read the API docs</a>
|
<a href="https://cal.com/docs/enterprise-features/api#api-server-specifications">Read the API docs</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Cal.com Public API
|
# Commercial Cal.com Public API
|
||||||
|
|
||||||
Welcome to the Public API ("/apps/api") of the Cal.com.
|
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) => {
|
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||||
const { prisma, isCustomPrisma, isAdmin } = req;
|
const { prisma, isCustomPrisma, isAdmin } = req;
|
||||||
const hasValidLicense = await checkLicense(prisma);
|
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" });
|
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 the user is an admin and using a license key (from customPrisma), skip the apiKey check.
|
||||||
if (isCustomPrisma && isAdmin) {
|
if (isCustomPrisma && isAdmin) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
||||||
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
|||||||
const schemaEventTypeCreateParams = z
|
const schemaEventTypeCreateParams = z
|
||||||
.object({
|
.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
slug: z.string(),
|
slug: z.string().transform((s) => slugify(s)),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
length: z.number().int(),
|
length: z.number().int(),
|
||||||
metadata: z.any().optional(),
|
metadata: z.any().optional(),
|
||||||
@@ -76,7 +77,10 @@ export const schemaEventTypeCreateBodyParams = schemaEventTypeBaseBodyParams
|
|||||||
const schemaEventTypeEditParams = z
|
const schemaEventTypeEditParams = z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
slug: z.string().optional(),
|
slug: z
|
||||||
|
.string()
|
||||||
|
.transform((s) => slugify(s))
|
||||||
|
.optional(),
|
||||||
length: z.number().int().optional(),
|
length: z.number().int().optional(),
|
||||||
seatsPerTimeSlot: z.number().optional(),
|
seatsPerTimeSlot: z.number().optional(),
|
||||||
seatsShowAttendees: z.boolean().optional(),
|
seatsShowAttendees: z.boolean().optional(),
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
"@sentry/nextjs": "^7.20.0",
|
"@sentry/nextjs": "^7.20.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "~13.4.6",
|
"next": "^13.4.6",
|
||||||
"next-api-middleware": "^1.0.1",
|
"next-api-middleware": "^1.0.1",
|
||||||
"next-axiom": "^0.16.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
* slug:
|
* slug:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Unique slug for the event type
|
* description: Unique slug for the event type
|
||||||
* example: my-event
|
|
||||||
* hosts:
|
* hosts:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
@@ -168,9 +167,9 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
* type: string
|
* type: string
|
||||||
* displayLocationPublicly:
|
* displayLocationPublicly:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* example:
|
* examples:
|
||||||
* event-type:
|
* event-type:
|
||||||
* summary: An example of event type POST request
|
* summary: An example of an individual event type POST request
|
||||||
* value:
|
* value:
|
||||||
* title: Hello World
|
* title: Hello World
|
||||||
* slug: 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:
|
* tags:
|
||||||
* - event-types
|
* - event-types
|
||||||
* externalDocs:
|
* externalDocs:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
|||||||
|
|
||||||
export default function getCalLink(eventType: {
|
export default function getCalLink(eventType: {
|
||||||
team?: { slug: string | null } | null;
|
team?: { slug: string | null } | null;
|
||||||
owner: { username: string | null } | null;
|
owner?: { username: string | null } | null;
|
||||||
users?: { username: string | null }[];
|
users?: { username: string | null }[];
|
||||||
slug: string;
|
slug: string;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -2,29 +2,36 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
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 { 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 { TRPCError } from "@trpc/server";
|
||||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId";
|
|
||||||
|
|
||||||
import authMiddleware, { checkPermissions } from "./_auth-middleware";
|
import authMiddleware, { checkPermissions } from "./_auth-middleware";
|
||||||
|
|
||||||
const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
await checkPermissions(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN] });
|
await checkPermissions(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN] });
|
||||||
/** @see https://trpc.io/docs/server-side-calls */
|
async function sessionGetter() {
|
||||||
const ctx = await createContext({ req, res });
|
return {
|
||||||
const user = ctx.user;
|
user: {
|
||||||
if (!user) {
|
id: req.userId,
|
||||||
throw new Error("Internal Error.");
|
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 {
|
try {
|
||||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||||
return await publishHandler({ input: { teamId }, ctx: { ...ctx, user } });
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return await caller.publish(req.query as any /* Let tRPC handle this */);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof TRPCError) {
|
if (cause instanceof TRPCError) {
|
||||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||||
|
|||||||
@@ -26,15 +26,15 @@ export function VariantsTable({
|
|||||||
<table>
|
<table>
|
||||||
<RowTitles titles={titles} />
|
<RowTitles titles={titles} />
|
||||||
{columns.map((column) => (
|
{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
|
<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}>
|
key={column.props.variant}>
|
||||||
{column.props.variant}
|
{column.props.variant}
|
||||||
</th>
|
</th>
|
||||||
{React.Children.count(column.props.children) &&
|
{React.Children.count(column.props.children) &&
|
||||||
React.Children.map(column.props.children, (cell) => (
|
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}
|
{cell}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -70,9 +70,9 @@ export function VariantRow({ children }: RowProps) {
|
|||||||
export function RowTitles({ titles }: { titles: string[] }) {
|
export function RowTitles({ titles }: { titles: string[] }) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<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) => (
|
{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}
|
{title}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
"@calcom/ui": "*",
|
"@calcom/ui": "*",
|
||||||
"@radix-ui/react-avatar": "^1.0.0",
|
"@radix-ui/react-avatar": "^1.0.0",
|
||||||
"@radix-ui/react-collapsible": "^1.0.0",
|
"@radix-ui/react-collapsible": "^1.0.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.0",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
"@radix-ui/react-id": "^1.0.0",
|
"@radix-ui/react-id": "^1.0.0",
|
||||||
"@radix-ui/react-popover": "^1.0.2",
|
"@radix-ui/react-popover": "^1.0.2",
|
||||||
"@radix-ui/react-radio-group": "^1.0.0",
|
"@radix-ui/react-radio-group": "^1.0.0",
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
"@storybook/react": "^6.5.13",
|
"@storybook/react": "^6.5.13",
|
||||||
"@storybook/testing-library": "^0.0.13",
|
"@storybook/testing-library": "^0.0.13",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "^18.0.9",
|
||||||
"@vitejs/plugin-react": "^2.1.0",
|
"@vitejs/plugin-react": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.12",
|
"autoprefixer": "^10.4.12",
|
||||||
"babel-loader": "^8.2.5",
|
"babel-loader": "^8.2.5",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"storybook-addon-designs": "^6.3.1",
|
"storybook-addon-designs": "^6.3.1",
|
||||||
"storybook-addon-next": "^1.6.9",
|
"storybook-addon-next": "^1.6.9",
|
||||||
"storybook-react-i18next": "^1.1.2",
|
"storybook-react-i18next": "^1.1.2",
|
||||||
"tailwindcss": "^3.2.1",
|
"tailwindcss": "^3.3.1",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"vite": "^4.1.2"
|
"vite": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
--cal-bg-success: #E2FBE8;
|
--cal-bg-success: #E2FBE8;
|
||||||
--cal-bg-attention: #FCEED8;
|
--cal-bg-attention: #FCEED8;
|
||||||
--cal-bg-error: #F9E3E2;
|
--cal-bg-error: #F9E3E2;
|
||||||
|
--cal-bg-dark-error: #752522;
|
||||||
|
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
@@ -216,6 +217,7 @@
|
|||||||
--cal-border: #D1D5DB;
|
--cal-border: #D1D5DB;
|
||||||
--cal-border-subtle:#E5E7EB;
|
--cal-border-subtle:#E5E7EB;
|
||||||
--cal-border-muted:#F3F4F6;
|
--cal-border-muted:#F3F4F6;
|
||||||
|
--cal-border-error: #aa2e26;
|
||||||
|
|
||||||
/* Content/Text */
|
/* Content/Text */
|
||||||
--cal-text-emphasis: #111827;
|
--cal-text-emphasis: #111827;
|
||||||
@@ -251,6 +253,7 @@
|
|||||||
--cal-bg-success: #E2FBE8;
|
--cal-bg-success: #E2FBE8;
|
||||||
--cal-bg-attention: #FCEED8;
|
--cal-bg-attention: #FCEED8;
|
||||||
--cal-bg-error: #F9E3E2;
|
--cal-bg-error: #F9E3E2;
|
||||||
|
--cal-bg-dark-error: #752522;
|
||||||
|
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
@@ -258,6 +261,7 @@
|
|||||||
--cal-border: #444444;
|
--cal-border: #444444;
|
||||||
--cal-border-subtle: #2B2B2B;
|
--cal-border-subtle: #2B2B2B;
|
||||||
--cal-border-muted: #1C1C1C;
|
--cal-border-muted: #1C1C1C;
|
||||||
|
--cal-border-error: #aa2e26;
|
||||||
|
|
||||||
/* Content/Text */
|
/* Content/Text */
|
||||||
--cal-text-emphasis: #F3F4F6;
|
--cal-text-emphasis: #F3F4F6;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "16.9.1",
|
"@types/node": "16.9.1",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "^18.0.9",
|
||||||
"typescript": "^4.9.4"
|
"typescript": "^4.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,10 @@ import type { ReactNode } from "react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
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";
|
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
||||||
@@ -19,6 +20,7 @@ type AppListCardProps = {
|
|||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
invalidCredential?: boolean;
|
invalidCredential?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
credentialOwner?: CredentialOwner;
|
||||||
} & ShouldHighlight;
|
} & ShouldHighlight;
|
||||||
|
|
||||||
const schema = z.object({ hl: z.string().optional() });
|
const schema = z.object({ hl: z.string().optional() });
|
||||||
@@ -36,6 +38,7 @@ export default function AppListCard(props: AppListCardProps) {
|
|||||||
isTemplate,
|
isTemplate,
|
||||||
invalidCredential,
|
invalidCredential,
|
||||||
children,
|
children,
|
||||||
|
credentialOwner,
|
||||||
} = props;
|
} = props;
|
||||||
const {
|
const {
|
||||||
data: { hl },
|
data: { hl },
|
||||||
@@ -65,7 +68,7 @@ export default function AppListCard(props: AppListCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
<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}
|
{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 grow flex-col gap-y-1 truncate">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
@@ -85,6 +88,21 @@ export default function AppListCard(props: AppListCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import { components } from "react-select";
|
|||||||
|
|
||||||
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
|
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
|
||||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||||
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
import { APP_NAME, EMBED_LIB_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
|
DialogFooter,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
HorizontalTabs,
|
HorizontalTabs,
|
||||||
Label,
|
Label,
|
||||||
@@ -38,13 +39,21 @@ const enum Theme {
|
|||||||
dark = "dark",
|
dark = "dark",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMBED_CAL_ORIGIN = WEBAPP_URL;
|
||||||
|
const EMBED_CAL_JS_URL = `${WEBAPP_URL}/embed/embed.js`;
|
||||||
|
|
||||||
type PreviewState = {
|
type PreviewState = {
|
||||||
inline: {
|
inline: {
|
||||||
width: string;
|
width: string;
|
||||||
height: string;
|
height: string;
|
||||||
};
|
};
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
floatingPopup: Record<string, string | boolean | undefined>;
|
floatingPopup: {
|
||||||
|
config?: {
|
||||||
|
layout: BookerLayouts;
|
||||||
|
};
|
||||||
|
[key: string]: string | boolean | undefined | Record<string, string>;
|
||||||
|
};
|
||||||
elementClick: Record<string, string>;
|
elementClick: Record<string, string>;
|
||||||
palette: {
|
palette: {
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
@@ -175,14 +184,24 @@ const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
|
|||||||
return code`
|
return code`
|
||||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
function MyComponent() {
|
export default function MyApp() {
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
(async function () {
|
(async function () {
|
||||||
const cal = await getCalApi();
|
const cal = await getCalApi();
|
||||||
${uiInstructionCode}
|
${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": ({
|
"floating-popup": ({
|
||||||
@@ -193,38 +212,60 @@ function MyComponent() {
|
|||||||
uiInstructionCode: string;
|
uiInstructionCode: string;
|
||||||
}) => {
|
}) => {
|
||||||
return code`
|
return code`
|
||||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
import { getCalApi } from "@calcom/embed-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
function MyComponent() {
|
export default function App() {
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
(async function () {
|
(async function () {
|
||||||
const cal = await getCalApi();
|
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
|
||||||
cal("floatingButton", ${floatingButtonArg});
|
cal("floatingButton", ${floatingButtonArg});
|
||||||
${uiInstructionCode}
|
${uiInstructionCode}
|
||||||
})();
|
})();
|
||||||
}, [])
|
}, [])
|
||||||
};`;
|
};`;
|
||||||
},
|
},
|
||||||
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
"element-click": ({
|
||||||
|
calLink,
|
||||||
|
uiInstructionCode,
|
||||||
|
previewState,
|
||||||
|
}: {
|
||||||
|
calLink: string;
|
||||||
|
uiInstructionCode: string;
|
||||||
|
previewState: PreviewState;
|
||||||
|
}) => {
|
||||||
return code`
|
return code`
|
||||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
import { getCalApi } from "@calcom/embed-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
function MyComponent() {
|
export default function App() {
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
(async function () {
|
(async function () {
|
||||||
const cal = await getCalApi();
|
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
|
||||||
${uiInstructionCode}
|
${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: {
|
HTML: {
|
||||||
inline: ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
inline: ({
|
||||||
|
calLink,
|
||||||
|
uiInstructionCode,
|
||||||
|
previewState,
|
||||||
|
}: {
|
||||||
|
calLink: string;
|
||||||
|
uiInstructionCode: string;
|
||||||
|
previewState: PreviewState;
|
||||||
|
}) => {
|
||||||
return code`Cal("inline", {
|
return code`Cal("inline", {
|
||||||
elementOrSelector:"#my-cal-inline",
|
elementOrSelector:"#my-cal-inline",
|
||||||
calLink: "${calLink}"
|
calLink: "${calLink}",
|
||||||
|
layout: "${previewState.layout}"
|
||||||
});
|
});
|
||||||
|
|
||||||
${uiInstructionCode}`;
|
${uiInstructionCode}`;
|
||||||
@@ -240,8 +281,22 @@ ${uiInstructionCode}`;
|
|||||||
return code`Cal("floatingButton", ${floatingButtonArg});
|
return code`Cal("floatingButton", ${floatingButtonArg});
|
||||||
${uiInstructionCode}`;
|
${uiInstructionCode}`;
|
||||||
},
|
},
|
||||||
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
"element-click": ({
|
||||||
return code`// Important: Make sure to add \`data-cal-link="${calLink}"\` attribute to the element you want to open Cal on 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}`;
|
${uiInstructionCode}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -298,6 +353,7 @@ const getEmbedTypeSpecificString = ({
|
|||||||
} else if (embedType === "floating-popup") {
|
} else if (embedType === "floating-popup") {
|
||||||
const floatingButtonArg = {
|
const floatingButtonArg = {
|
||||||
calLink,
|
calLink,
|
||||||
|
...(IS_SELF_HOSTED ? { calOrigin: EMBED_CAL_ORIGIN } : null),
|
||||||
...previewState.floatingPopup,
|
...previewState.floatingPopup,
|
||||||
};
|
};
|
||||||
return frameworkCodes[embedType]({
|
return frameworkCodes[embedType]({
|
||||||
@@ -710,13 +766,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
},
|
},
|
||||||
theme: Theme.auto,
|
theme: Theme.auto,
|
||||||
|
layout: BookerLayouts.MONTH_VIEW,
|
||||||
floatingPopup: {},
|
floatingPopup: {},
|
||||||
elementClick: {},
|
elementClick: {},
|
||||||
hideEventTypeDetails: false,
|
hideEventTypeDetails: false,
|
||||||
palette: {
|
palette: {
|
||||||
brandColor: "#000000",
|
brandColor: "#000000",
|
||||||
},
|
},
|
||||||
layout: BookerLayouts.MONTH_VIEW,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -778,8 +834,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
name: "ui",
|
name: "ui",
|
||||||
arg: {
|
arg: {
|
||||||
theme: previewState.theme,
|
theme: previewState.theme,
|
||||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
|
||||||
layout: previewState.layout,
|
layout: previewState.layout,
|
||||||
|
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||||
styles: {
|
styles: {
|
||||||
branding: {
|
branding: {
|
||||||
...previewState.palette,
|
...previewState.palette,
|
||||||
@@ -1000,7 +1056,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||||
<div className="whitespace-nowrap">Text color</div>
|
<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
|
<ColorPicker
|
||||||
className="w-[130px]"
|
className="w-[130px]"
|
||||||
popoverAlign="start"
|
popoverAlign="start"
|
||||||
@@ -1102,8 +1158,15 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreviewState((previewState) => {
|
setPreviewState((previewState) => {
|
||||||
|
const config = {
|
||||||
|
...(previewState.floatingPopup.config ?? {}),
|
||||||
|
layout: option.value,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
...previewState,
|
...previewState,
|
||||||
|
floatingPopup: {
|
||||||
|
config,
|
||||||
|
},
|
||||||
layout: option.value,
|
layout: option.value,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1144,8 +1207,12 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={router.query.embedTabName == "embed-preview" ? "block" : "hidden"} />
|
<div className={router.query.embedTabName == "embed-preview" ? "mt-2 block" : "hidden"} />
|
||||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
<DialogFooter
|
||||||
|
className="mt-10 flex flex-row-reverse gap-x-2"
|
||||||
|
showDivider
|
||||||
|
customDividerClassNames="w-2/3">
|
||||||
|
<DialogClose />
|
||||||
{tab.type === "code" ? (
|
{tab.type === "code" ? (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -1160,8 +1227,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
{t("copy_code")}
|
{t("copy_code")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<DialogClose />
|
</DialogFooter>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
|||||||
isCurrent
|
isCurrent
|
||||||
? "text-emphasis border-gray-900"
|
? "text-emphasis border-gray-900"
|
||||||
: "hover:border-default hover:text-default text-subtle border-transparent",
|
: "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
|
className
|
||||||
)}
|
)}
|
||||||
aria-current={isCurrent ? "page" : undefined}>
|
aria-current={isCurrent ? "page" : undefined}>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Script from "next/script";
|
|||||||
|
|
||||||
import "@calcom/embed-core/src/embed-iframe";
|
import "@calcom/embed-core/src/embed-iframe";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
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 { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||||
|
|
||||||
import type { AppProps } from "@lib/app-providers";
|
import type { AppProps } from "@lib/app-providers";
|
||||||
@@ -53,10 +53,6 @@ function PageWrapper(props: AppProps) {
|
|||||||
// Use the layout defined at the page level, if available
|
// Use the layout defined at the page level, if available
|
||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
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;
|
const path = router.asPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +66,7 @@ function PageWrapper(props: AppProps) {
|
|||||||
<DefaultSeo
|
<DefaultSeo
|
||||||
// Set canonical to https://cal.com or self-hosted URL
|
// Set canonical to https://cal.com or self-hosted URL
|
||||||
canonical={
|
canonical={
|
||||||
isCalcom
|
IS_CALCOM
|
||||||
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
|
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
|
||||||
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
|
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { IframeHTMLAttributes } from "react";
|
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 useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components";
|
import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components";
|
||||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
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 Shell from "@calcom/features/shell/Shell";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { App as AppType } from "@calcom/types/App";
|
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 { 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 { 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 */
|
/* These app slugs all require Google Cal to be installed */
|
||||||
|
|
||||||
@@ -60,11 +77,14 @@ const Component = ({
|
|||||||
}).format(price);
|
}).format(price);
|
||||||
|
|
||||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||||
const appCredentials = trpc.viewer.appCredentialsByType.useQuery(
|
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||||
|
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||||
{ appType: type },
|
{ appType: type },
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSettled(data) {
|
||||||
setExistingCredentials(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 (
|
return (
|
||||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||||
{hasDescriptionItems && (
|
{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 ? (
|
||||||
descriptionItems.map((descriptionItem, index) =>
|
descriptionItems.map((descriptionItem, index) =>
|
||||||
typeof descriptionItem === "object" ? (
|
typeof descriptionItem === "object" ? (
|
||||||
@@ -141,7 +161,7 @@ const Component = ({
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
{!appCredentials.isLoading ? (
|
{!appDbQuery.isLoading ? (
|
||||||
isGlobal ||
|
isGlobal ||
|
||||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
@@ -166,28 +186,19 @@ const Component = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{ type, variant, slug }} multiInstall {...props} />
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : existingCredentials.length > 0 ? (
|
) : showDisconnectIntegration ? (
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
buttonProps={{ color: "secondary" }}
|
buttonProps={{ color: "secondary" }}
|
||||||
label={t("disconnect")}
|
label={t("disconnect")}
|
||||||
credentialId={existingCredentials[0]}
|
credentialId={existingCredentials[0]}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
appCredentials.refetch();
|
appDbQuery.refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -206,15 +217,7 @@ const Component = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{type, variant, slug}} credentials={appDbQuery.data?.credentials} {...props} />
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -260,7 +263,7 @@ const Component = ({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</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">
|
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||||
{docs && (
|
{docs && (
|
||||||
<li>
|
<li>
|
||||||
@@ -269,7 +272,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-emphasis text-sm font-normal no-underline hover:underline"
|
className="text-emphasis text-sm font-normal no-underline hover:underline"
|
||||||
href={docs}>
|
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")}
|
{t("documentation")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -281,7 +284,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-emphasis font-normal no-underline hover:underline"
|
className="text-emphasis font-normal no-underline hover:underline"
|
||||||
href={website}>
|
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://", "")}
|
{website.replace("https://", "")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -293,7 +296,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-emphasis font-normal no-underline hover:underline"
|
className="text-emphasis font-normal no-underline hover:underline"
|
||||||
href={"mailto:" + email}>
|
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}
|
{email}
|
||||||
</a>
|
</a>
|
||||||
@@ -306,7 +309,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-emphasis font-normal no-underline hover:underline"
|
className="text-emphasis font-normal no-underline hover:underline"
|
||||||
href={tos}>
|
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")}
|
{t("terms_of_service")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -318,7 +321,7 @@ const Component = ({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-emphasis font-normal no-underline hover:underline"
|
className="text-emphasis font-normal no-underline hover:underline"
|
||||||
href={privacy}>
|
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")}
|
{t("privacy_policy")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -385,3 +388,107 @@ export default function App(props: {
|
|||||||
</Shell>
|
</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 ? (
|
{emptyStore ? (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={AlertCircle}
|
Icon={AlertCircle}
|
||||||
headline={t("no_apps")}
|
headline={isAdmin ? t("no_apps") : t("no_apps_configured")}
|
||||||
description={isAdmin ? "You can enable apps in the settings" : ""}
|
description={isAdmin ? t("enable_in_settings") : t("please_contact_admin")}
|
||||||
buttonText={isAdmin ? t("apps_settings") : ""}
|
buttonText={isAdmin ? t("apps_settings") : ""}
|
||||||
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,66 +1,117 @@
|
|||||||
import { z } from "zod";
|
import type {
|
||||||
|
DefaultEventLocationType,
|
||||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
EventLocationTypeFromApp,
|
||||||
|
LocationObject,
|
||||||
|
} from "@calcom/app-store/locations";
|
||||||
|
import { getEventLocationType, getTranslatedLocation } from "@calcom/app-store/locations";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Tooltip } from "@calcom/ui";
|
import { Tooltip } from "@calcom/ui";
|
||||||
import { Link } from "@calcom/ui/components/icon";
|
import { Link } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import type { Props } from "./pages/AvailabilityPage";
|
|
||||||
|
|
||||||
const excludeNullValues = (value: unknown) => !!value;
|
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 { t } = useLocale();
|
||||||
|
|
||||||
const renderLocations = locations.map((location, index) => {
|
return (
|
||||||
const eventLocationType = getEventLocationType(location.type);
|
<div className="my-2 me-2 flex w-full flex-col space-y-3 break-words">
|
||||||
if (!eventLocationType) {
|
<p>{t("select_on_next_step")}</p>
|
||||||
// It's possible that the location app got uninstalled
|
{locations.map(
|
||||||
return null;
|
(
|
||||||
|
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[];
|
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">
|
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm">
|
||||||
{filteredLocations}
|
{filteredLocations}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const reserveSlot = (slot: Slot) => {
|
const reserveSlot = (slot: Slot) => {
|
||||||
|
// Prevent double clicking
|
||||||
|
if (reserveSlotMutation.isLoading || reserveSlotMutation.isSuccess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
reserveSlotMutation.mutate({
|
reserveSlotMutation.mutate({
|
||||||
slotUtcStartDate: slot.time,
|
slotUtcStartDate: slot.time,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
@@ -132,8 +136,20 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
let slotFull, notEnoughSeats;
|
let slotFull, notEnoughSeats;
|
||||||
|
|
||||||
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
||||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
|
if (slot.attendees && bookingAttendees && seatsPerTimeSlot) {
|
||||||
notEnoughSeats = 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 (
|
return (
|
||||||
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||||
@@ -144,7 +160,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25",
|
"text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25",
|
||||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||||
)}>
|
)}
|
||||||
|
data-testid="time"
|
||||||
|
data-disabled="true">
|
||||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||||
{notEnoughSeats ? (
|
{notEnoughSeats ? (
|
||||||
<p className="text-sm">{t("not_enough_seats")}</p>
|
<p className="text-sm">{t("not_enough_seats")}</p>
|
||||||
@@ -165,14 +183,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
data-disabled="false">
|
data-disabled="false">
|
||||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||||
{!!seatsPerTimeSlot && (
|
{!!seatsPerTimeSlot && (
|
||||||
<p
|
<p className={`${colorClass} text-sm`}>
|
||||||
className={`${
|
|
||||||
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
|
|
||||||
? "text-rose-600"
|
|
||||||
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
|
|
||||||
? "text-yellow-500"
|
|
||||||
: "text-emerald-400"
|
|
||||||
} text-sm`}>
|
|
||||||
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
||||||
{seatsPerTimeSlot}{" "}
|
{seatsPerTimeSlot}{" "}
|
||||||
{t("seats_available", {
|
{t("seats_available", {
|
||||||
|
|||||||
@@ -1,162 +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 ">
|
|
||||||
{eventType.team?.parent?.name} {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 (
|
return (
|
||||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
<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" ? (
|
{paymentAppData.paymentOption === "HOLD" ? (
|
||||||
<>{props.t("no_show_fee_amount", params)}</>
|
<>{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 classNames from "@calcom/lib/classNames";
|
||||||
import { formatTime } from "@calcom/lib/date-fns";
|
import { formatTime } from "@calcom/lib/date-fns";
|
||||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||||
|
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
@@ -49,6 +50,8 @@ type BookingItemProps = BookingItem & {
|
|||||||
function BookingListItem(booking: BookingItemProps) {
|
function BookingListItem(booking: BookingItemProps) {
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
// Get user so we can determine 12/24 hour format preferences
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
|
const bookerUrl = useBookerUrl();
|
||||||
|
|
||||||
const user = query.data;
|
const user = query.data;
|
||||||
const {
|
const {
|
||||||
t,
|
t,
|
||||||
@@ -170,7 +173,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
id: "reschedule",
|
id: "reschedule",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
label: t("reschedule_booking"),
|
label: t("reschedule_booking"),
|
||||||
href: `/reschedule/${booking.uid}${
|
href: `${bookerUrl}/reschedule/${booking.uid}${
|
||||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
@@ -374,7 +377,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
|
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
|
||||||
{/* Time and Badges for mobile */}
|
{/* 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="flex w-full items-center justify-between sm:hidden">
|
||||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||||
<div className="text-subtle pr-2 text-sm">
|
<div className="text-subtle pr-2 text-sm">
|
||||||
@@ -423,7 +426,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
<span> </span>
|
<span> </span>
|
||||||
|
|
||||||
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
|
{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")}
|
{t("pending_payment")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
placeholder={t("cancellation_reason_placeholder")}
|
placeholder={t("cancellation_reason_placeholder")}
|
||||||
value={cancellationReason}
|
value={cancellationReason}
|
||||||
onChange={(e) => setCancellationReason(e.target.value)}
|
onChange={(e) => setCancellationReason(e.target.value)}
|
||||||
className="mt-2 mb-4 w-full "
|
className="mb-4 mt-2 w-full "
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function SkeletonItem() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
<SkeletonText className="h-6 w-16" />
|
<SkeletonText className="h-6 w-16" />
|
||||||
<SkeletonText className="h-6 w-32" />
|
<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,
|
getHumanReadableLocationValue,
|
||||||
getMessageForOrganizer,
|
getMessageForOrganizer,
|
||||||
LocationType,
|
LocationType,
|
||||||
|
OrganizerDefaultConferencingAppType,
|
||||||
} from "@calcom/app-store/locations";
|
} from "@calcom/app-store/locations";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
@@ -33,12 +34,12 @@ interface ISetLocationDialog {
|
|||||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||||
selection?: LocationOption;
|
selection?: LocationOption;
|
||||||
booking?: BookingItem;
|
booking?: BookingItem;
|
||||||
isTeamEvent?: boolean;
|
|
||||||
defaultValues?: LocationObject[];
|
defaultValues?: LocationObject[];
|
||||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isOpenDialog: boolean;
|
isOpenDialog: boolean;
|
||||||
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
||||||
setEditingLocationType?: (param: string) => void;
|
setEditingLocationType?: (param: string) => void;
|
||||||
|
teamId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LocationInput = (props: {
|
const LocationInput = (props: {
|
||||||
@@ -78,15 +79,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
saveLocation,
|
saveLocation,
|
||||||
selection,
|
selection,
|
||||||
booking,
|
booking,
|
||||||
isTeamEvent,
|
|
||||||
setShowLocationModal,
|
setShowLocationModal,
|
||||||
isOpenDialog,
|
isOpenDialog,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
setSelectedLocation,
|
setSelectedLocation,
|
||||||
setEditingLocationType,
|
setEditingLocationType,
|
||||||
|
teamId,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const locationsQuery = trpc.viewer.locationOptions.useQuery();
|
const locationsQuery = trpc.viewer.locationOptions.useQuery({ teamId });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -102,6 +103,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
locationType: z.string(),
|
locationType: z.string(),
|
||||||
phone: z.string().optional().nullable(),
|
phone: z.string().optional().nullable(),
|
||||||
locationAddress: z.string().optional(),
|
locationAddress: z.string().optional(),
|
||||||
|
credentialId: z.number().optional(),
|
||||||
locationLink: z
|
locationLink: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -172,8 +174,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(defaultLocation);
|
|
||||||
|
|
||||||
const LocationOptions = (() => {
|
const LocationOptions = (() => {
|
||||||
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
|
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
|
||||||
if (!eventLocationType.variable) {
|
if (!eventLocationType.variable) {
|
||||||
@@ -259,7 +259,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
|
|
||||||
{booking && (
|
{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">
|
<p className="text-emphasis mb-2 ml-1 text-sm">
|
||||||
{getHumanReadableLocationValue(booking.location, t)}
|
{getHumanReadableLocationValue(booking.location, t)}
|
||||||
</p>
|
</p>
|
||||||
@@ -297,6 +297,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.credentialId) {
|
||||||
|
details = { ...details, credentialId: values.credentialId };
|
||||||
|
}
|
||||||
saveLocation(newLocation, details);
|
saveLocation(newLocation, details);
|
||||||
setShowLocationModal(false);
|
setShowLocationModal(false);
|
||||||
setSelectedLocation?.(undefined);
|
setSelectedLocation?.(undefined);
|
||||||
@@ -311,8 +314,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
query={locationsQuery}
|
query={locationsQuery}
|
||||||
success={({ data }) => {
|
success={({ data }) => {
|
||||||
if (!data.length) return null;
|
if (!data.length) return null;
|
||||||
const locationOptions = [...data].filter((option) => {
|
const locationOptions = [...data].map((option) => {
|
||||||
return !isTeamEvent ? option.label !== "Conferencing" : true;
|
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) {
|
if (booking) {
|
||||||
locationOptions.map((location) =>
|
locationOptions.map((location) =>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<DialogHeader title={t("send_reschedule_request")} />
|
<DialogHeader title={t("send_reschedule_request")} />
|
||||||
<p className="text-subtle text-sm">{t("reschedule_modal_description")}</p>
|
<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")}
|
{t("reason_for_reschedule_request")}
|
||||||
<span className="text-subtle font-normal"> (Optional)</span>
|
<span className="text-subtle font-normal"> (Optional)</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const ErrorDebugPanel: React.FC<{ error: Props["error"]; children?: never }> = (
|
|||||||
{debugMap.map(([key, value]) => {
|
{debugMap.map(([key, value]) => {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
return (
|
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>
|
<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>
|
<dd className="text-emphasis mt-1 text-sm sm:col-span-2 sm:mt-0">{value}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
|||||||
backgroundImage: "url(/calendar-preview.svg)",
|
backgroundImage: "url(/calendar-preview.svg)",
|
||||||
}}>
|
}}>
|
||||||
<div className="m-auto flex items-center justify-center self-stretch">
|
<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-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>
|
<p className="text-default ml-1.5 text-[10px] font-normal">8 - 10 AM</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { EventNameObjectType } from "@calcom/core/event";
|
import type { EventNameObjectType } from "@calcom/core/event";
|
||||||
import { getEventName } from "@calcom/core/event";
|
import { getEventName } from "@calcom/core/event";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
allowDisablingHostConfirmationEmails,
|
allowDisablingHostConfirmationEmails,
|
||||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||||
|
import { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema"
|
||||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
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">) => {
|
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||||
|
const { data: user } = trpc.viewer.me.useQuery();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -49,6 +52,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
const bookingFields: Prisma.JsonObject = {};
|
const bookingFields: Prisma.JsonObject = {};
|
||||||
|
|
||||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
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 }) => {
|
eventType.bookingFields.forEach(({ name }) => {
|
||||||
bookingFields[name] = name + " input";
|
bookingFields[name] = name + " input";
|
||||||
@@ -81,6 +87,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
hidden: !enabled,
|
hidden: !enabled,
|
||||||
|
editable: (!enabled ? "system-but-hidden" : "system-but-optional") as z.infer<typeof EditableSchema>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return field;
|
return field;
|
||||||
@@ -153,7 +160,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
color="minimal"
|
color="minimal"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="edit custom name"
|
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)}>
|
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -162,7 +169,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
</div>
|
</div>
|
||||||
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
|
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
|
||||||
<div>
|
<div>
|
||||||
<BookerLayoutSelector fallbackToUserSettings />
|
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||||
</div>
|
</div>
|
||||||
<hr className="border-subtle" />
|
<hr className="border-subtle" />
|
||||||
<FormBuilder
|
<FormBuilder
|
||||||
@@ -216,7 +223,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
onChange(e ? value : "");
|
onChange(e ? value : "");
|
||||||
}}>
|
}}>
|
||||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
{/* 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
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label={t("redirect_success_booking")}
|
label={t("redirect_success_booking")}
|
||||||
@@ -268,7 +275,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
color="minimal"
|
color="minimal"
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
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"
|
aria-label="copy link"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(placeholderHashedLink);
|
navigator.clipboard.writeText(placeholderHashedLink);
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
|||||||
|
|
||||||
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
|
const { data: eventTypeApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||||
extendsFeature: "EventType",
|
extendsFeature: "EventType",
|
||||||
|
teamId: eventType.team?.id || eventType.parent?.teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const methods = useFormContext<FormValues>();
|
const methods = useFormContext<FormValues>();
|
||||||
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
const installedApps =
|
||||||
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
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 allAppsData = methods.watch("metadata")?.apps || {};
|
||||||
|
|
||||||
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
||||||
@@ -62,11 +66,54 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
t("locked_fields_member_description")
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="before:border-0">
|
<div className="before:border-0">
|
||||||
{!installedApps?.length && isManagedEventType && (
|
{isManagedEventType && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="neutral"
|
severity="neutral"
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
@@ -92,15 +139,20 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{installedApps?.map((app) => (
|
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||||
<EventTypeAppCard
|
{installedApps.map((app) => {
|
||||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
if (!app.teams.length)
|
||||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
return (
|
||||||
key={app.slug}
|
<EventTypeAppCard
|
||||||
app={app}
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
eventType={eventType}
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||||
/>
|
key={app.slug}
|
||||||
))}
|
app={app}
|
||||||
|
eventType={eventType}
|
||||||
|
{...shouldLockDisableProps("apps")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!shouldLockDisableProps("apps").disabled && (
|
{!shouldLockDisableProps("apps").disabled && (
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-4 lg: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">
|
<div className="w-full">
|
||||||
<Label htmlFor="beforeBufferTime">
|
<Label htmlFor="beforeBufferTime">
|
||||||
{t("before_event")}
|
{t("before_event")}
|
||||||
@@ -245,7 +245,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="w-full">
|
||||||
<Label htmlFor="minimumBookingNotice">
|
<Label htmlFor="minimumBookingNotice">
|
||||||
{t("minimum_booking_notice")}
|
{t("minimum_booking_notice")}
|
||||||
@@ -416,7 +416,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{period.type === "RANGE" && (
|
{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
|
<Controller
|
||||||
name="periodDates"
|
name="periodDates"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -437,7 +437,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -117,8 +117,16 @@ export const EventSetupTab = (
|
|||||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
||||||
|
|
||||||
const locationOptions = props.locationOptions.filter((option) => {
|
const locationOptions = props.locationOptions.map((locationOption) => {
|
||||||
return !team ? option.label !== "Conferencing" : true;
|
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) => ({
|
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||||
@@ -284,7 +292,7 @@ export const EventSetupTab = (
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={`${location.type}${index}`}
|
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 justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<img
|
<img
|
||||||
@@ -298,7 +306,7 @@ export const EventSetupTab = (
|
|||||||
)}
|
)}
|
||||||
alt={`${eventLocationType.label} logo`}
|
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>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
@@ -332,7 +340,7 @@ export const EventSetupTab = (
|
|||||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||||
) && (
|
) && (
|
||||||
<div className="text-default flex text-sm">
|
<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">
|
<Trans i18nKey="event_type_requres_google_cal">
|
||||||
<p>
|
<p>
|
||||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||||
@@ -522,7 +530,6 @@ export const EventSetupTab = (
|
|||||||
|
|
||||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||||
<EditLocationDialog
|
<EditLocationDialog
|
||||||
isTeamEvent={!!team}
|
|
||||||
isOpenDialog={showLocationModal}
|
isOpenDialog={showLocationModal}
|
||||||
setShowLocationModal={setShowLocationModal}
|
setShowLocationModal={setShowLocationModal}
|
||||||
saveLocation={saveLocation}
|
saveLocation={saveLocation}
|
||||||
@@ -545,6 +552,7 @@ export const EventSetupTab = (
|
|||||||
}
|
}
|
||||||
setSelectedLocation={setSelectedLocation}
|
setSelectedLocation={setSelectedLocation}
|
||||||
setEditingLocationType={setEditingLocationType}
|
setEditingLocationType={setEditingLocationType}
|
||||||
|
teamId={eventType.team?.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useMemo, useState, Suspense } from "react";
|
|||||||
import type { UseFormReturn } from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
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 Shell from "@calcom/features/shell/Shell";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
@@ -210,7 +211,7 @@ function EventTypeSingleLayout({
|
|||||||
}
|
}
|
||||||
if (isManagedEventType || isChildrenManagedEventType) {
|
if (isManagedEventType || isChildrenManagedEventType) {
|
||||||
// Removing apps and workflows for manageg event types by admins v1
|
// Removing apps and workflows for manageg event types by admins v1
|
||||||
navigation.splice(-2, 1);
|
navigation.splice(0, 1);
|
||||||
} else {
|
} else {
|
||||||
navigation.push({
|
navigation.push({
|
||||||
name: "webhooks",
|
name: "webhooks",
|
||||||
@@ -233,9 +234,11 @@ function EventTypeSingleLayout({
|
|||||||
formMethods,
|
formMethods,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
const orgBranding = useOrgBrandingValues();
|
||||||
eventType.slug
|
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 embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
|
||||||
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
||||||
@@ -369,7 +372,7 @@ function EventTypeSingleLayout({
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<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
|
<Skeleton
|
||||||
as={Label}
|
as={Label}
|
||||||
htmlFor="hiddenSwitch"
|
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="">
|
<div className="">
|
||||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||||
<li className="flex items-center whitespace-nowrap">
|
<li className="flex items-center whitespace-nowrap">
|
||||||
<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" />
|
<SkeletonText className="h-4 w-12" />
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center whitespace-nowrap">
|
<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" />
|
<SkeletonText className="h-4 w-12" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const AppConnectionItem = (props: IAppConnectionItem) => {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
disabled={installed}
|
disabled={installed}
|
||||||
type="button"
|
type="button"
|
||||||
|
loading={buttonProps?.isLoading}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// Save cookie key to return url step
|
// Save cookie key to return url step
|
||||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
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 { nextStep } = props;
|
||||||
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
|
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
|
||||||
const { t } = useLocale();
|
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(
|
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
|
||||||
(item) => item.calendars && item.calendars?.length > 0
|
(item) => item.calendars && item.calendars?.length > 0
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
|||||||
const { data: queryConnectedVideoApps, isLoading } = trpc.viewer.integrations.useQuery({
|
const { data: queryConnectedVideoApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||||
variant: "conferencing",
|
variant: "conferencing",
|
||||||
onlyInstalled: false,
|
onlyInstalled: false,
|
||||||
|
sortByMostPopular: true,
|
||||||
});
|
});
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||||
(item) => item.credentialIds.length > 0
|
(item) => item.userCredentialIds.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +39,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
|||||||
title={item.name}
|
title={item.name}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
logo={item.logo}
|
logo={item.logo}
|
||||||
installed={item.credentialIds.length > 0}
|
installed={item.userCredentialIds.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -22,15 +22,13 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||||||
const { nextStep } = props;
|
const { nextStep } = props;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
let queryAvailability;
|
|
||||||
if (defaultScheduleId) {
|
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||||
queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
{ scheduleId: defaultScheduleId! },
|
||||||
{ scheduleId: defaultScheduleId },
|
{
|
||||||
{
|
enabled: router.isReady && !!defaultScheduleId,
|
||||||
enabled: router.isReady,
|
}
|
||||||
}
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const availabilityForm = useForm({
|
const availabilityForm = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
|
|||||||
|
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
@@ -75,14 +75,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
|||||||
{...form.register("password")}
|
{...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"
|
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} />
|
<TwoFactor center={false} />
|
||||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<Button type="submit" className="ms-2 me-2" disabled={isDisabling}>
|
<Button type="submit" className="me-2 ms-2" disabled={isDisabling}>
|
||||||
{t("disable")}
|
{t("disable")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
<Button color="secondary" onClick={onCancel}>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati
|
|||||||
|
|
||||||
return (
|
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>
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { BaseSyntheticEvent } from "react";
|
import type { BaseSyntheticEvent } from "react";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
|
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogContent, Form } from "@calcom/ui";
|
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 (
|
return (
|
||||||
<Dialog open={true}>
|
<Dialog open={true}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -178,19 +190,19 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
|||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ms-2 me-2"
|
className="me-2 ms-2"
|
||||||
onClick={handleSetup}
|
onClick={handleSetup}
|
||||||
disabled={password.length === 0 || isSubmitting}>
|
disabled={password.length === 0 || isSubmitting}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
<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")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<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")}
|
{t("enable")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
|||||||
|
|
||||||
return (
|
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>
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">{t("2fa")}</h2>
|
<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">
|
<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" />
|
<Shield className="text-inverted h-6 w-6" />
|
||||||
</div>
|
</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">
|
<h3 className="font-cal text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
|
|||||||
|
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||||
<Form form={form} handleSubmit={handleDisable}>
|
<Form form={form} handleSubmit={handleDisable}>
|
||||||
<div className="mb-4">
|
<div className="mb-8">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
labelProps={{
|
labelProps={{
|
||||||
className: "block text-sm font-medium text-default",
|
className: "block text-sm font-medium text-default",
|
||||||
@@ -82,21 +82,19 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
{...form.register("password")}
|
{...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"
|
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} />
|
<TwoFactor center={false} />
|
||||||
|
|
||||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<DialogFooter showDivider 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>
|
|
||||||
<Button color="secondary" onClick={onCancel}>
|
<Button color="secondary" onClick={onCancel}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button type="submit" className="me-2 ms-2" data-testid="disable-2fa" disabled={isDisabling}>
|
||||||
|
{t("disable")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { BaseSyntheticEvent } from "react";
|
import type { BaseSyntheticEvent } from "react";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
|
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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";
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||||
@@ -150,20 +162,20 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
</WithStep>
|
</WithStep>
|
||||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
<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
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={dataUri} alt="" />
|
<img src={dataUri} alt="" />
|
||||||
}
|
}
|
||||||
</div>
|
</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}
|
{secret}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<Form handleSubmit={handleEnable} form={form}>
|
<Form handleSubmit={handleEnable} form={form}>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<div className="mb-4">
|
<div className="-mt-4 pb-2">
|
||||||
<TwoFactor center />
|
<TwoFactor center />
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
@@ -173,11 +185,14 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</WithStep>
|
</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}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ms-2 me-2"
|
className="me-2 ms-2"
|
||||||
onClick={handleSetup}
|
onClick={handleSetup}
|
||||||
disabled={password.length === 0 || isSubmitting}>
|
disabled={password.length === 0 || isSubmitting}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
@@ -187,20 +202,17 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
data-testid="goto-otp-screen"
|
data-testid="goto-otp-screen"
|
||||||
className="ms-2 me-2"
|
className="me-2 ms-2"
|
||||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<Button type="submit" className="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")}
|
{t("enable")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
</DialogFooter>
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
|||||||
<Label htmlFor="username" className={classNames(longWebsiteUrl && "mb-0")}>
|
<Label htmlFor="username" className={classNames(longWebsiteUrl && "mb-0")}>
|
||||||
<span className="block">{t("username")}</span>
|
<span className="block">{t("username")}</span>
|
||||||
{longWebsiteUrl && (
|
{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}
|
{process.env.NEXT_PUBLIC_WEBSITE_URL}
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
|
|
||||||
|
|
||||||
const ChooseLicense = (
|
const ChooseLicense = (
|
||||||
props: {
|
props: {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -50,23 +49,19 @@ const ChooseLicense = (
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Item>
|
</RadioGroup.Item>
|
||||||
<RadioGroup.Item value="EE">
|
<RadioGroup.Item value="EE" disabled>
|
||||||
<div
|
<Link href="https://cal.com/sales" target="_blank">
|
||||||
className={classNames(
|
<div className={classNames("bg-default h-full cursor-pointer space-y-2 rounded-md border p-4")}>
|
||||||
"bg-default cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
|
<h2 className="font-cal text-emphasis text-xl">{t("custom_plan")}</h2>
|
||||||
value === "EE" && "ring-2 ring-black"
|
<p className="font-medium text-green-800">{t("contact_sales")}</p>
|
||||||
)}>
|
<p className="text-subtle">Build on top of Cal.com</p>
|
||||||
<h2 className="font-cal text-emphasis text-xl">{t("ee_enterprise_license")}</h2>
|
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
||||||
<p className="font-medium text-green-800">
|
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||||
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
|
<li>{t("repackage_rebrand_resell")}</li>
|
||||||
</p>
|
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||||
<p className="text-subtle">{t("enterprise_license_includes")}</p>
|
</ul>
|
||||||
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
</div>
|
||||||
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
</Link>
|
||||||
<li>{t("repackage_rebrand_resell")}</li>
|
|
||||||
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</RadioGroup.Item>
|
</RadioGroup.Item>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const StepDone = (props: {
|
|||||||
<Check className="text-inverted dark:bg-default dark:text-default inline-block h-10 w-10" />
|
<Check className="text-inverted dark:bg-default dark:text-default inline-block h-10 w-10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[420px] text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||||
@@ -12,27 +11,23 @@ type TeamType = NonNullable<TeamWithMembers>;
|
|||||||
type MembersType = TeamType["members"];
|
type MembersType = TeamType["members"];
|
||||||
type MemberType = MembersType[number] & { safeBio: string | null };
|
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 Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isBioEmpty = !member.bio || !member.bio.replace("<p><br></p>", "").length;
|
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
|
// We don't want to forward orgSlug and user which are route params to the next route
|
||||||
const { slug: _slug, ...queryParamsToForward } = router.query;
|
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = router.query;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
|
<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">
|
<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
|
<Avatar size="md" alt={member.name || ""} imageSrc={"/" + member.username + "/avatar.png"} />
|
||||||
size="md"
|
<section className="mt-2 line-clamp-4 w-full space-y-1">
|
||||||
alt={member.name || ""}
|
|
||||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
|
||||||
/>
|
|
||||||
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
|
||||||
<p className="text-default font-medium">{member.name}</p>
|
<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 ? (
|
{!isBioEmpty ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const EditableHeading = function EditableHeading({
|
|||||||
<label className="min-w-8 relative inline-block">
|
<label className="min-w-8 relative inline-block">
|
||||||
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
||||||
{!isEditing && isReady && (
|
{!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
|
<input
|
||||||
{...passThroughProps}
|
{...passThroughProps}
|
||||||
@@ -31,7 +31,7 @@ const EditableHeading = function EditableHeading({
|
|||||||
value={value}
|
value={value}
|
||||||
required
|
required
|
||||||
className={classNames(
|
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) => {
|
onFocus={(e) => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function InfoBadge({ content }: { content: string }) {
|
|||||||
<>
|
<>
|
||||||
<Tooltip side="top" content={content}>
|
<Tooltip side="top" content={content}>
|
||||||
<span title={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>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function ModalContainer(
|
|||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
return (
|
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}>
|
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { debounce, noop } from "lodash";
|
import { debounce, noop } from "lodash";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import type { RefCallback } from "react";
|
import type { RefCallback } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -45,6 +47,7 @@ const obtainNewUsernameChangeCondition = ({
|
|||||||
|
|
||||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const { data: session, update } = useSession();
|
||||||
const {
|
const {
|
||||||
currentUsername,
|
currentUsername,
|
||||||
setCurrentUsername = noop,
|
setCurrentUsername = noop,
|
||||||
@@ -93,6 +96,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
onSuccessMutation && (await onSuccessMutation());
|
onSuccessMutation && (await onSuccessMutation());
|
||||||
|
await update();
|
||||||
setOpenDialogSaveUsername(false);
|
setOpenDialogSaveUsername(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -116,7 +120,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
|
|
||||||
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
|
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
|
||||||
inputUsernameValue || usernameFromStripe
|
inputUsernameValue || usernameFromStripe
|
||||||
}&action=${usernameChangeCondition}&callbackUrl=${router.asPath}`;
|
}&action=${usernameChangeCondition}&callbackUrl=${WEBAPP_URL}${router.asPath}`;
|
||||||
|
|
||||||
const ActionButtons = () => {
|
const ActionButtons = () => {
|
||||||
if (paymentRequired) {
|
if (paymentRequired) {
|
||||||
@@ -224,7 +228,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
}}
|
}}
|
||||||
data-testid="username-input"
|
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
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mx-2 py-2",
|
"mx-2 py-2",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { debounce, noop } from "lodash";
|
import { debounce, noop } from "lodash";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import type { RefCallback } from "react";
|
import type { RefCallback } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ interface ICustomUsernameProps {
|
|||||||
|
|
||||||
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
|
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const { data: session, update } = useSession();
|
||||||
const {
|
const {
|
||||||
currentUsername,
|
currentUsername,
|
||||||
setCurrentUsername = noop,
|
setCurrentUsername = noop,
|
||||||
@@ -69,6 +71,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
onSuccessMutation && (await onSuccessMutation());
|
onSuccessMutation && (await onSuccessMutation());
|
||||||
setOpenDialogSaveUsername(false);
|
setOpenDialogSaveUsername(false);
|
||||||
setCurrentUsername(inputUsernameValue);
|
setCurrentUsername(inputUsernameValue);
|
||||||
|
await update();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
onErrorMutation && onErrorMutation(error);
|
onErrorMutation && onErrorMutation(error);
|
||||||
@@ -80,7 +83,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
|
|
||||||
const ActionButtons = () => {
|
const ActionButtons = () => {
|
||||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpenDialogSaveUsername(true)}
|
onClick={() => setOpenDialogSaveUsername(true)}
|
||||||
@@ -135,7 +138,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
/>
|
/>
|
||||||
{currentUsername !== inputUsernameValue && (
|
{currentUsername !== inputUsernameValue && (
|
||||||
<div className="absolute right-[2px] top-6 flex flex-row">
|
<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" /> : <></>}
|
{usernameIsAvailable ? <Check className="h-4 w-4" /> : <></>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,23 +159,23 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
<DialogContent type="confirmation" Icon={Edit2} title={t("confirm_username_change_dialog_title")}>
|
<DialogContent type="confirmation" Icon={Edit2} title={t("confirm_username_change_dialog_title")}>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<div className="mb-4 w-full pt-1">
|
<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="bg-subtle flex w-full flex-wrap gap-6 rounded-sm px-2 py-3 text-sm">
|
||||||
<div className="flex-1 px-2">
|
<div>
|
||||||
<p className="text-subtle">{t("current_username")}</p>
|
<p className="text-subtle">{t("current_username")}</p>
|
||||||
<p className="text-emphasis mt-1" data-testid="current-username">
|
<p className="text-emphasis mt-1" data-testid="current-username">
|
||||||
{currentUsername}
|
{currentUsername}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-6 flex-1">
|
<div>
|
||||||
<p className="text-subtle" data-testid="new-username">
|
<p className="text-subtle" data-testid="new-username">
|
||||||
{t("new_username")}
|
{t("new_username")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-emphasis">{inputUsernameValue}</p>
|
<p className="text-emphasis mt-1">{inputUsernameValue}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={updateUsernameMutation.isLoading}
|
loading={updateUsernameMutation.isLoading}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRouter } from "next/router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
@@ -24,9 +24,9 @@ interface UsernameAvailabilityFieldProps {
|
|||||||
function useUserNamePrefix(organization: RouterOutputs["viewer"]["me"]["organization"]): string {
|
function useUserNamePrefix(organization: RouterOutputs["viewer"]["me"]["organization"]): string {
|
||||||
return organization
|
return organization
|
||||||
? organization.slug
|
? organization.slug
|
||||||
? `${organization.slug}.${subdomainSuffix()}/`
|
? getOrgFullDomain(organization.slug, { protocol: false })
|
||||||
: organization.metadata && organization.metadata.requestedSlug
|
: organization.metadata && organization.metadata.requestedSlug
|
||||||
? `${organization.metadata.requestedSlug}.${subdomainSuffix()}/`
|
? 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://", "")
|
||||||
: process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "");
|
: process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
|||||||
name="location"
|
name="location"
|
||||||
id="location-select"
|
id="location-select"
|
||||||
components={{
|
components={{
|
||||||
Option: (props) => (
|
Option: (props) => {
|
||||||
<components.Option {...props}>
|
return (
|
||||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
<components.Option {...props}>
|
||||||
</components.Option>
|
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||||
),
|
</components.Option>
|
||||||
|
);
|
||||||
|
},
|
||||||
SingleValue: (props) => (
|
SingleValue: (props) => (
|
||||||
<components.SingleValue {...props}>
|
<components.SingleValue {...props}>
|
||||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
<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 ReactSelect, { components } from "react-select";
|
||||||
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||||
|
|
||||||
export type SelectProps<
|
export type SelectProps<
|
||||||
Option,
|
Option,
|
||||||
@@ -30,7 +30,7 @@ function Select<
|
|||||||
Group extends GroupBase<Option> = GroupBase<Option>
|
Group extends GroupBase<Option> = GroupBase<Option>
|
||||||
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
|
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
|
||||||
const [mounted, setMounted] = useState<boolean>(false);
|
const [mounted, setMounted] = useState<boolean>(false);
|
||||||
const { resolvedTheme, forcedTheme } = useTheme();
|
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||||
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
||||||
const darkThemeColors = {
|
const darkThemeColors = {
|
||||||
/** Dark Theme starts */
|
/** 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;
|
||||||
|
};
|
||||||
@@ -48,6 +48,12 @@ type AppPropsWithChildren = AppProps & {
|
|||||||
children: ReactNode;
|
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) => {
|
const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
||||||
/**
|
/**
|
||||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||||
@@ -87,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
|
// 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
|
// 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
|
// 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";
|
const isEmbedMode = typeof embedNamespace === "string";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,10 +164,10 @@ function getThemeProviderProps({
|
|||||||
? ThemeSupport.None
|
? ThemeSupport.None
|
||||||
: ThemeSupport.App;
|
: ThemeSupport.App;
|
||||||
|
|
||||||
const isBookingPageThemSupportRequired = themeSupport === ThemeSupport.Booking;
|
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||||
const themeBasis = props.themeBasis;
|
const themeBasis = props.themeBasis;
|
||||||
|
|
||||||
if ((isBookingPageThemSupportRequired || isEmbedMode) && !themeBasis) {
|
if ((isBookingPageThemeSupportRequired || isEmbedMode) && !themeBasis) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
|
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
|
||||||
);
|
);
|
||||||
@@ -184,7 +190,7 @@ function getThemeProviderProps({
|
|||||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||||
: themeSupport === ThemeSupport.App
|
: themeSupport === ThemeSupport.App
|
||||||
? "app-theme"
|
? "app-theme"
|
||||||
: isBookingPageThemSupportRequired
|
: isBookingPageThemeSupportRequired
|
||||||
? `booking-theme${appearanceIdSuffix}`
|
? `booking-theme${appearanceIdSuffix}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import type { Maybe } from "@calcom/trpc/server";
|
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 {
|
export function getLocaleFromHeaders(req: IncomingMessage): string {
|
||||||
let preferredLocale: string | null | undefined;
|
let preferredLocale: string | null | undefined;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { collectEvents } from "next-collect/server";
|
|||||||
import type { NextMiddleware } from "next/server";
|
import type { NextMiddleware } from "next/server";
|
||||||
import { NextResponse, userAgent } from "next/server";
|
import { NextResponse, userAgent } from "next/server";
|
||||||
|
|
||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
||||||
import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { isIpInBanlist } from "@calcom/lib/getIP";
|
import { isIpInBanlist } from "@calcom/lib/getIP";
|
||||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||||
@@ -11,19 +10,6 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
|
|||||||
const middleware: NextMiddleware = async (req) => {
|
const middleware: NextMiddleware = async (req) => {
|
||||||
const url = req.nextUrl;
|
const url = req.nextUrl;
|
||||||
const requestHeaders = new Headers(req.headers);
|
const requestHeaders = new Headers(req.headers);
|
||||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.get("host") ?? "");
|
|
||||||
/**
|
|
||||||
* We are using env variable to toggle new-booker because using flags would be an unnecessary delay for booking pages
|
|
||||||
* Also, we can't easily identify the booker page requests here(to just fetch the flags for those requests)
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Make sure we are in the presence of an organization
|
|
||||||
if (isValidOrgDomain && url.pathname === "/") {
|
|
||||||
// In the presence of an organization, cover its profile page at "/"
|
|
||||||
// rewrites for org profile page using team profile page
|
|
||||||
url.pathname = `/org/${currentOrgDomain}`;
|
|
||||||
return NextResponse.rewrite(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isIpInBanlist(req) && url.pathname !== "/api/nope") {
|
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
|
// DDOS Prevention: Immediately end request with no response - Avoids a redirect as well initiated by NextAuth on invalid callback
|
||||||
@@ -83,26 +69,6 @@ const middleware: NextMiddleware = async (req) => {
|
|||||||
requestHeaders.set("x-csp-enforce", "true");
|
requestHeaders.set("x-csp-enforce", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidOrgDomain) {
|
|
||||||
// Match /:slug to determine if it corresponds to org subteam slug or org user slug
|
|
||||||
const slugs = /^\/([^/]+)(\/[^/]+)?$/.exec(url.pathname);
|
|
||||||
// In the presence of an organization, if not team profile, a user or team is being accessed
|
|
||||||
if (slugs) {
|
|
||||||
const [_, teamName, eventType] = slugs;
|
|
||||||
// Fetch the corresponding subteams for the entered organization
|
|
||||||
const getSubteams = await fetch(`${WEBAPP_URL}/api/organizations/${currentOrgDomain}/subteams`);
|
|
||||||
if (getSubteams.ok) {
|
|
||||||
const data = await getSubteams.json();
|
|
||||||
// Treat entered slug as a team if found in the subteams fetched
|
|
||||||
if (data.slugs.includes(teamName)) {
|
|
||||||
// Rewriting towards /team/:slug to bring up the team profile within the org
|
|
||||||
url.pathname = `/team/${teamName}${eventType ?? ""}`;
|
|
||||||
return NextResponse.rewrite(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next({
|
return NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
|
|||||||
+141
-107
@@ -1,10 +1,15 @@
|
|||||||
require("dotenv").config({ path: "../../.env" });
|
require("dotenv").config({ path: "../../.env" });
|
||||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const glob = require("glob");
|
|
||||||
const englishTranslation = require("./public/static/locales/en/common.json");
|
const englishTranslation = require("./public/static/locales/en/common.json");
|
||||||
const { withAxiom } = require("next-axiom");
|
const { withAxiom } = require("next-axiom");
|
||||||
const { i18n } = require("./next-i18next.config");
|
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.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
||||||
@@ -70,7 +75,6 @@ const informAboutDuplicateTranslations = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
informAboutDuplicateTranslations();
|
informAboutDuplicateTranslations();
|
||||||
|
|
||||||
const plugins = [];
|
const plugins = [];
|
||||||
if (process.env.ANALYZE === "true") {
|
if (process.env.ANALYZE === "true") {
|
||||||
// only load dependency if env `ANALYZE` was set
|
// only load dependency if env `ANALYZE` was set
|
||||||
@@ -81,17 +85,45 @@ if (process.env.ANALYZE === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
plugins.push(withAxiom);
|
plugins.push(withAxiom);
|
||||||
|
const matcherConfigRootPath = {
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: "host",
|
||||||
|
value: orgHostPath,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: "/",
|
||||||
|
};
|
||||||
|
|
||||||
/** Needed to rewrite public booking page, gets all static pages but [user] */
|
const matcherConfigUserRoute = {
|
||||||
const pages = glob
|
has: [
|
||||||
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
|
{
|
||||||
.map((filename) =>
|
type: "host",
|
||||||
filename
|
value: orgHostPath,
|
||||||
.substr(6)
|
},
|
||||||
.replace(/(\.tsx|\.js|\.ts)/, "")
|
],
|
||||||
.replace(/\/.*/, "")
|
source: orgUserRoutePath,
|
||||||
)
|
};
|
||||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
|
|
||||||
|
const matcherConfigUserTypeRoute = {
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: "host",
|
||||||
|
value: orgHostPath,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: orgUserTypeRoutePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const matcherConfigUserTypeEmbedRoute = {
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: "host",
|
||||||
|
value: orgHostPath,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: orgUserTypeEmbedRoutePath,
|
||||||
|
};
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
@@ -183,127 +215,81 @@ const nextConfig = {
|
|||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
const beforeFiles = [
|
||||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||||
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
...(process.env.ORGANIZATIONS_ENABLED
|
||||||
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
|
? [
|
||||||
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
|
{
|
||||||
const userTypeRouteRegExp = `/:user((?!${pages.join("|")})[^/]*)/:type((?!book)[^/]+)`;
|
...matcherConfigRootPath,
|
||||||
let rewrites = [
|
destination: "/team/:orgSlug",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...matcherConfigUserRoute,
|
||||||
|
destination: "/org/:orgSlug/:user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...matcherConfigUserTypeRoute,
|
||||||
|
destination: "/org/:orgSlug/:user/:type",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...matcherConfigUserTypeEmbedRoute,
|
||||||
|
destination: "/org/:orgSlug/:user/:type/embed",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
let afterFiles = [
|
||||||
{
|
{
|
||||||
source: "/org/:slug",
|
source: "/org/:slug",
|
||||||
destination: "/team/:slug",
|
destination: "/team/:slug",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
source: "/:user/avatar.png",
|
|
||||||
destination: "/api/user/avatar?username=:user",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: "/team/:teamname/avatar.png",
|
source: "/team/:teamname/avatar.png",
|
||||||
destination: "/api/user/avatar?teamname=:teamname",
|
destination: "/api/user/avatar?teamname=:teamname",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
source: "/forms/:formQuery*",
|
// When updating this also update pagesAndRewritePaths.js
|
||||||
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*",
|
|
||||||
},
|
|
||||||
// Keep cookie based booker enabled just in case we disable new-booker globally
|
|
||||||
...[
|
...[
|
||||||
{
|
{
|
||||||
source: userTypeRouteRegExp,
|
source: "/:user/avatar.png",
|
||||||
destination: "/new-booker/:user/:type",
|
destination: "/api/user/avatar?username=:user",
|
||||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/team/:slug/:type",
|
source: "/forms/:formQuery*",
|
||||||
destination: "/new-booker/team/:slug/:type",
|
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/d/:link/:slug",
|
source: "/router",
|
||||||
destination: "/new-booker/d/:link/:slug",
|
destination: "/apps/routing-forms/router",
|
||||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
|
||||||
},
|
},
|
||||||
],
|
|
||||||
// Keep cookie based booker enabled to test new-booker embed in production
|
|
||||||
...[
|
|
||||||
{
|
{
|
||||||
source: `/:user((?!${pages.join("|")}).*)/:type/embed`,
|
source: "/success/:path*",
|
||||||
destination: "/new-booker/:user/:type/embed",
|
has: [
|
||||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
{
|
||||||
|
type: "query",
|
||||||
|
key: "uid",
|
||||||
|
value: "(?<uid>.*)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
destination: "/booking/:uid/:path*",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/team/:slug/:type/embed",
|
source: "/cancel/:path*",
|
||||||
destination: "/new-booker/team/:slug/:type/embed",
|
destination: "/booking/:path*",
|
||||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
/* TODO: have these files being served from another deployment or CDN {
|
/* TODO: have these files being served from another deployment or CDN {
|
||||||
source: "/embed/embed.js",
|
source: "/embed/embed.js",
|
||||||
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
|
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
|
||||||
}, */
|
}, */
|
||||||
|
|
||||||
/**
|
|
||||||
* Enables new booker using cookie. It works even if NEW_BOOKER_ENABLED_FOR_NON_EMBED, NEW_BOOKER_ENABLED_FOR_EMBED are disabled
|
|
||||||
*/
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Enable New Booker for all Embed Requests
|
return {
|
||||||
if (process.env.NEW_BOOKER_ENABLED_FOR_EMBED === "1") {
|
beforeFiles,
|
||||||
console.log("Enabling New Booker for Embed");
|
afterFiles,
|
||||||
rewrites.push(
|
};
|
||||||
...[
|
|
||||||
{
|
|
||||||
source: `/:user((?!${pages.join("|")}).*)/:type/embed`,
|
|
||||||
destination: "/new-booker/:user/:type/embed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/team/:slug/:type/embed",
|
|
||||||
destination: "/new-booker/team/:slug/:type/embed",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable New Booker for All but embed Requests
|
|
||||||
if (process.env.NEW_BOOKER_ENABLED_FOR_NON_EMBED === "1") {
|
|
||||||
console.log("Enabling New Booker for Non-Embed");
|
|
||||||
rewrites.push(
|
|
||||||
...[
|
|
||||||
{
|
|
||||||
source: userTypeRouteRegExp,
|
|
||||||
destination: "/new-booker/:user/:type",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/team/:slug/:type",
|
|
||||||
destination: "/new-booker/team/:slug/:type",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/d/:link/:slug",
|
|
||||||
destination: "/new-booker/d/:link/:slug",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return rewrites;
|
|
||||||
},
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
@@ -338,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() {
|
async redirects() {
|
||||||
@@ -412,6 +436,16 @@ const nextConfig = {
|
|||||||
destination: "/event-types?openIntercom=true",
|
destination: "/event-types?openIntercom=true",
|
||||||
permanent: 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") {
|
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.0.3",
|
"version": "3.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"@next/bundle-analyzer": "^13.1.6",
|
"@next/bundle-analyzer": "^13.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.0.0",
|
"@radix-ui/react-avatar": "^1.0.0",
|
||||||
"@radix-ui/react-collapsible": "^1.0.0",
|
"@radix-ui/react-collapsible": "^1.0.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.0",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
"@radix-ui/react-id": "^1.0.0",
|
"@radix-ui/react-id": "^1.0.0",
|
||||||
"@radix-ui/react-popover": "^1.0.2",
|
"@radix-ui/react-popover": "^1.0.2",
|
||||||
"@radix-ui/react-radio-group": "^1.0.0",
|
"@radix-ui/react-radio-group": "^1.0.0",
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.0.0",
|
"@radix-ui/react-tooltip": "^1.0.0",
|
||||||
"@stripe/react-stripe-js": "^1.10.0",
|
"@stripe/react-stripe-js": "^1.10.0",
|
||||||
"@stripe/stripe-js": "^1.35.0",
|
"@stripe/stripe-js": "^1.35.0",
|
||||||
|
"@tanem/react-nprogress": "^5.0.44",
|
||||||
"@tanstack/react-query": "^4.3.9",
|
"@tanstack/react-query": "^4.3.9",
|
||||||
"@tremor/react": "^2.0.0",
|
"@tremor/react": "^2.0.0",
|
||||||
"@types/turndown": "^5.0.1",
|
"@types/turndown": "^5.0.1",
|
||||||
@@ -89,11 +90,11 @@
|
|||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"next": "^13.4.6",
|
"next": "^13.4.6",
|
||||||
"next-auth": "^4.20.1",
|
"next-auth": "^4.22.1",
|
||||||
"next-axiom": "^0.16.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-collect": "^0.2.1",
|
"next-collect": "^0.2.1",
|
||||||
"next-i18next": "^11.3.0",
|
"next-i18next": "^13.2.2",
|
||||||
"next-seo": "^4.26.0",
|
"next-seo": "^6.0.0",
|
||||||
"next-themes": "^0.2.0",
|
"next-themes": "^0.2.0",
|
||||||
"nodemailer": "^6.7.8",
|
"nodemailer": "^6.7.8",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
@@ -109,7 +110,7 @@
|
|||||||
"react-hook-form": "^7.43.3",
|
"react-hook-form": "^7.43.3",
|
||||||
"react-hot-toast": "^2.3.0",
|
"react-hot-toast": "^2.3.0",
|
||||||
"react-intl": "^5.25.1",
|
"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-multi-email": "^0.5.3",
|
||||||
"react-phone-input-2": "^2.15.1",
|
"react-phone-input-2": "^2.15.1",
|
||||||
"react-phone-number-input": "^3.2.7",
|
"react-phone-number-input": "^3.2.7",
|
||||||
@@ -162,7 +163,6 @@
|
|||||||
"copy-webpack-plugin": "^11.0.0",
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
"detect-port": "^1.3.0",
|
"detect-port": "^1.3.0",
|
||||||
"env-cmd": "^10.1.0",
|
"env-cmd": "^10.1.0",
|
||||||
"mockdate": "^3.0.5",
|
|
||||||
"module-alias": "^2.2.2",
|
"module-alias": "^2.2.2",
|
||||||
"msw": "^0.42.3",
|
"msw": "^0.42.3",
|
||||||
"postcss": "^8.4.18",
|
"postcss": "^8.4.18",
|
||||||
|
|||||||
+119
-63
@@ -3,22 +3,37 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HeadSeo } from "@calcom/ui";
|
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 PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
import { ssgInit } from "@server/lib/ssg";
|
import { ssgInit } from "@server/lib/ssg";
|
||||||
|
|
||||||
|
enum pageType {
|
||||||
|
ORG = "org",
|
||||||
|
TEAM = "team",
|
||||||
|
USER = "user",
|
||||||
|
OTHER = "other",
|
||||||
|
}
|
||||||
|
|
||||||
export default function Custom404() {
|
export default function Custom404() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [username] = router.asPath.replace("%20", "-").split(/[?#]/);
|
const [username, setUsername] = useState<string>("");
|
||||||
|
const [currentPageType, setCurrentPageType] = useState<pageType>(pageType.USER);
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
|
{
|
||||||
|
title: "Enterprise",
|
||||||
|
description: "Learn more about organizations and subdomains in our enterprise plan.",
|
||||||
|
icon: Shield,
|
||||||
|
href: `${WEBSITE_URL}/enterprise`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("documentation"),
|
title: t("documentation"),
|
||||||
description: t("documentation_description"),
|
description: t("documentation_description"),
|
||||||
@@ -33,19 +48,38 @@ export default function Custom404() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup?username=`);
|
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
|
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
|
||||||
}, [username]);
|
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 isSuccessPage = router.asPath.startsWith("/booking");
|
||||||
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
||||||
const isSignup = router.asPath.startsWith("/signup");
|
const isSignup = router.asPath.startsWith("/signup");
|
||||||
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
|
|
||||||
|
|
||||||
// In case of invalid email verification token, we intentionally redirect to 404 from the API
|
|
||||||
const isInvalidToken = router.asPath === "/404";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If we're on 404 and the route is insights it means it is disabled
|
* If we're on 404 and the route is insights it means it is disabled
|
||||||
* TODO: Abstract this for all disabled features
|
* TODO: Abstract this for all disabled features
|
||||||
@@ -63,7 +97,7 @@ export default function Custom404() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-screen bg-white px-4" data-testid="404-page">
|
<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">
|
<div className="text-center">
|
||||||
<p className="text-sm font-semibold uppercase tracking-wide text-black">{t("error_404")}</p>
|
<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">
|
<h1 className="font-cal mt-2 text-4xl font-extrabold text-gray-900 sm:text-5xl">
|
||||||
@@ -84,6 +118,8 @@ export default function Custom404() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
@@ -95,7 +131,7 @@ export default function Custom404() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="bg-default min-h-screen px-4" data-testid="404-page">
|
<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" ? (
|
{isSignup && process.env.NEXT_PUBLIC_WEBAPP_URL !== "https://app.cal.com" ? (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
@@ -165,18 +201,25 @@ export default function Custom404() {
|
|||||||
</li>
|
</li>
|
||||||
<li className="px-4 py-2">
|
<li className="px-4 py-2">
|
||||||
<a
|
<a
|
||||||
href={JOIN_SLACK}
|
href={JOIN_DISCORD}
|
||||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-emphasis text-base font-medium">
|
<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="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||||
<span className="absolute inset-0" aria-hidden="true" />
|
<span className="absolute inset-0" aria-hidden="true" />
|
||||||
Slack
|
Discord
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
||||||
@@ -204,21 +247,15 @@ export default function Custom404() {
|
|||||||
{t("error_404")}
|
{t("error_404")}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="font-cal text-emphasis mt-2 text-4xl font-extrabold sm:text-5xl">
|
<h1 className="font-cal text-emphasis mt-2 text-4xl font-extrabold sm:text-5xl">
|
||||||
{isSuccessPage
|
{isSuccessPage ? "Booking not found" : t("page_doesnt_exist")}
|
||||||
? t("booking_not_found")
|
|
||||||
: isInvalidToken
|
|
||||||
? t("invalid_verification_link")
|
|
||||||
: t("page_doesnt_exist")}
|
|
||||||
</h1>
|
</h1>
|
||||||
{isSubpage ? (
|
{isSubpage && currentPageType !== pageType.TEAM ? (
|
||||||
<span className="mt-2 inline-block text-lg ">
|
<span className="mt-2 inline-block text-lg ">
|
||||||
{t("check_spelling_mistakes_or_go_back")}
|
{t("check_spelling_mistakes_or_go_back")}
|
||||||
</span>
|
</span>
|
||||||
) : isInvalidToken ? (
|
) : IS_CALCOM ? (
|
||||||
<span className="mt-2 inline-block text-lg">{t("token_invalid_expired")}</span>
|
|
||||||
) : isCalcom ? (
|
|
||||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
<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">
|
<strong className="text-blue-500">
|
||||||
{new URL(WEBSITE_URL).hostname}
|
{new URL(WEBSITE_URL).hostname}
|
||||||
{username}
|
{username}
|
||||||
@@ -227,18 +264,17 @@ export default function Custom404() {
|
|||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="mt-2 inline-block text-lg">
|
<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>{" "}
|
<strong className="text-lgtext-green-500 mt-2 inline-block">{username}</strong>{" "}
|
||||||
{t("is_still_available")}
|
{t("is_still_available")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">
|
{((!isSubpage && IS_CALCOM) ||
|
||||||
{t("popular_pages")}
|
currentPageType === pageType.ORG ||
|
||||||
</h2>
|
currentPageType === pageType.TEAM) && (
|
||||||
{!isSubpage && isCalcom && (
|
<ul role="list" className="my-4">
|
||||||
<ul role="list" className="mt-4">
|
|
||||||
<li className="border-2 border-green-500 px-4 py-2">
|
<li className="border-2 border-green-500 px-4 py-2">
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@@ -255,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-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||||
<span className="focus:outline-none">
|
<span className="focus:outline-none">
|
||||||
<span className="absolute inset-0" aria-hidden="true" />
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</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>
|
||||||
<div className="flex-shrink-0 self-center">
|
<div className="flex-shrink-0 self-center">
|
||||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||||
@@ -268,47 +313,58 @@ export default function Custom404() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">
|
||||||
<ul role="list" className="border-subtle divide-subtle mt-4 divide-y">
|
{t("popular_pages")}
|
||||||
{links.map((link, linkIdx) => (
|
</h2>
|
||||||
<li key={linkIdx} className="px-4 py-2">
|
<ul role="list" className="border-subtle divide-subtle divide-y">
|
||||||
<Link
|
{links
|
||||||
href={link.href}
|
.filter((_, idx) => currentPageType === pageType.ORG || idx !== 0)
|
||||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
.map((link, linkIdx) => (
|
||||||
<div className="flex-shrink-0">
|
<li key={linkIdx} className="px-4 py-2">
|
||||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
<a
|
||||||
<link.icon className="text-default h-6 w-6" aria-hidden="true" />
|
href={link.href}
|
||||||
</span>
|
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||||
</div>
|
<div className="flex-shrink-0">
|
||||||
<div className="min-w-0 flex-1">
|
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
<h3 className="text-emphasis text-base font-medium">
|
<link.icon className="text-default h-6 w-6" aria-hidden="true" />
|
||||||
<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>
|
</span>
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-subtle text-base">{link.description}</p>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<h3 className="text-emphasis text-base font-medium">
|
||||||
<div className="flex-shrink-0 self-center">
|
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
<span className="absolute inset-0" aria-hidden="true" />
|
||||||
</div>
|
{link.title}
|
||||||
</Link>
|
</span>
|
||||||
</li>
|
</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">
|
<li className="px-4 py-2">
|
||||||
<a
|
<a
|
||||||
href={JOIN_SLACK}
|
href={JOIN_DISCORD}
|
||||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-emphasis text-base font-medium">
|
<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="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||||
<span className="absolute inset-0" aria-hidden="true" />
|
<span className="absolute inset-0" aria-hidden="true" />
|
||||||
Slack
|
Discord
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
<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">
|
<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>
|
<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>
|
<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
|
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
||||||
away for you.
|
away for you.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
||||||
import defaultEvents, {
|
import defaultEvents, {
|
||||||
getDynamicEventDescription,
|
getDynamicEventDescription,
|
||||||
getGroupName,
|
getGroupName,
|
||||||
@@ -37,7 +36,8 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
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 {
|
const {
|
||||||
users,
|
users,
|
||||||
profile,
|
profile,
|
||||||
@@ -102,6 +102,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const query = { ...router.query };
|
const query = { ...router.query };
|
||||||
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
||||||
|
delete query.orgSlug;
|
||||||
const nameOrUsername = user.name || user.username || "";
|
const nameOrUsername = user.name || user.username || "";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -132,7 +133,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
shouldAlignCentrally ? "mx-auto" : "",
|
shouldAlignCentrally ? "mx-auto" : "",
|
||||||
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
|
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
|
{isSingleUser && ( // When we deal with a single user, not dynamic group
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
@@ -207,8 +208,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
User.isBookingPage = true;
|
UserPage.isBookingPage = true;
|
||||||
User.PageWrapper = PageWrapper;
|
UserPage.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
const getEventTypesWithHiddenFromDB = async (userId: number) => {
|
const getEventTypesWithHiddenFromDB = async (userId: number) => {
|
||||||
return (
|
return (
|
||||||
@@ -289,7 +290,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
|
|
||||||
const users = usersWithoutAvatar.map((user) => ({
|
const users = usersWithoutAvatar.map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
avatar: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
avatar: `/${user.username}/avatar.png`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
|
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
|
||||||
@@ -360,6 +361,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
const safeBio = markdownToSafeHTML(user.bio) || "";
|
const safeBio = markdownToSafeHTML(user.bio) || "";
|
||||||
|
|
||||||
const markdownStrippedBio = stripMarkdown(user?.bio || "");
|
const markdownStrippedBio = stripMarkdown(user?.bio || "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
users,
|
users,
|
||||||
@@ -385,3 +387,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default UserPage;
|
||||||
+142
-319
@@ -1,97 +1,61 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { LocationObject } from "@calcom/app-store/locations";
|
import { Booker } from "@calcom/atoms";
|
||||||
|
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||||
|
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||||
|
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
import { classNames } from "@calcom/lib";
|
||||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import prisma from "@calcom/prisma";
|
||||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
|
||||||
import type { User } from "@calcom/prisma/client";
|
|
||||||
|
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
export default function Type({ slug, user, booking, away, isBrandingHidden, rescheduleUid, org }: PageProps) {
|
||||||
|
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
|
||||||
export default function Type(props: AvailabilityPageProps) {
|
return (
|
||||||
const { t } = useLocale();
|
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
|
||||||
|
<BookerSeo
|
||||||
return props.away ? (
|
username={user}
|
||||||
<div className="dark:bg-inverted h-screen">
|
eventSlug={slug}
|
||||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
rescheduleUid={rescheduleUid ?? undefined}
|
||||||
<div className="space-y-6" data-testid="event-types">
|
hideBranding={isBrandingHidden}
|
||||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
org={org}
|
||||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
/>
|
||||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
<Booker
|
||||||
😴{" " + t("user_away")}
|
username={user}
|
||||||
</h2>
|
eventSlug={slug}
|
||||||
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
|
bookingData={booking}
|
||||||
</div>
|
isAway={away}
|
||||||
</div>
|
hideBranding={isBrandingHidden}
|
||||||
</div>
|
org={org}
|
||||||
</main>
|
/>
|
||||||
</div>
|
</main>
|
||||||
) : props.isDynamic && !props.profile.allowDynamicBooking ? (
|
|
||||||
<div className="dark:bg-darkgray-50 h-screen">
|
|
||||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
|
||||||
<div className="space-y-6" data-testid="event-types">
|
|
||||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
|
||||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
|
||||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
|
||||||
{" " + t("unavailable")}
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
) : !props.isValidOrgDomain && props.organizationContext ? (
|
|
||||||
<div className="dark:bg-darkgray-50 h-screen">
|
|
||||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
|
||||||
<div className="space-y-6" data-testid="event-types">
|
|
||||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
|
||||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
|
||||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
|
||||||
{" " + t("unavailable")}
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-md">{t("user_belongs_organization")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AvailabilityPage {...props} />
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Type.isBookingPage = true;
|
Type.isBookingPage = true;
|
||||||
Type.PageWrapper = PageWrapper;
|
Type.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
const paramsSchema = z.object({ type: z.string(), user: z.string() });
|
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||||
// load server side dependencies
|
const { rescheduleUid, bookingUid } = context.query;
|
||||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
|
||||||
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
|
|
||||||
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
|
|
||||||
const { EventTypeMetaDataSchema, teamMetadataSchema } = await import("@calcom/prisma/zod-utils");
|
|
||||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
|
||||||
const ssr = await ssrInit(context);
|
|
||||||
const { type: slug, user: username } = paramsSchema.parse(context.query);
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const { ssrInit } = await import("@server/lib/ssr");
|
||||||
|
const ssr = await ssrInit(context);
|
||||||
|
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
/** TODO: We should standarize this */
|
username: {
|
||||||
username: username.toLowerCase().replace(/( |%20)/g, "+"),
|
in: usernames,
|
||||||
|
},
|
||||||
organization: isValidOrgDomain
|
organization: isValidOrgDomain
|
||||||
? {
|
? {
|
||||||
slug: currentOrgDomain,
|
slug: currentOrgDomain,
|
||||||
@@ -99,183 +63,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
away: true,
|
|
||||||
name: true,
|
|
||||||
hideBranding: true,
|
|
||||||
timeZone: true,
|
|
||||||
theme: true,
|
|
||||||
weekStart: true,
|
|
||||||
brandColor: true,
|
|
||||||
darkBrandColor: true,
|
|
||||||
metadata: true,
|
|
||||||
organizationId: true,
|
|
||||||
eventTypes: {
|
|
||||||
where: {
|
|
||||||
// Many-to-many relationship causes inclusion of the team events - cool -
|
|
||||||
// but to prevent these from being selected, make sure the teamId is NULL.
|
|
||||||
AND: [{ slug }, { teamId: null }],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
title: true,
|
|
||||||
slug: true,
|
|
||||||
hidden: true,
|
|
||||||
recurringEvent: true,
|
|
||||||
length: true,
|
|
||||||
locations: true,
|
|
||||||
id: true,
|
|
||||||
description: true,
|
|
||||||
price: true,
|
|
||||||
currency: true,
|
|
||||||
requiresConfirmation: true,
|
|
||||||
schedulingType: true,
|
|
||||||
metadata: true,
|
|
||||||
seatsPerTimeSlot: true,
|
|
||||||
team: {
|
|
||||||
select: {
|
|
||||||
logo: true,
|
|
||||||
parent: {
|
|
||||||
select: {
|
|
||||||
logo: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{
|
|
||||||
position: "desc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "asc",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
teams: {
|
|
||||||
include: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!user || !user.eventTypes.length) return { notFound: true };
|
|
||||||
|
|
||||||
const [eventType]: ((typeof user.eventTypes)[number] & {
|
|
||||||
users: Pick<User, "name" | "username" | "hideBranding" | "timeZone">[];
|
|
||||||
})[] = [
|
|
||||||
{
|
|
||||||
...user.eventTypes[0],
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
name: user.name,
|
|
||||||
username: user.username,
|
|
||||||
hideBranding: user.hideBranding,
|
|
||||||
timeZone: user.timeZone,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!eventType) return { notFound: true };
|
|
||||||
|
|
||||||
//TODO: Use zodSchema to verify it instead of using Type Assertion
|
|
||||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
|
||||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
|
||||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
|
||||||
locations: privacyFilteredLocations(locations),
|
|
||||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
|
||||||
});
|
|
||||||
// Check if the user you are logging into has any active teams or premium user name
|
|
||||||
const hasActiveTeam =
|
|
||||||
user.teams.filter((m) => {
|
|
||||||
if (!IS_TEAM_BILLING_ENABLED) return true;
|
|
||||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
|
||||||
if (metadata.success && metadata.data?.subscriptionId) return true;
|
|
||||||
return false;
|
|
||||||
}).length > 0;
|
|
||||||
|
|
||||||
const hasPremiumUserName = hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
eventType: eventTypeObject,
|
|
||||||
profile: {
|
|
||||||
...eventType.users[0],
|
|
||||||
theme: user.theme,
|
|
||||||
allowDynamicBooking: false,
|
|
||||||
weekStart: user.weekStart,
|
|
||||||
brandColor: user.brandColor,
|
|
||||||
darkBrandColor: user.darkBrandColor,
|
|
||||||
slug: `${user.username}/${eventType.slug}`,
|
|
||||||
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
|
||||||
},
|
|
||||||
// Dynamic group has no theme preference right now. It uses system theme.
|
|
||||||
themeBasis: user.username,
|
|
||||||
organizationContext: user?.organizationId !== null,
|
|
||||||
away: user?.away,
|
|
||||||
isDynamic: false,
|
|
||||||
trpcState: ssr.dehydrate(),
|
|
||||||
isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""),
|
|
||||||
isBrandingHidden: isBrandingHidden(user.hideBranding, hasActiveTeam || hasPremiumUserName),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|
||||||
// load server side dependencies
|
|
||||||
const { getDefaultEvent, getGroupName, getUsernameList } = await import("@calcom/lib/defaultEvents");
|
|
||||||
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
|
|
||||||
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
|
|
||||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
|
||||||
const { EventTypeMetaDataSchema, userMetadata: userMetadataSchema } = await import(
|
|
||||||
"@calcom/prisma/zod-utils"
|
|
||||||
);
|
|
||||||
const ssr = await ssrInit(context);
|
|
||||||
|
|
||||||
const { getAppFromSlug } = await import("@calcom/app-store/utils");
|
|
||||||
|
|
||||||
const { type: typeParam, user: userParam } = paramsSchema.parse(context.params);
|
|
||||||
const usernameList = getUsernameList(userParam);
|
|
||||||
const length = parseInt(typeParam);
|
|
||||||
const eventType = getDefaultEvent("" + length);
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
username: {
|
|
||||||
in: usernameList,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
bio: true,
|
|
||||||
avatar: true,
|
|
||||||
startTime: true,
|
|
||||||
endTime: true,
|
|
||||||
timeZone: true,
|
|
||||||
weekStart: true,
|
|
||||||
availability: true,
|
|
||||||
hideBranding: true,
|
|
||||||
brandColor: true,
|
|
||||||
darkBrandColor: true,
|
|
||||||
defaultScheduleId: true,
|
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
metadata: true,
|
|
||||||
organizationId: true,
|
|
||||||
away: true,
|
|
||||||
schedules: {
|
|
||||||
select: {
|
|
||||||
availability: true,
|
|
||||||
timeZone: true,
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
theme: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,87 +72,122 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||||
|
|
||||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
let booking: GetBookingType | null = null;
|
||||||
let sortedUsers: typeof users = [];
|
if (rescheduleUid) {
|
||||||
if (users.length > 1) {
|
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||||
sortedUsers = users.sort((a, b) => {
|
} else if (bookingUid) {
|
||||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
|
||||||
return aIndex - bIndex;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
// We use this to both prefetch the query on the server,
|
||||||
|
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||||
|
const eventData = await ssr.viewer.public.event.fetch({
|
||||||
|
username: usernames.join("+"),
|
||||||
|
eventSlug: slug,
|
||||||
|
org,
|
||||||
|
});
|
||||||
|
|
||||||
// Get the prefered location type from the first user
|
if (!eventData) {
|
||||||
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
return {
|
||||||
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
notFound: true,
|
||||||
|
};
|
||||||
if (preferedLocationType?.appSlug) {
|
|
||||||
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
|
||||||
const appType = foundApp?.appData?.location?.type;
|
|
||||||
if (appType) {
|
|
||||||
// Replace the location with the prefered location type
|
|
||||||
// This will still be default to daily if the app is not found
|
|
||||||
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
|
||||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
|
||||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
|
||||||
locations: privacyFilteredLocations(locations),
|
|
||||||
users: users.map((user) => {
|
|
||||||
return {
|
|
||||||
name: user.name,
|
|
||||||
username: user.username,
|
|
||||||
hideBranding: user.hideBranding,
|
|
||||||
timeZone: user.timeZone,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const dynamicNames = users.map((user) => {
|
|
||||||
return user.name || "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const profile = {
|
|
||||||
name: getGroupName(dynamicNames),
|
|
||||||
image: null,
|
|
||||||
slug: "" + length,
|
|
||||||
theme: null as string | null,
|
|
||||||
weekStart: "Sunday",
|
|
||||||
brandColor: "",
|
|
||||||
darkBrandColor: "",
|
|
||||||
allowDynamicBooking: !users.some((user) => {
|
|
||||||
return !user.allowDynamicBooking;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
eventType: eventTypeObject,
|
org,
|
||||||
profile,
|
booking,
|
||||||
// Dynamic group has no theme preference right now. It uses system theme.
|
user: usernames.join("+"),
|
||||||
themeBasis: null,
|
slug,
|
||||||
isDynamic: true,
|
|
||||||
away: false,
|
away: false,
|
||||||
organizationContext: !users.some((user) => user.organizationId === null),
|
|
||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""),
|
isBrandingHidden: false,
|
||||||
isBrandingHidden: false, // I think we should always show branding for dynamic groups - saves us checking every single user
|
themeBasis: null,
|
||||||
|
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||||
|
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||||
const { user: userParam } = paramsSchema.parse(context.params);
|
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||||
// dynamic groups are not generated at build time, but otherwise are probably cached until infinity.
|
const username = usernames[0];
|
||||||
const isDynamicGroup = getUsernameList(userParam).length > 1;
|
const { rescheduleUid, bookingUid } = context.query;
|
||||||
if (isDynamicGroup) {
|
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||||
return await getDynamicGroupPageProps(context);
|
|
||||||
} else {
|
const { ssrInit } = await import("@server/lib/ssr");
|
||||||
return await getUserPageProps(context);
|
const ssr = await ssrInit(context);
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username,
|
||||||
|
organization: isValidOrgDomain
|
||||||
|
? {
|
||||||
|
slug: currentOrgDomain,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
away: true,
|
||||||
|
hideBranding: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let booking: GetBookingType | null = null;
|
||||||
|
if (rescheduleUid) {
|
||||||
|
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||||
|
} else if (bookingUid) {
|
||||||
|
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||||
|
// We use this to both prefetch the query on the server,
|
||||||
|
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||||
|
const eventData = await ssr.viewer.public.event.fetch({
|
||||||
|
username,
|
||||||
|
eventSlug: slug,
|
||||||
|
org,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!eventData) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
booking,
|
||||||
|
away: user?.away,
|
||||||
|
user: username,
|
||||||
|
slug,
|
||||||
|
org,
|
||||||
|
trpcState: ssr.dehydrate(),
|
||||||
|
isBrandingHidden: user?.hideBranding,
|
||||||
|
themeBasis: username,
|
||||||
|
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||||
|
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
type: z.string().transform((s) => slugify(s)),
|
||||||
|
user: z.string().transform((s) => getUsernameList(s)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Booker page fetches a tiny bit of data server side, to determine early
|
||||||
|
// whether the page should show an away state or dynamic booking not allowed.
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const { user } = paramsSchema.parse(context.params);
|
||||||
|
const isDynamicGroup = user.length > 1;
|
||||||
|
|
||||||
|
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
|
||||||
|
};
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||||
|
|
||||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||||
|
|
||||||
export { default } from "../[type]";
|
export { default } from "../[type]";
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
// Somehow these types don't accept the {notFound: true} return type.
|
||||||
const ssrResponse = await _getServerSideProps(context);
|
// Probably still need to fix this. I don't know why this isn't allowed yet.
|
||||||
if (ssrResponse.notFound) {
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
return ssrResponse;
|
// @ts-ignore
|
||||||
}
|
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||||
return {
|
|
||||||
...ssrResponse,
|
|
||||||
props: {
|
|
||||||
...ssrResponse.props,
|
|
||||||
isEmbed: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { LocationObject } from "@calcom/app-store/locations";
|
|
||||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
|
||||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
|
||||||
import dayjs from "@calcom/dayjs";
|
|
||||||
import getBooking from "@calcom/features/bookings/lib/get-booking";
|
|
||||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
|
||||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
|
||||||
import { parseRecurringEvent } from "@calcom/lib";
|
|
||||||
import {
|
|
||||||
getDefaultEvent,
|
|
||||||
getDynamicEventName,
|
|
||||||
getGroupName,
|
|
||||||
getUsernameList,
|
|
||||||
} from "@calcom/lib/defaultEvents";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
|
||||||
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
|
|
||||||
import {
|
|
||||||
customInputSchema,
|
|
||||||
EventTypeMetaDataSchema,
|
|
||||||
userMetadata as userMetadataSchema,
|
|
||||||
} from "@calcom/prisma/zod-utils";
|
|
||||||
|
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
|
||||||
import BookingPage from "@components/booking/pages/BookingPage";
|
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
|
||||||
|
|
||||||
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
|
|
||||||
|
|
||||||
export default function Book(props: BookPageProps) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
return props.away ? (
|
|
||||||
<div className="dark:bg-inverted h-screen">
|
|
||||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
|
||||||
<div className="space-y-6" data-testid="event-types">
|
|
||||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
|
||||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
|
||||||
<h2 className="font-cal dark:text-inverted text-default mb-2 text-3xl">
|
|
||||||
😴{" " + t("user_away")}
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
) : props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
|
|
||||||
<div className="dark:bg-inverted h-screen">
|
|
||||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
|
||||||
<div className="space-y-6" data-testid="event-types">
|
|
||||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
|
||||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
|
||||||
<h2 className="font-cal dark:text-inverted text-default mb-2 text-3xl">
|
|
||||||
{" " + t("unavailable")}
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<BookingPage {...props} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Book.isBookingPage = true;
|
|
||||||
Book.PageWrapper = PageWrapper;
|
|
||||||
|
|
||||||
const querySchema = z.object({
|
|
||||||
bookingUid: z.string().optional(),
|
|
||||||
count: z.coerce.number().optional(),
|
|
||||||
embed: z.string().optional(),
|
|
||||||
rescheduleUid: z.string().optional(),
|
|
||||||
slug: z.string().optional(),
|
|
||||||
/** This is the event "type" ID */
|
|
||||||
type: z.coerce.number().optional(),
|
|
||||||
user: z.string(),
|
|
||||||
seatReferenceUid: z.string().optional(),
|
|
||||||
date: z.string().optional(),
|
|
||||||
duration: z.coerce.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|
||||||
const ssr = await ssrInit(context);
|
|
||||||
const query = querySchema.parse(context.query);
|
|
||||||
const usernameList = getUsernameList(query.user);
|
|
||||||
const eventTypeSlug = query.slug;
|
|
||||||
const recurringEventCountQuery = query.count;
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
username: {
|
|
||||||
in: usernameList,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
bio: true,
|
|
||||||
avatar: true,
|
|
||||||
theme: true,
|
|
||||||
brandColor: true,
|
|
||||||
darkBrandColor: true,
|
|
||||||
allowDynamicBooking: true,
|
|
||||||
away: true,
|
|
||||||
metadata: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!users.length) return { notFound: true };
|
|
||||||
const [user] = users;
|
|
||||||
const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug;
|
|
||||||
|
|
||||||
// Dynamic Group link doesn't need a type but it must have a slug
|
|
||||||
if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) {
|
|
||||||
return { notFound: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTypeRaw = isDynamicGroupBooking
|
|
||||||
? getDefaultEvent(eventTypeSlug)
|
|
||||||
: await prisma.eventType.findUnique({
|
|
||||||
where: {
|
|
||||||
id: query.type,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
...bookEventTypeSelect,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!eventTypeRaw) return { notFound: true };
|
|
||||||
const eventType = {
|
|
||||||
...eventTypeRaw,
|
|
||||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
|
||||||
bookingFields: getBookingFieldsWithSystemFields(eventTypeRaw),
|
|
||||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLocations = () => {
|
|
||||||
let locations = eventTypeRaw.locations || [];
|
|
||||||
if (!isDynamicGroupBooking) return locations;
|
|
||||||
|
|
||||||
let sortedUsers: typeof users = [];
|
|
||||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
|
||||||
if (users.length > 1) {
|
|
||||||
sortedUsers = users.sort((a, b) => {
|
|
||||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
|
||||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
|
||||||
return aIndex - bIndex;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the prefered location type from the first user
|
|
||||||
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
|
||||||
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
|
||||||
|
|
||||||
if (preferedLocationType?.appSlug) {
|
|
||||||
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
|
||||||
const appType = foundApp?.appData?.location?.type;
|
|
||||||
if (appType) {
|
|
||||||
// Replace the location with the prefered location type
|
|
||||||
// This will still be default to daily if the app is not found
|
|
||||||
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return locations;
|
|
||||||
};
|
|
||||||
|
|
||||||
const eventTypeObject = [eventType].map((e) => {
|
|
||||||
let locations = getLocations();
|
|
||||||
locations = privacyFilteredLocations(locations as LocationObject[]);
|
|
||||||
return {
|
|
||||||
...e,
|
|
||||||
locations: locations,
|
|
||||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
|
||||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
|
||||||
schedulingType: null,
|
|
||||||
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
|
||||||
users: users.map((u) => ({
|
|
||||||
id: u.id,
|
|
||||||
name: u.name,
|
|
||||||
username: u.username,
|
|
||||||
avatar: u.avatar,
|
|
||||||
image: u.avatar,
|
|
||||||
slug: u.username,
|
|
||||||
theme: u.theme,
|
|
||||||
})),
|
|
||||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
|
||||||
};
|
|
||||||
})[0];
|
|
||||||
|
|
||||||
// If rescheduleUid and event has seats lets convert Uid to bookingUid
|
|
||||||
let rescheduleUid = query.rescheduleUid;
|
|
||||||
const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot;
|
|
||||||
let attendeeEmail: string;
|
|
||||||
let bookingUidWithSeats: string | null = null;
|
|
||||||
|
|
||||||
if (rescheduleEventTypeHasSeats) {
|
|
||||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
|
||||||
where: {
|
|
||||||
referenceUid: query.rescheduleUid,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
attendee: true,
|
|
||||||
booking: {
|
|
||||||
select: {
|
|
||||||
uid: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (bookingSeat) {
|
|
||||||
rescheduleUid = bookingSeat.booking.uid;
|
|
||||||
attendeeEmail = bookingSeat.attendee.email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.duration) {
|
|
||||||
// If it's not reschedule but event Type has seats we should obtain
|
|
||||||
// the bookingUid regardless and use it to get the booking
|
|
||||||
const currentSeats = await prisma.booking.findFirst({
|
|
||||||
where: {
|
|
||||||
eventTypeId: eventTypeRaw.id,
|
|
||||||
startTime: dayjs(query.date).toISOString(),
|
|
||||||
endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(),
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
uid: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (currentSeats && currentSeats) {
|
|
||||||
bookingUidWithSeats = currentSeats.uid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let booking: GetBookingType | null = null;
|
|
||||||
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
|
|
||||||
booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
|
|
||||||
const currentAttendee = booking?.attendees.find((attendee) => {
|
|
||||||
return attendee.email === attendeeEmail;
|
|
||||||
});
|
|
||||||
if (currentAttendee) {
|
|
||||||
booking.attendees = [currentAttendee] || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : [];
|
|
||||||
|
|
||||||
const profile = isDynamicGroupBooking
|
|
||||||
? {
|
|
||||||
name: getGroupName(dynamicNames),
|
|
||||||
image: null,
|
|
||||||
slug: eventTypeSlug,
|
|
||||||
theme: null,
|
|
||||||
brandColor: "",
|
|
||||||
darkBrandColor: "",
|
|
||||||
allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking),
|
|
||||||
eventName: getDynamicEventName(dynamicNames, eventTypeSlug),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
name: user.name || user.username,
|
|
||||||
image: user.avatar,
|
|
||||||
slug: user.username,
|
|
||||||
theme: user.theme,
|
|
||||||
brandColor: user.brandColor,
|
|
||||||
darkBrandColor: user.darkBrandColor,
|
|
||||||
eventName: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
|
||||||
const recurringEventCount =
|
|
||||||
(eventType.recurringEvent?.count &&
|
|
||||||
recurringEventCountQuery &&
|
|
||||||
(recurringEventCountQuery <= eventType.recurringEvent.count
|
|
||||||
? recurringEventCountQuery
|
|
||||||
: eventType.recurringEvent.count)) ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
away: user.away,
|
|
||||||
profile,
|
|
||||||
// Dynamic group has no theme preference right now. It uses system theme.
|
|
||||||
themeBasis: isDynamicGroupBooking ? null : user.username,
|
|
||||||
eventType: eventTypeObject,
|
|
||||||
booking,
|
|
||||||
currentSlotBooking: currentSlotBooking,
|
|
||||||
recurringEventCount,
|
|
||||||
trpcState: ssr.dehydrate(),
|
|
||||||
isDynamicGroupBooking,
|
|
||||||
hasHashedBookingLink: false,
|
|
||||||
hashedLink: null,
|
|
||||||
isEmbed: !!query.embed,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { getStaticProps, getStaticPaths, default } from "./index";
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* This page is empty for the user, it is used only to take advantage of the
|
|
||||||
* caching system that NextJS uses SSG pages.
|
|
||||||
* TODO: Redirect to user profile on browser
|
|
||||||
*/
|
|
||||||
import type { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import getCalendarsEvents from "@calcom/core/getCalendarsEvents";
|
|
||||||
import dayjs from "@calcom/dayjs";
|
|
||||||
import prisma from "@calcom/prisma";
|
|
||||||
|
|
||||||
const paramsSchema = z.object({ user: z.string(), month: z.string() });
|
|
||||||
export const getStaticProps: GetStaticProps<
|
|
||||||
{ results: Awaited<ReturnType<typeof getCalendarsEvents>> },
|
|
||||||
{ user: string }
|
|
||||||
> = async (context) => {
|
|
||||||
const { user: username, month } = paramsSchema.parse(context.params);
|
|
||||||
const userWithCredentials = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
username,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
credentials: true,
|
|
||||||
selectedCalendars: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Subtract 11 hours from the start date to avoid problems in UTC- time zones.
|
|
||||||
const startDate = dayjs.utc(month, "YYYY-MM").startOf("day").subtract(11, "hours").format();
|
|
||||||
// Add 14 hours from the start date to avoid problems in UTC+ time zones.
|
|
||||||
const endDate = dayjs.utc(month, "YYYY-MM").endOf("month").add(14, "hours").format();
|
|
||||||
try {
|
|
||||||
const results = userWithCredentials?.credentials
|
|
||||||
? await getCalendarsEvents(
|
|
||||||
userWithCredentials?.credentials,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
userWithCredentials?.selectedCalendars
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: { results, date: new Date().toISOString() },
|
|
||||||
revalidate: 1,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
let message = "Unknown error while fetching calendar";
|
|
||||||
if (error instanceof Error) message = error.message;
|
|
||||||
console.error(error, message);
|
|
||||||
return {
|
|
||||||
props: { results: [], date: new Date().toISOString(), message },
|
|
||||||
revalidate: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = () => {
|
|
||||||
return {
|
|
||||||
paths: [],
|
|
||||||
fallback: "blocking",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type Props = InferGetStaticPropsType<typeof getStaticProps>;
|
|
||||||
const CalendarCache = (props: Props) =>
|
|
||||||
process.env.NODE_ENV === "development" ? <pre>{JSON.stringify(props, null, " ")}</pre> : <div />;
|
|
||||||
|
|
||||||
export default CalendarCache;
|
|
||||||
@@ -48,6 +48,13 @@ class MyDocument extends Document<Props> {
|
|||||||
<meta name="msapplication-TileColor" content="#ff0000" />
|
<meta name="msapplication-TileColor" content="#ff0000" />
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
|
||||||
|
{(process.env.NODE_ENV === "development" || process.env.VERCEL_ENV === "preview") && (
|
||||||
|
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||||
|
<script
|
||||||
|
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
|
||||||
|
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<body
|
<body
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ import { z } from "zod";
|
|||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { sendPasswordResetEmail } from "@calcom/emails";
|
import { sendPasswordResetEmail } from "@calcom/emails";
|
||||||
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
|
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import rateLimiter from "@calcom/lib/rateLimit";
|
|
||||||
import { defaultHandler } from "@calcom/lib/server";
|
import { defaultHandler } from "@calcom/lib/server";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const t = await getTranslation(req.body.language ?? "en", "common");
|
|
||||||
|
|
||||||
const email = z
|
const email = z
|
||||||
.string()
|
.string()
|
||||||
.email()
|
.email()
|
||||||
@@ -34,15 +31,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
// 10 requests per minute
|
// 10 requests per minute
|
||||||
|
|
||||||
const limiter = await rateLimiter();
|
await checkRateLimitAndThrowError({
|
||||||
const limit = await limiter({
|
rateLimitingType: "core",
|
||||||
identifier: ip,
|
identifier: ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!limit.success) {
|
|
||||||
throw new Error(ErrorCode.RateLimitExceeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const maybeUser = await prisma.user.findUnique({
|
const maybeUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -52,6 +45,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
name: true,
|
name: true,
|
||||||
identityProvider: true,
|
identityProvider: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
locale: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,6 +56,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = await getTranslation(maybeUser.locale ?? "en", "common");
|
||||||
|
|
||||||
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
|
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
|
||||||
where: {
|
where: {
|
||||||
email: maybeUser.email,
|
email: maybeUser.email,
|
||||||
@@ -93,17 +89,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
resetLink,
|
resetLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** So we can test the password reset flow on CI */
|
return res
|
||||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
.status(201)
|
||||||
return res.status(201).json({
|
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||||
message: "If this email exists in our system, you should receive a Reset email.",
|
|
||||||
resetLink,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return res
|
|
||||||
.status(201)
|
|
||||||
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
// console.error(reason);
|
// console.error(reason);
|
||||||
return res.status(500).json({ message: "Unable to create password reset request" });
|
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||||
|
|||||||
@@ -1,55 +1,66 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||||
|
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
const passwordResetRequestSchema = z.object({
|
||||||
|
password: z.string().refine(validPassword, () => ({
|
||||||
|
message: "Password does not meet the requirements",
|
||||||
|
})),
|
||||||
|
requestId: z.string(), // format doesn't matter.
|
||||||
|
});
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== "POST") {
|
// Bad Method when not POST
|
||||||
return res.status(400).json({ message: "" });
|
if (req.method !== "POST") return res.status(405).end();
|
||||||
}
|
|
||||||
|
|
||||||
|
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(req.body);
|
||||||
|
// rate-limited there is a low, very low chance that a password request stays valid long enough
|
||||||
|
// to brute force 3.8126967e+40 options.
|
||||||
|
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: rawRequestId,
|
||||||
|
expires: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(rawPassword);
|
||||||
|
// this can fail if a password request has been made for an email that has since changed or-
|
||||||
|
// never existed within Cal. In this case we do not want to disclose the email's existence.
|
||||||
|
// instead, we just return 404
|
||||||
try {
|
try {
|
||||||
const rawPassword = req.body?.password;
|
|
||||||
const rawRequestId = req.body?.requestId;
|
|
||||||
|
|
||||||
if (!rawPassword || !rawRequestId) {
|
|
||||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeRequest = await prisma.resetPasswordRequest.findUnique({
|
|
||||||
where: {
|
|
||||||
id: rawRequestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!maybeRequest) {
|
|
||||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeUser = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
email: maybeRequest.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!maybeUser) {
|
|
||||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(rawPassword);
|
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: maybeUser.id,
|
email: maybeRequest.email,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
return res.status(201).json({ message: "Password reset." });
|
return res.status(404).end();
|
||||||
} catch (reason) {
|
|
||||||
console.error(reason);
|
|
||||||
return res.status(500).json({ message: "Unable to create password reset request" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await expireResetPasswordRequest(rawRequestId);
|
||||||
|
|
||||||
|
return res.status(201).json({ message: "Password reset." });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expireResetPasswordRequest(rawRequestId: string) {
|
||||||
|
await prisma.resetPasswordRequest.update({
|
||||||
|
where: {
|
||||||
|
id: rawRequestId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
// We set the expiry to now to invalidate the request
|
||||||
|
expires: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||||
|
import { validateUsernameInOrg } from "@calcom/lib/validateUsernameInOrg";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||||
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
const signupSchema = z.object({
|
const signupSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(7),
|
password: z.string().min(7),
|
||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
|
token: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -26,7 +30,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
const { email, password, language } = signupSchema.parse(data);
|
const { email, password, language, token } = signupSchema.parse(data);
|
||||||
|
|
||||||
const username = slugify(data.username);
|
const username = slugify(data.username);
|
||||||
const userEmail = email.toLowerCase();
|
const userEmail = email.toLowerCase();
|
||||||
@@ -67,6 +71,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
return res.status(409).json({ message });
|
return res.status(409).json({ message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
|
||||||
|
if (token) {
|
||||||
|
foundToken = await prisma.verificationToken.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
expires: true,
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!foundToken) {
|
||||||
|
return res.status(401).json({ message: "Invalid Token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
||||||
|
return res.status(401).json({ message: "Token expired" });
|
||||||
|
}
|
||||||
|
if (foundToken?.teamId) {
|
||||||
|
const isValidUsername = await validateUsernameInOrg(username, foundToken?.teamId);
|
||||||
|
|
||||||
|
if (!isValidUsername) {
|
||||||
|
return res.status(409).json({ message: "Username already taken" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(password);
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
@@ -85,25 +118,84 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If user has been invitedTo a team, we accept the membership
|
if (foundToken && foundToken?.teamId) {
|
||||||
if (user.invitedTo) {
|
const team = await prisma.team.findUnique({
|
||||||
const team = await prisma.team.findFirst({
|
where: {
|
||||||
where: { id: user.invitedTo },
|
id: foundToken.teamId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (team) {
|
if (team) {
|
||||||
|
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||||
|
if (teamMetadata?.isOrganization) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
organizationId: team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const membership = await prisma.membership.update({
|
const membership = await prisma.membership.update({
|
||||||
where: {
|
where: {
|
||||||
userId_teamId: { userId: user.id, teamId: user.invitedTo },
|
userId_teamId: { userId: user.id, teamId: team.id },
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync Services: Close.com
|
|
||||||
closeComUpsertTeamUser(team, user, membership.role);
|
closeComUpsertTeamUser(team, user, membership.role);
|
||||||
|
|
||||||
|
// Accept any child team invites for orgs.
|
||||||
|
if (team.parentId) {
|
||||||
|
// Join ORG
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
organizationId: team.parentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG. */
|
||||||
|
await prisma.membership.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
team: {
|
||||||
|
id: team.parentId,
|
||||||
|
},
|
||||||
|
accepted: false,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join any other invites
|
||||||
|
await prisma.membership.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
team: {
|
||||||
|
parentId: team.parentId,
|
||||||
|
},
|
||||||
|
accepted: false,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup token after use
|
||||||
|
await prisma.verificationToken.delete({
|
||||||
|
where: {
|
||||||
|
id: foundToken.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendEmailVerification({
|
await sendEmailVerification({
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!foundToken) {
|
if (!foundToken) {
|
||||||
return res.redirect(`${WEBAPP_URL}/404`);
|
return res.status(401).json({ message: "No token found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
||||||
return res.redirect(`${WEBAPP_URL}/404`);
|
return res.status(401).json({ message: "Token expired" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import notEmpty from "@calcom/lib/notEmpty";
|
import notEmpty from "@calcom/lib/notEmpty";
|
||||||
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
const selectedCalendarSelectSchema = z.object({
|
const selectedCalendarSelectSchema = z.object({
|
||||||
@@ -73,10 +72,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
res.status(200).json({ message: "Calendar Selection Saved" });
|
res.status(200).json({ message: "Calendar Selection Saved" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["DELETE", "POST"].includes(req.method)) {
|
|
||||||
await revalidateCalendarCache(res.revalidate, `${session?.user?.username}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
|
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||||
|
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
|
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||||
|
const data: RecurringBookingCreateBody[] = req.body;
|
||||||
|
const session = await getServerSession({ req, res });
|
||||||
|
const createdBookings: BookingResponse[] = [];
|
||||||
|
const allRecurringDates: string[] = data.map((booking) => booking.start);
|
||||||
|
let appsStatus: AppsStatus[] | undefined = undefined;
|
||||||
|
|
||||||
|
/* To mimic API behavior and comply with types */
|
||||||
|
req.userId = session?.user?.id || -1;
|
||||||
|
|
||||||
|
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
|
||||||
|
// emailed booking taking into account accumulated results to send app status accurately
|
||||||
|
for (let key = data.length - 1; key >= 0; key--) {
|
||||||
|
const booking = data[key];
|
||||||
|
if (key === 0) {
|
||||||
|
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||||
|
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||||
|
.reduce((prev, curr) => {
|
||||||
|
if (prev[curr.type]) {
|
||||||
|
prev[curr.type].failures += curr.failures;
|
||||||
|
prev[curr.type].success += curr.success;
|
||||||
|
} else {
|
||||||
|
prev[curr.type] = curr;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}, {} as { [key: string]: AppsStatus });
|
||||||
|
appsStatus = Object.values(calcAppsStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurringEventReq: NextApiRequest & { userId?: number } = req;
|
||||||
|
|
||||||
|
recurringEventReq.body = {
|
||||||
|
...booking,
|
||||||
|
appsStatus,
|
||||||
|
allRecurringDates,
|
||||||
|
currentRecurringIndex: key,
|
||||||
|
noEmail: key !== 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const eachRecurringBooking = await handleNewBooking(recurringEventReq, {
|
||||||
|
isNotAnApiCall: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
createdBookings.push(eachRecurringBooking);
|
||||||
|
}
|
||||||
|
return createdBookings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(handler);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import type { AppCategories, Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
|
||||||
|
const log = logger.getChildLogger({
|
||||||
|
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* syncAppMeta makes sure any app metadata that has been replicated into the database
|
||||||
|
* remains synchronized with any changes made to the app config files.
|
||||||
|
*/
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||||
|
if (process.env.CRON_API_KEY !== apiKey) {
|
||||||
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.status(405).json({ message: "Invalid method" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`🧐 Checking DB apps are in-sync with app metadata`);
|
||||||
|
|
||||||
|
const dbApps = await prisma.app.findMany();
|
||||||
|
|
||||||
|
for await (const dbApp of dbApps) {
|
||||||
|
const app = await getAppWithMetadata(dbApp);
|
||||||
|
const updates: Prisma.AppUpdateManyMutationInput = {};
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
log.warn(`💀 App ${dbApp.slug} (${dbApp.dirName}) no longer exists.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any changes in the app categories (tolerates changes in ordering)
|
||||||
|
if (
|
||||||
|
dbApp.categories.length !== app.categories.length ||
|
||||||
|
!dbApp.categories.every((category) => app.categories.includes(category))
|
||||||
|
) {
|
||||||
|
updates["categories"] = app.categories as AppCategories[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbApp.dirName !== (app.dirName ?? app.slug)) {
|
||||||
|
updates["dirName"] = app.dirName ?? app.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
log.info(`🔨 Updating app ${dbApp.slug} with ${Object.keys(updates).join(", ")}`);
|
||||||
|
if (!isDryRun) {
|
||||||
|
await prisma.app.update({
|
||||||
|
where: { slug: dbApp.slug },
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info(`✅ App ${dbApp.slug} is up-to-date and correct`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
Loaded 100 of 789 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user