Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a90848edcb | ||
|
|
49b0f48be0 | ||
|
|
ceb8906c27 | ||
|
|
f9c578c87d | ||
|
|
c105793486 | ||
|
|
59b503eb7e | ||
|
|
b6a28b1950 | ||
|
|
b6ea551580 | ||
|
|
c5bf75385f | ||
|
|
1298b05e1a | ||
|
|
e0c27d8721 | ||
|
|
666428ba53 | ||
|
|
ead11ef009 | ||
|
|
e7543c10a9 | ||
|
|
bba52db02f | ||
|
|
a86d4ec89d | ||
|
|
d415944d5b | ||
|
|
98c78ba7e1 | ||
|
|
c554779490 | ||
|
|
53655a43eb | ||
|
|
38d1b92226 | ||
|
|
3e08c66888 | ||
|
|
a186b130cb | ||
|
|
bb052a24bc | ||
|
|
a1b6b7ad66 | ||
|
|
35181e4bfd | ||
|
|
c2e3638993 | ||
|
|
3ca7456885 | ||
|
|
07bfaec24e | ||
|
|
032435fbd4 | ||
|
|
caf1b4c5d7 | ||
|
|
20898e1505 | ||
|
|
0bb99fc667 | ||
|
|
e5b88bd0b0 | ||
|
|
4a81b59fc9 | ||
|
|
e1f47ef40e | ||
|
|
e568d70e65 | ||
|
|
41b6377f29 | ||
|
|
685be1663e | ||
|
|
85a1713897 | ||
|
|
c22d406d12 | ||
|
|
cf43d9dcfc | ||
|
|
30a23cedbc | ||
|
|
6e2a96d69c | ||
|
|
490e0a9bb4 | ||
|
|
31bc586ee8 | ||
|
+15 |
68bd877c5b | ||
|
|
b4f44e9a60 | ||
|
|
9c448e3b87 | ||
|
|
5deda33712 | ||
|
|
2335948099 | ||
|
|
8bbb38d2be | ||
|
|
a4035aef98 | ||
|
|
c57de8f43f | ||
|
|
2b1918f40a | ||
|
|
fb2c966e6c | ||
|
|
4f3cf4f948 | ||
|
|
4613ac34c9 | ||
|
|
d69ba825fe | ||
|
|
92a221197a | ||
|
|
b54f716fd7 | ||
|
|
57e712dd9e | ||
|
|
2021b641ce | ||
|
|
ef45cbfb3f | ||
|
|
ab17cb216f | ||
|
|
b04c870a3d | ||
|
|
09abb90c8b | ||
|
|
b8e3b549f8 | ||
|
|
403d08de41 | ||
|
|
d7c3132fa5 | ||
|
|
a2df323cf8 | ||
|
|
fe82d88adf | ||
|
|
01e2ba3fd9 | ||
|
|
c9b87bbf85 | ||
|
|
19e6dbb781 | ||
|
|
a3a49f9069 | ||
|
|
468da1f35a | ||
|
|
ec4e2f5996 | ||
|
|
8a413a97b4 | ||
|
|
c275981be6 | ||
|
|
ce13350d5d | ||
|
|
919066222b | ||
|
|
79b60adf9b | ||
|
|
1d882e6b3b | ||
|
|
7f7841fabc | ||
|
|
260064c836 | ||
|
|
8c8be1efa3 | ||
|
|
9084cc6677 | ||
|
|
fda3192d53 | ||
|
|
4acfb4b28c | ||
|
|
a0642598ab | ||
|
|
20282a16f7 | ||
|
|
69120095e4 | ||
|
|
bfc137c349 | ||
|
|
13caf6f2cd | ||
|
|
1d4eeab33e | ||
|
|
3bee2d705f | ||
|
|
9c621bf726 | ||
|
|
70461b2718 | ||
|
|
efb04d04ab | ||
|
|
538e3bf07c | ||
|
|
798707a553 | ||
|
|
5940f9bccd | ||
|
|
5e449670ea | ||
|
|
82d7a3db92 | ||
|
|
0b7e3510a5 | ||
|
|
e0c87cf664 | ||
|
|
0d653afd0c | ||
|
|
f414428dfe | ||
|
|
2a171e9284 | ||
|
|
faf974ca4c | ||
|
|
a78a7a1fa0 | ||
|
|
5943763726 | ||
|
|
2648e2770f | ||
|
|
4e7ab391ff | ||
|
|
578495de87 | ||
|
|
86b3a33ff6 | ||
|
|
94476565ad | ||
|
|
3b50fe075d | ||
|
|
96263b0cf7 | ||
|
|
3a3d8db489 | ||
|
|
de04b1a561 | ||
|
|
ca946a9822 | ||
|
|
a5c05bc550 | ||
|
|
fd02cc44fa | ||
|
|
5ee962948f | ||
|
|
c350f099f7 | ||
|
|
07860c238a | ||
|
|
2565d0915a | ||
|
|
3fdc41968f |
@@ -125,4 +125,5 @@ SALESFORCE_CONSUMER_SECRET=""
|
|||||||
ZOHOCRM_CLIENT_ID=""
|
ZOHOCRM_CLIENT_ID=""
|
||||||
ZOHOCRM_CLIENT_SECRET=""
|
ZOHOCRM_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
|
||||||
# *********************************************************************************************************
|
# *********************************************************************************************************
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ jobs:
|
|||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@v1.13.0
|
uses: crowdin/github-action@v1.13.0
|
||||||
with:
|
with:
|
||||||
|
# upload sources
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
|
|
||||||
|
# upload translations (& auto-approve 'em)
|
||||||
|
upload_translations_args: '--auto-approve-imported'
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
|
||||||
push_translations: true
|
push_translations: true
|
||||||
|
|
||||||
|
# download translations
|
||||||
|
download_translations: true
|
||||||
|
|
||||||
|
# GH config
|
||||||
commit_message: "New Crowdin translations by Github Action"
|
commit_message: "New Crowdin translations by Github Action"
|
||||||
localization_branch_name: main
|
localization_branch_name: main
|
||||||
create_pull_request: false
|
create_pull_request: false
|
||||||
|
|||||||
@@ -44,4 +44,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
message: |
|
message: |
|
||||||
Thank you for following the naming conventions! 🙏
|
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ jobs:
|
|||||||
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 [discord](https://go.cal.com/discord)
|
Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
|
||||||
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. ❤️🎉
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@ tasks:
|
|||||||
next_auth_secret=$(openssl rand -base64 32) &&
|
next_auth_secret=$(openssl rand -base64 32) &&
|
||||||
calendso_encryption_key=$(openssl rand -base64 24) &&
|
calendso_encryption_key=$(openssl rand -base64 24) &&
|
||||||
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
||||||
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
|
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" \
|
||||||
|
-e "s|http://localhost:3000|https://localhost:3000|" \
|
||||||
|
-e "s|localhost:3000|3000-$GITPOD_WORKSPACE_ID.$GITPOD_WORKSPACE_CLUSTER_HOST|" .env
|
||||||
command: yarn dx
|
command: yarn dx
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
_
|
|
||||||
+27
-2
@@ -92,7 +92,22 @@ To develop locally:
|
|||||||
- 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.
|
||||||
|
|
||||||
6. Start developing and watch for code changes:
|
6. Setup Node
|
||||||
|
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You first might need to install the specific version and then use it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm install && nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||||
|
|
||||||
|
7. Start developing and watch for code changes:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn dev
|
yarn dev
|
||||||
@@ -120,6 +135,16 @@ This will run and test all flows in multiple Chromium windows to verify that no
|
|||||||
yarn test-e2e
|
yarn test-e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Resolving issues
|
||||||
|
|
||||||
|
##### E2E test browsers not installed
|
||||||
|
|
||||||
|
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium
|
||||||
|
```
|
||||||
|
|
||||||
## Linting
|
## Linting
|
||||||
|
|
||||||
To check the formatting of your code:
|
To check the formatting of your code:
|
||||||
@@ -135,4 +160,4 @@ If you get errors, be sure to fix them before committing.
|
|||||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||||
- Be sure to fill the PR Template accordingly.
|
- Be sure to fill the PR Template accordingly.
|
||||||
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||||
|
|||||||
@@ -131,23 +131,39 @@ Here is what you need to be able to run Cal.com.
|
|||||||
> 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
|
2. Go to the project folder
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd cal.com
|
cd cal.com
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Install packages with yarn
|
3. Install packages with yarn
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn
|
yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Set up your `.env` file
|
4. Set up your `.env` file
|
||||||
|
|
||||||
- Duplicate `.env.example` to `.env`
|
- 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.
|
||||||
|
|
||||||
|
5. Setup Node
|
||||||
|
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You first might need to install the specific version and then use it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm install && nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||||
|
|
||||||
#### Quick start with `yarn dx`
|
#### Quick start with `yarn dx`
|
||||||
|
|
||||||
> - **Requires Docker and Docker Compose to be installed**
|
> - **Requires Docker and Docker Compose to be installed**
|
||||||
@@ -221,6 +237,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
|
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -259,6 +276,16 @@ yarn test-e2e
|
|||||||
yarn playwright show-report test-results/reports/playwright-html-report
|
yarn playwright show-report test-results/reports/playwright-html-report
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Resolving issues
|
||||||
|
|
||||||
|
##### E2E test browsers not installed
|
||||||
|
|
||||||
|
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium
|
||||||
|
```
|
||||||
|
|
||||||
### Upgrading from earlier versions
|
### Upgrading from earlier versions
|
||||||
|
|
||||||
1. Pull the current version:
|
1. Pull the current version:
|
||||||
@@ -470,9 +497,8 @@ following
|
|||||||
4. Select Basecamp 4 as the product to integrate with.
|
4. Select Basecamp 4 as the product to integrate with.
|
||||||
5. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs.
|
5. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||||
6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
|
6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
|
||||||
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
||||||
For example, `Cal.com (support@cal.com)`.
|
For example, `Cal.com (support@cal.com)`.
|
||||||
|
|
||||||
|
|
||||||
### Obtaining HubSpot Client ID and Secret
|
### Obtaining HubSpot Client ID and Secret
|
||||||
|
|
||||||
@@ -504,6 +530,10 @@ For example, `Cal.com (support@cal.com)`.
|
|||||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
||||||
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
||||||
|
|
||||||
|
### Obtaining Zoho Calendar Client ID and Secret
|
||||||
|
|
||||||
|
[Follow these steps](./packages/app-store/zohocalendar/)
|
||||||
|
|
||||||
### Obtaining Zoho Bigin Client ID and Secret
|
### Obtaining Zoho Bigin Client ID and Secret
|
||||||
|
|
||||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
BACKEND_URL=http://localhost:3002/api
|
BACKEND_URL=http://localhost:3002/api
|
||||||
|
# BACKEND_URL=https://api.cal.com/v1
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
# FRONTEND_URL=https://cal.com
|
||||||
|
|
||||||
APP_ID=cal-ai
|
APP_ID=cal-ai
|
||||||
APP_URL=http://localhost:3000/apps/cal-ai
|
APP_URL=http://localhost:3000/apps/cal-ai
|
||||||
|
|||||||
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
# Cal.com Email Assistant
|
# Cal.com Email Assistant
|
||||||
|
|
||||||
Welcome to the first stage of Cal AI!
|
Welcome to the first stage of Cal.ai!
|
||||||
|
|
||||||
This app lets you chat with your calendar via email:
|
This app lets you chat with your calendar via email:
|
||||||
|
|
||||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||||
|
|
||||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||||
|
|
||||||
@@ -24,10 +24,10 @@ If you haven't yet, please run the [root setup](/README.md) steps.
|
|||||||
|
|
||||||
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
||||||
|
|
||||||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||||
- A default sender email (for example, `ai@cal.dev`)
|
- A default sender email (for example, `ai@cal.dev`)
|
||||||
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||||
|
|
||||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/ai",
|
"name": "@calcom/ai",
|
||||||
"version": "1.0.1",
|
"version": "1.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Cal.com Inc.",
|
"author": "Cal.com Inc.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -18,21 +18,21 @@ export const POST = async (request: NextRequest) => {
|
|||||||
|
|
||||||
const json = await request.json();
|
const json = await request.json();
|
||||||
|
|
||||||
const { apiKey, userId, message, subject, user, replyTo } = json;
|
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
|
||||||
|
|
||||||
if ((!message && !subject) || !user) {
|
if ((!message && !subject) || !user) {
|
||||||
return new NextResponse("Missing fields", { status: 400 });
|
return new NextResponse("Missing fields", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
|
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
|
||||||
|
|
||||||
// Send response to user
|
// Send response to user
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
subject: `Re: ${subject}`,
|
subject: `Re: ${subject}`,
|
||||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||||
to: user.email,
|
to: user.email,
|
||||||
from: replyTo,
|
from: agentEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new NextResponse("ok");
|
return new NextResponse("ok");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
|
|||||||
import { env } from "../../../env.mjs";
|
import { env } from "../../../env.mjs";
|
||||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||||
|
import { extractUsers } from "../../../utils/extractUsers";
|
||||||
import getHostFromHeaders from "../../../utils/host";
|
import getHostFromHeaders from "../../../utils/host";
|
||||||
import now from "../../../utils/now";
|
import now from "../../../utils/now";
|
||||||
import sendEmail from "../../../utils/sendEmail";
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
@@ -45,6 +46,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
select: {
|
select: {
|
||||||
email: true,
|
email: true,
|
||||||
id: true,
|
id: true,
|
||||||
|
username: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
select: {
|
select: {
|
||||||
@@ -53,15 +55,15 @@ export const POST = async (request: NextRequest) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: { email: envelope.from },
|
where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// User is not a cal.com user or is using an unverified email.
|
// User is not a cal.com user or is using an unverified email.
|
||||||
if (!signature || !user) {
|
if (!signature || !user) {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||||
subject: `Re: ${body.subject}`,
|
subject: `Re: ${body.subject}`,
|
||||||
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||||
to: envelope.from,
|
to: envelope.from,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
});
|
});
|
||||||
@@ -76,9 +78,9 @@ export const POST = async (request: NextRequest) => {
|
|||||||
const url = env.APP_URL;
|
const url = env.APP_URL;
|
||||||
|
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||||
subject: `Re: ${body.subject}`,
|
subject: `Re: ${body.subject}`,
|
||||||
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
|
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
||||||
to: envelope.from,
|
to: envelope.from,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
});
|
});
|
||||||
@@ -89,7 +91,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
const { apiKey } = credential as { apiKey: string };
|
const { apiKey } = credential as { apiKey: string };
|
||||||
|
|
||||||
// Pre-fetch data relevant to most bookings.
|
// Pre-fetch data relevant to most bookings.
|
||||||
const [eventTypes, availability] = await Promise.all([
|
const [eventTypes, availability, users] = await Promise.all([
|
||||||
fetchEventTypes({
|
fetchEventTypes({
|
||||||
apiKey,
|
apiKey,
|
||||||
}),
|
}),
|
||||||
@@ -99,6 +101,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
dateFrom: now(user.timeZone),
|
dateFrom: now(user.timeZone),
|
||||||
dateTo: now(user.timeZone),
|
dateTo: now(user.timeZone),
|
||||||
}),
|
}),
|
||||||
|
extractUsers(`${parsed.text} ${parsed.subject}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ("error" in availability) {
|
if ("error" in availability) {
|
||||||
@@ -138,9 +141,11 @@ export const POST = async (request: NextRequest) => {
|
|||||||
user: {
|
user: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
eventTypes,
|
eventTypes,
|
||||||
|
username: user.username,
|
||||||
timeZone: user.timeZone,
|
timeZone: user.timeZone,
|
||||||
workingHours,
|
workingHours,
|
||||||
},
|
},
|
||||||
|
users,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
BACKEND_URL: process.env.BACKEND_URL,
|
BACKEND_URL: process.env.BACKEND_URL,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
APP_ID: process.env.APP_ID,
|
APP_ID: process.env.APP_ID,
|
||||||
APP_URL: process.env.APP_URL,
|
APP_URL: process.env.APP_URL,
|
||||||
PARSE_KEY: process.env.PARSE_KEY,
|
PARSE_KEY: process.env.PARSE_KEY,
|
||||||
@@ -32,6 +33,7 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
BACKEND_URL: z.string().url(),
|
BACKEND_URL: z.string().url(),
|
||||||
|
FRONTEND_URL: z.string().url(),
|
||||||
APP_ID: z.string().min(1),
|
APP_ID: z.string().min(1),
|
||||||
APP_URL: z.string().url(),
|
APP_URL: z.string().url(),
|
||||||
PARSE_KEY: z.string().min(1),
|
PARSE_KEY: z.string().min(1),
|
||||||
|
|||||||
+25
-14
@@ -1,6 +1,8 @@
|
|||||||
import { DynamicStructuredTool } from "langchain/tools";
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { UserList } from "~/src/types/user";
|
||||||
|
|
||||||
import { env } from "../env.mjs";
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,21 +11,23 @@ import { env } from "../env.mjs";
|
|||||||
const createBooking = async ({
|
const createBooking = async ({
|
||||||
apiKey,
|
apiKey,
|
||||||
userId,
|
userId,
|
||||||
|
users,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
timeZone,
|
timeZone,
|
||||||
language,
|
language,
|
||||||
responses,
|
invite,
|
||||||
}: {
|
}: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
users: UserList;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
language: string;
|
language: string;
|
||||||
responses: { name?: string; email?: string; location?: string };
|
invite: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}): Promise<string | Error | { error: string }> => {
|
}): Promise<string | Error | { error: string }> => {
|
||||||
@@ -36,6 +40,18 @@ const createBooking = async ({
|
|||||||
|
|
||||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const user = users.find((u) => u.id === invite);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { error: `User with id ${invite} not found to invite` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = {
|
||||||
|
id: invite,
|
||||||
|
name: user.username,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
end,
|
end,
|
||||||
@@ -66,19 +82,19 @@ const createBooking = async ({
|
|||||||
return "Booking created";
|
return "Booking created";
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBookingTool = (apiKey: string, userId: number) => {
|
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
|
||||||
return new DynamicStructuredTool({
|
return new DynamicStructuredTool({
|
||||||
description:
|
description: "Creates a booking on the primary user's calendar.",
|
||||||
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
|
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
|
||||||
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
|
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
await createBooking({
|
await createBooking({
|
||||||
apiKey,
|
apiKey,
|
||||||
userId,
|
userId,
|
||||||
|
users,
|
||||||
end,
|
end,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
language,
|
language,
|
||||||
responses,
|
invite,
|
||||||
start,
|
start,
|
||||||
status,
|
status,
|
||||||
timeZone,
|
timeZone,
|
||||||
@@ -86,19 +102,14 @@ const createBookingTool = (apiKey: string, userId: number) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
name: "createBookingIfAvailable",
|
name: "createBooking",
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
end: z
|
end: z
|
||||||
.string()
|
.string()
|
||||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||||
eventTypeId: z.number(),
|
eventTypeId: z.number(),
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
responses: z
|
invite: z.number().describe("External user id to invite."),
|
||||||
.object({
|
|
||||||
email: z.string().optional(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
})
|
|
||||||
.describe("External invited user. Not the user making the request."),
|
|
||||||
start: z.string(),
|
start: z.string(),
|
||||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||||
timeZone: z.string(),
|
timeZone: z.string(),
|
||||||
@@ -12,13 +12,11 @@ export const fetchAvailability = async ({
|
|||||||
userId,
|
userId,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
eventTypeId,
|
|
||||||
}: {
|
}: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
dateFrom: string;
|
dateFrom: string;
|
||||||
dateTo: string;
|
dateTo: string;
|
||||||
eventTypeId?: number;
|
|
||||||
}): Promise<Partial<Availability> | { error: string }> => {
|
}): Promise<Partial<Availability> | { error: string }> => {
|
||||||
const params: { [k: string]: string } = {
|
const params: { [k: string]: string } = {
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -27,8 +25,6 @@ export const fetchAvailability = async ({
|
|||||||
dateTo,
|
dateTo,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(params);
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||||
@@ -51,30 +47,29 @@ export const fetchAvailability = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAvailabilityTool = (apiKey: string, userId: number) => {
|
const getAvailabilityTool = (apiKey: string) => {
|
||||||
return new DynamicStructuredTool({
|
return new DynamicStructuredTool({
|
||||||
description: "Get availability within range.",
|
description: "Get availability of users within range.",
|
||||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
func: async ({ userIds, dateFrom, dateTo }) => {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
await fetchAvailability({
|
await Promise.all(
|
||||||
apiKey,
|
userIds.map(
|
||||||
userId,
|
async (userId) =>
|
||||||
dateFrom,
|
await fetchAvailability({
|
||||||
dateTo,
|
userId: userId,
|
||||||
eventTypeId,
|
apiKey,
|
||||||
})
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
name: "getAvailability",
|
name: "getAvailability",
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
|
userIds: z.array(z.number()).describe("The users to fetch availability for."),
|
||||||
dateFrom: z.string(),
|
dateFrom: z.string(),
|
||||||
dateTo: z.string(),
|
dateTo: z.string(),
|
||||||
eventTypeId: z
|
|
||||||
.number()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const fetchBookings = async ({
|
|||||||
|
|
||||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||||
return new DynamicStructuredTool({
|
return new DynamicStructuredTool({
|
||||||
description: "Get bookings for a user between two dates.",
|
description: "Get bookings for the primary user between two dates.",
|
||||||
func: async ({ from, to }) => {
|
func: async ({ from, to }) => {
|
||||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import type { EventType } from "../types/eventType";
|
|||||||
/**
|
/**
|
||||||
* Fetches event types by user ID.
|
* Fetches event types by user ID.
|
||||||
*/
|
*/
|
||||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
|
||||||
const params = {
|
const params: Record<string, string> = {
|
||||||
apiKey,
|
apiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
params["userId"] = userId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(params);
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||||
@@ -28,6 +32,7 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
|||||||
|
|
||||||
return data.event_types.map((eventType: EventType) => ({
|
return data.event_types.map((eventType: EventType) => ({
|
||||||
id: eventType.id,
|
id: eventType.id,
|
||||||
|
slug: eventType.slug,
|
||||||
length: eventType.length,
|
length: eventType.length,
|
||||||
title: eventType.title,
|
title: eventType.title,
|
||||||
}));
|
}));
|
||||||
@@ -35,16 +40,19 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
|||||||
|
|
||||||
const getEventTypesTool = (apiKey: string) => {
|
const getEventTypesTool = (apiKey: string) => {
|
||||||
return new DynamicStructuredTool({
|
return new DynamicStructuredTool({
|
||||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
|
||||||
func: async () => {
|
func: async ({ userId }) => {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
await fetchEventTypes({
|
await fetchEventTypes({
|
||||||
apiKey,
|
apiKey,
|
||||||
|
userId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
name: "getEventTypes",
|
name: "getEventTypes",
|
||||||
schema: z.object({}),
|
schema: z.object({
|
||||||
|
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "~/src/env.mjs";
|
||||||
|
import type { User, UserList } from "~/src/types/user";
|
||||||
|
import sendEmail from "~/src/utils/sendEmail";
|
||||||
|
|
||||||
|
export const sendBookingLink = async ({
|
||||||
|
user,
|
||||||
|
agentEmail,
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
eventTypeSlug,
|
||||||
|
date,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
user: User;
|
||||||
|
users: UserList;
|
||||||
|
agentEmail: string;
|
||||||
|
subject: string;
|
||||||
|
to: string[];
|
||||||
|
message: string;
|
||||||
|
eventTypeSlug: string;
|
||||||
|
date: string;
|
||||||
|
}) => {
|
||||||
|
const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
cc: user.email,
|
||||||
|
from: agentEmail,
|
||||||
|
text: message.split("[[[Booking Link]]]").join(url),
|
||||||
|
html: message
|
||||||
|
.split("\n")
|
||||||
|
.join("<br>")
|
||||||
|
.split("[[[Booking Link]]]")
|
||||||
|
.join(`<a href="${url}">Booking Link</a>`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return "Booking link sent";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendBookingLinkTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Send a booking link via email. Useful for scheduling with non cal users.",
|
||||||
|
func: async ({ message, subject, to, eventTypeSlug, date }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await sendBookingLink({
|
||||||
|
apiKey,
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
agentEmail,
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
eventTypeSlug,
|
||||||
|
date,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "sendBookingLink",
|
||||||
|
|
||||||
|
schema: z.object({
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Make sure to nicely format the message and introduce yourself as the primary user's booking assistant. Make sure to include a spot for the link using: [[[Booking Link]]]"
|
||||||
|
),
|
||||||
|
subject: z.string(),
|
||||||
|
to: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe("array of emails to send the booking link to. Primary user is automatically CC'd"),
|
||||||
|
eventTypeSlug: z.string().describe("the slug of the event type to book"),
|
||||||
|
date: z.string().describe("the date (yyyy-mm-dd) to suggest for the booking"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sendBookingLinkTool;
|
||||||
@@ -2,8 +2,17 @@ import type { EventType } from "./eventType";
|
|||||||
import type { WorkingHours } from "./workingHours";
|
import type { WorkingHours } from "./workingHours";
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
username: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
eventTypes: EventType[];
|
eventTypes: EventType[];
|
||||||
workingHours: WorkingHours[];
|
workingHours: WorkingHours[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserList = {
|
||||||
|
id?: number;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
type: "fromUsername" | "fromEmail";
|
||||||
|
}[];
|
||||||
|
|||||||
+57
-23
@@ -2,30 +2,40 @@ import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
|||||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||||
|
|
||||||
import { env } from "../env.mjs";
|
import { env } from "../env.mjs";
|
||||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
import createBookingIfAvailable from "../tools/createBooking";
|
||||||
import deleteBooking from "../tools/deleteBooking";
|
import deleteBooking from "../tools/deleteBooking";
|
||||||
import getAvailability from "../tools/getAvailability";
|
import getAvailability from "../tools/getAvailability";
|
||||||
import getBookings from "../tools/getBookings";
|
import getBookings from "../tools/getBookings";
|
||||||
|
import sendBookingLink from "../tools/sendBookingLink";
|
||||||
import updateBooking from "../tools/updateBooking";
|
import updateBooking from "../tools/updateBooking";
|
||||||
import type { EventType } from "../types/eventType";
|
import type { EventType } from "../types/eventType";
|
||||||
import type { User } from "../types/user";
|
import type { User, UserList } from "../types/user";
|
||||||
import type { WorkingHours } from "../types/workingHours";
|
import type { WorkingHours } from "../types/workingHours";
|
||||||
import now from "./now";
|
import now from "./now";
|
||||||
|
|
||||||
const gptModel = "gpt-4";
|
const gptModel = "gpt-4";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core of the Cal AI booking agent: a LangChain Agent Executor.
|
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
|
||||||
* Uses a toolchain to book meetings, list available slots, etc.
|
* Uses a toolchain to book meetings, list available slots, etc.
|
||||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||||
*/
|
*/
|
||||||
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
|
const agent = async (
|
||||||
|
input: string,
|
||||||
|
user: User,
|
||||||
|
users: UserList,
|
||||||
|
apiKey: string,
|
||||||
|
userId: number,
|
||||||
|
agentEmail: string
|
||||||
|
) => {
|
||||||
const tools = [
|
const tools = [
|
||||||
createBookingIfAvailable(apiKey, userId),
|
// getEventTypes(apiKey),
|
||||||
getAvailability(apiKey, userId),
|
getAvailability(apiKey),
|
||||||
getBookings(apiKey, userId),
|
getBookings(apiKey, userId),
|
||||||
|
createBookingIfAvailable(apiKey, userId, users),
|
||||||
updateBooking(apiKey, userId),
|
updateBooking(apiKey, userId),
|
||||||
deleteBooking(apiKey),
|
deleteBooking(apiKey),
|
||||||
|
sendBookingLink(apiKey, user, users, agentEmail),
|
||||||
];
|
];
|
||||||
|
|
||||||
const model = new ChatOpenAI({
|
const model = new ChatOpenAI({
|
||||||
@@ -39,24 +49,48 @@ const agent = async (input: string, user: User, apiKey: string, userId: number)
|
|||||||
*/
|
*/
|
||||||
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||||
agentArgs: {
|
agentArgs: {
|
||||||
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
|
||||||
Make sure your final answers are definitive, complete and well formatted.
|
Make sure your final answers are definitive, complete and well formatted.
|
||||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||||
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
|
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
||||||
|
|
||||||
The current time in the user's timezone is: ${now(user.timeZone)}
|
The primary user's id is: ${userId}
|
||||||
The user's time zone is: ${user.timeZone}
|
The primary user's username is: ${user.username}
|
||||||
The user's event types are: ${user.eventTypes
|
The current time in the primary user's timezone is: ${now(user.timeZone)}
|
||||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
The primary user's time zone is: ${user.timeZone}
|
||||||
.join("\n")}
|
The primary user's event types are: ${user.eventTypes
|
||||||
The user's working hours are: ${user.workingHours
|
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
|
||||||
.map(
|
.join("\n")}
|
||||||
(w: WorkingHours) =>
|
The primary user's working hours are: ${user.workingHours
|
||||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
.map(
|
||||||
w.startTime
|
(w: WorkingHours) =>
|
||||||
}, End Time (minutes in UTC): ${w.endTime}`
|
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||||
)
|
w.startTime
|
||||||
.join("\n")}
|
}, End Time (minutes in UTC): ${w.endTime};`
|
||||||
|
)
|
||||||
|
.join("\n")}
|
||||||
|
${
|
||||||
|
users.length
|
||||||
|
? `The email references the following @usernames and emails: ${users
|
||||||
|
.map(
|
||||||
|
(u) =>
|
||||||
|
`${
|
||||||
|
(u.id ? `, id: ${u.id}` : "id: (non user)") +
|
||||||
|
(u.username
|
||||||
|
? u.type === "fromUsername"
|
||||||
|
? `, username: @${u.username}`
|
||||||
|
: ", username: REDACTED"
|
||||||
|
: ", (no username)") +
|
||||||
|
(u.email
|
||||||
|
? u.type === "fromEmail"
|
||||||
|
? `, email: ${u.email}`
|
||||||
|
: ", email: REDACTED"
|
||||||
|
: ", (no email)")
|
||||||
|
};`
|
||||||
|
)
|
||||||
|
.join("\n")}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
agentType: "openai-functions",
|
agentType: "openai-functions",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const context = { apiKey: "", userId: "" };
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import type { UserList } from "../types/user";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Extracts usernames (@Example) and emails (hi@example.com) from a string
|
||||||
|
*/
|
||||||
|
export const extractUsers = async (text: string) => {
|
||||||
|
const usernames = text.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)?.map((username) => username.slice(1));
|
||||||
|
const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g);
|
||||||
|
|
||||||
|
const dbUsersFromUsernames = usernames
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
username: {
|
||||||
|
in: usernames,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const usersFromUsernames = usernames
|
||||||
|
? usernames.map((username) => {
|
||||||
|
const user = dbUsersFromUsernames.find((u) => u.username === username);
|
||||||
|
return user
|
||||||
|
? {
|
||||||
|
username,
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
type: "fromUsername",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
username,
|
||||||
|
id: null,
|
||||||
|
email: null,
|
||||||
|
type: "fromUsername",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const dbUsersFromEmails = emails
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
in: emails,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const usersFromEmails = emails
|
||||||
|
? emails.map((email) => {
|
||||||
|
const user = dbUsersFromEmails.find((u) => u.email === email);
|
||||||
|
return user
|
||||||
|
? {
|
||||||
|
email,
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
type: "fromEmail",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
email,
|
||||||
|
id: null,
|
||||||
|
username: null,
|
||||||
|
type: "fromEmail",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...usersFromUsernames, ...usersFromEmails] as UserList;
|
||||||
|
};
|
||||||
@@ -8,12 +8,14 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
|||||||
const send = async ({
|
const send = async ({
|
||||||
subject,
|
subject,
|
||||||
to,
|
to,
|
||||||
|
cc,
|
||||||
from,
|
from,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
}: {
|
}: {
|
||||||
subject: string;
|
subject: string;
|
||||||
to: string;
|
to: string | string[];
|
||||||
|
cc?: string | string[];
|
||||||
from: string;
|
from: string;
|
||||||
text: string;
|
text: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
@@ -22,9 +24,10 @@ const send = async ({
|
|||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
to,
|
to,
|
||||||
|
cc,
|
||||||
from: {
|
from: {
|
||||||
email: from,
|
email: from,
|
||||||
name: "Cal AI",
|
name: "Cal.ai",
|
||||||
},
|
},
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const hostSchema = _HostModel.pick({
|
|||||||
userId: true,
|
userId: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const childrenSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
userId: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
export const schemaEventTypeBaseBodyParams = EventType.pick({
|
export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
@@ -45,6 +50,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
|||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
minimumBookingNotice: true,
|
minimumBookingNotice: true,
|
||||||
|
parentId: true,
|
||||||
beforeEventBuffer: true,
|
beforeEventBuffer: true,
|
||||||
afterEventBuffer: true,
|
afterEventBuffer: true,
|
||||||
teamId: true,
|
teamId: true,
|
||||||
@@ -56,7 +62,12 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
|||||||
bookingLimits: true,
|
bookingLimits: true,
|
||||||
durationLimits: true,
|
durationLimits: true,
|
||||||
})
|
})
|
||||||
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
|
.merge(
|
||||||
|
z.object({
|
||||||
|
children: z.array(childrenSchema).optional().default([]),
|
||||||
|
hosts: z.array(hostSchema).optional().default([]),
|
||||||
|
})
|
||||||
|
)
|
||||||
.partial()
|
.partial()
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -73,6 +84,7 @@ const schemaEventTypeCreateParams = z
|
|||||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||||
bookingFields: eventTypeBookingFields.optional(),
|
bookingFields: eventTypeBookingFields.optional(),
|
||||||
scheduleId: z.number().optional(),
|
scheduleId: z.number().optional(),
|
||||||
|
parentId: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -125,6 +137,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
|||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
slotInterval: true,
|
slotInterval: true,
|
||||||
|
parentId: true,
|
||||||
successRedirectUrl: true,
|
successRedirectUrl: true,
|
||||||
description: true,
|
description: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
@@ -137,6 +150,8 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
|||||||
durationLimits: true,
|
durationLimits: true,
|
||||||
}).merge(
|
}).merge(
|
||||||
z.object({
|
z.object({
|
||||||
|
children: z.array(childrenSchema).optional().default([]),
|
||||||
|
hosts: z.array(hostSchema).optional().default([]),
|
||||||
locations: z
|
locations: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /availability:
|
* /teams/{teamId}/availability:
|
||||||
* get:
|
* get:
|
||||||
* summary: Find user or team availability
|
* summary: Find team availability
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: apiKey
|
* name: apiKey
|
||||||
@@ -21,25 +21,14 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* type: string
|
* type: string
|
||||||
* example: "1234abcd5678efgh"
|
* example: "1234abcd5678efgh"
|
||||||
* description: Your API key
|
* description: Your API key
|
||||||
* - in: query
|
* - in: path
|
||||||
* name: userId
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* example: 101
|
|
||||||
* description: ID of the user to fetch the availability for
|
|
||||||
* - in: query
|
|
||||||
* name: teamId
|
* name: teamId
|
||||||
|
* required: true
|
||||||
* schema:
|
* schema:
|
||||||
* type: integer
|
* type: integer
|
||||||
* example: 123
|
* example: 123
|
||||||
* description: ID of the team to fetch the availability for
|
* description: ID of the team to fetch the availability for
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: username
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* example: "alice"
|
|
||||||
* description: username of the user to fetch the availability for
|
|
||||||
* - in: query
|
|
||||||
* name: dateFrom
|
* name: dateFrom
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
@@ -59,7 +48,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* type: integer
|
* type: integer
|
||||||
* example: 123
|
* example: 123
|
||||||
* description: Event Type ID of the event type to fetch the availability for
|
* description: Event Type ID of the event type to fetch the availability for
|
||||||
* operationId: availability
|
* operationId: team-availability
|
||||||
* tags:
|
* tags:
|
||||||
* - availability
|
* - availability
|
||||||
* responses:
|
* responses:
|
||||||
@@ -96,7 +85,89 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* 401:
|
* 401:
|
||||||
* description: Authorization information is missing or invalid.
|
* description: Authorization information is missing or invalid.
|
||||||
* 404:
|
* 404:
|
||||||
* description: User not found | Team not found | Team has no members
|
* description: Team not found | Team has no members
|
||||||
|
*
|
||||||
|
* /availability:
|
||||||
|
* get:
|
||||||
|
* summary: Find user availability
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* example: "1234abcd5678efgh"
|
||||||
|
* description: Your API key
|
||||||
|
* - in: query
|
||||||
|
* name: userId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* example: 101
|
||||||
|
* description: ID of the user to fetch the availability for
|
||||||
|
* - in: query
|
||||||
|
* name: username
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* example: "alice"
|
||||||
|
* description: username of the user to fetch the availability for
|
||||||
|
* - in: query
|
||||||
|
* name: dateFrom
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: date
|
||||||
|
* example: "2023-05-14 00:00:00"
|
||||||
|
* description: Start Date of the availability query
|
||||||
|
* - in: query
|
||||||
|
* name: dateTo
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: date
|
||||||
|
* example: "2023-05-20 00:00:00"
|
||||||
|
* description: End Date of the availability query
|
||||||
|
* - in: query
|
||||||
|
* name: eventTypeId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* example: 123
|
||||||
|
* description: Event Type ID of the event type to fetch the availability for
|
||||||
|
* operationId: user-availability
|
||||||
|
* tags:
|
||||||
|
* - availability
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* example:
|
||||||
|
* busy:
|
||||||
|
* - start: "2023-05-14T10:00:00.000Z"
|
||||||
|
* end: "2023-05-14T11:00:00.000Z"
|
||||||
|
* title: "Team meeting between Alice and Bob"
|
||||||
|
* - start: "2023-05-15T14:00:00.000Z"
|
||||||
|
* end: "2023-05-15T15:00:00.000Z"
|
||||||
|
* title: "Project review between Carol and Dave"
|
||||||
|
* - start: "2023-05-16T09:00:00.000Z"
|
||||||
|
* end: "2023-05-16T10:00:00.000Z"
|
||||||
|
* - start: "2023-05-17T13:00:00.000Z"
|
||||||
|
* end: "2023-05-17T14:00:00.000Z"
|
||||||
|
* timeZone: "America/New_York"
|
||||||
|
* workingHours:
|
||||||
|
* - days: [1, 2, 3, 4, 5]
|
||||||
|
* startTime: 540
|
||||||
|
* endTime: 1020
|
||||||
|
* userId: 101
|
||||||
|
* dateOverrides:
|
||||||
|
* - date: "2023-05-15"
|
||||||
|
* startTime: 600
|
||||||
|
* endTime: 960
|
||||||
|
* userId: 101
|
||||||
|
* currentSeats: 4
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: User not found
|
||||||
*/
|
*/
|
||||||
interface MemberRoles {
|
interface MemberRoles {
|
||||||
[userId: number | string]: MembershipRole;
|
[userId: number | string]: MembershipRole;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
team: { select: { slug: true } },
|
team: { select: { slug: true } },
|
||||||
users: true,
|
users: true,
|
||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
|
children: { select: { id: true, userId: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await checkPermissions(req, eventType);
|
await checkPermissions(req, eventType);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
team: { select: { slug: true } },
|
team: { select: { slug: true } },
|
||||||
users: true,
|
users: true,
|
||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
|
children: { select: { id: true, userId: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// this really should return [], but backwards compatibility..
|
// this really should return [], but backwards compatibility..
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
|
|
||||||
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||||
|
|
||||||
|
import checkParentEventOwnership from "./_utils/checkParentEventOwnership";
|
||||||
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
|
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
|
||||||
|
import checkUserMembership from "./_utils/checkUserMembership";
|
||||||
import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,10 +120,13 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
* schedulingType:
|
* schedulingType:
|
||||||
* type: string
|
* type: string
|
||||||
* description: The type of scheduling if a Team event. Required for team events only
|
* description: The type of scheduling if a Team event. Required for team events only
|
||||||
* enum: [ROUND_ROBIN, COLLECTIVE]
|
* enum: [ROUND_ROBIN, COLLECTIVE, MANAGED]
|
||||||
* price:
|
* price:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: Price of the event type booking
|
* description: Price of the event type booking
|
||||||
|
* parentId:
|
||||||
|
* type: integer
|
||||||
|
* description: EventTypeId of the parent managed event
|
||||||
* currency:
|
* currency:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Currency acronym. Eg- usd, eur, gbp, etc.
|
* description: Currency acronym. Eg- usd, eur, gbp, etc.
|
||||||
@@ -276,6 +281,11 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
|
|
||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|
||||||
|
if (parsedBody.parentId) {
|
||||||
|
await checkParentEventOwnership(parsedBody.parentId, userId);
|
||||||
|
await checkUserMembership(parsedBody.parentId, parsedBody.userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (isAdmin && parsedBody.userId) {
|
if (isAdmin && parsedBody.userId) {
|
||||||
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
|
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user, identified by the provided userId, has ownership (or admin rights) over
|
||||||
|
* the team associated with the event type identified by the parentId.
|
||||||
|
*
|
||||||
|
* @param parentId - The ID of the parent event type.
|
||||||
|
* @param userId - The ID of the user.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} If the parent event type is not found,
|
||||||
|
* if the parent event type doesn't belong to any team,
|
||||||
|
* or if the user doesn't have ownership or admin rights to the associated team.
|
||||||
|
*/
|
||||||
|
export default async function checkParentEventOwnership(parentId: number, userId: number) {
|
||||||
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentEventType) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: "Parent event type not found.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentEventType.teamId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "This event type is not capable of having children",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamMember = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
teamId: parentEventType.teamId,
|
||||||
|
userId: userId,
|
||||||
|
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamMember) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: "User is not authorized to access the team to which the parent event type belongs.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user, identified by the provided userId, is a member of the team associated
|
||||||
|
* with the event type identified by the parentId.
|
||||||
|
*
|
||||||
|
* @param parentId - The ID of the event type.
|
||||||
|
* @param userId - The ID of the user.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} If the event type is not found,
|
||||||
|
* if the event type doesn't belong to any team,
|
||||||
|
* or if the user isn't a member of the associated team.
|
||||||
|
*/
|
||||||
|
export default async function checkUserMembership(parentId: number, userId: number) {
|
||||||
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentEventType) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: "Event type not found.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentEventType.teamId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "This event type is not capable of having children.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamMember = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
teamId: parentEventType.teamId,
|
||||||
|
userId: userId,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamMember) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "User is not a team member.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
@@ -8,7 +10,6 @@ import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
|||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
|
||||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<img
|
<img
|
||||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||||
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
|
||||||
|
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||||
|
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||||
|
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import type { AppCategories } from "@calcom/prisma/enums";
|
||||||
|
import { trpc, type RouterOutputs } from "@calcom/trpc";
|
||||||
|
import type { App } from "@calcom/types/App";
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
List,
|
||||||
|
showToast,
|
||||||
|
Button,
|
||||||
|
DropdownMenuItem,
|
||||||
|
Alert,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
import { MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import AppListCard from "@components/AppListCard";
|
||||||
|
|
||||||
|
interface AppListProps {
|
||||||
|
variant?: AppCategories;
|
||||||
|
data: RouterOutputs["viewer"]["integrations"];
|
||||||
|
handleDisconnect: (credentialId: number) => void;
|
||||||
|
listClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => {
|
||||||
|
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||||
|
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSuccessCallback = useCallback(() => {
|
||||||
|
setBulkUpdateModal(true);
|
||||||
|
showToast("Default app updated successfully", "success");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast("Default app updated successfully", "success");
|
||||||
|
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showToast(`Error: ${error.message}`, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ChildAppCard = ({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
|
||||||
|
credentialOwner?: CredentialOwner;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const appSlug = item?.slug;
|
||||||
|
const appIsDefault =
|
||||||
|
appSlug === defaultConferencingApp?.appSlug ||
|
||||||
|
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
||||||
|
return (
|
||||||
|
<AppListCard
|
||||||
|
key={item.name}
|
||||||
|
description={item.description}
|
||||||
|
title={item.name}
|
||||||
|
logo={item.logo}
|
||||||
|
isDefault={appIsDefault}
|
||||||
|
shouldHighlight
|
||||||
|
slug={item.slug}
|
||||||
|
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
|
||||||
|
credentialOwner={item?.credentialOwner}
|
||||||
|
actions={
|
||||||
|
!item.credentialOwner?.readOnly ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Dropdown modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<DropdownItem
|
||||||
|
type="button"
|
||||||
|
color="secondary"
|
||||||
|
StartIcon={Video}
|
||||||
|
onClick={() => {
|
||||||
|
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
|
||||||
|
if (locationType?.linkType === "static") {
|
||||||
|
setLocationType({ ...locationType, slug: appSlug });
|
||||||
|
} else {
|
||||||
|
updateDefaultAppMutation.mutate({
|
||||||
|
appSlug,
|
||||||
|
});
|
||||||
|
setBulkUpdateModal(true);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{t("set_as_default")}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<ConnectOrDisconnectIntegrationMenuItem
|
||||||
|
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
|
||||||
|
type={item.type}
|
||||||
|
isGlobal={item.isGlobal}
|
||||||
|
installed
|
||||||
|
invalidCredentialIds={item.invalidCredentialIds}
|
||||||
|
handleDisconnect={handleDisconnect}
|
||||||
|
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}>
|
||||||
|
<AppSettings slug={item.slug} />
|
||||||
|
</AppListCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
|
||||||
|
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||||
|
const appCards = [];
|
||||||
|
|
||||||
|
if (app.userCredentialIds.length) {
|
||||||
|
appCards.push(<ChildAppCard item={app} />);
|
||||||
|
}
|
||||||
|
for (const team of app.teams) {
|
||||||
|
if (team) {
|
||||||
|
appCards.push(
|
||||||
|
<ChildAppCard
|
||||||
|
item={{
|
||||||
|
...app,
|
||||||
|
credentialOwner: {
|
||||||
|
name: team.name,
|
||||||
|
avatar: team.logo,
|
||||||
|
teamId: team.teamId,
|
||||||
|
credentialId: team.credentialId,
|
||||||
|
readOnly: !team.isAdmin,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appCards;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List className={listClassName}>
|
||||||
|
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||||
|
{data.items
|
||||||
|
.filter((item) => item.invalidCredentialIds)
|
||||||
|
.map((item) => {
|
||||||
|
if (!item.teams.length) return <ChildAppCard item={item} />;
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
{locationType && (
|
||||||
|
<AppSetDefaultLinkDialog
|
||||||
|
locationType={locationType}
|
||||||
|
setLocationType={() => setLocationType(undefined)}
|
||||||
|
onSuccess={onSuccessCallback}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bulkUpdateModal && (
|
||||||
|
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConnectOrDisconnectIntegrationMenuItem(props: {
|
||||||
|
credentialId: number;
|
||||||
|
type: App["type"];
|
||||||
|
isGlobal?: boolean;
|
||||||
|
installed?: boolean;
|
||||||
|
invalidCredentialIds?: number[];
|
||||||
|
teamId?: number;
|
||||||
|
handleDisconnect: (credentialId: number, teamId?: number) => void;
|
||||||
|
}) {
|
||||||
|
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
const handleOpenChange = () => {
|
||||||
|
utils.viewer.integrations.invalidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (credentialId || type === "stripe_payment" || isGlobal) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<DropdownItem
|
||||||
|
color="destructive"
|
||||||
|
onClick={() => handleDisconnect(credentialId, teamId)}
|
||||||
|
disabled={isGlobal}
|
||||||
|
StartIcon={Trash}>
|
||||||
|
{t("remove_app")}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!installed) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center truncate">
|
||||||
|
<Alert severity="warning" title={t("not_installed")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InstallAppButton
|
||||||
|
type={type}
|
||||||
|
render={(buttonProps) => (
|
||||||
|
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
|
||||||
|
{t("install")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
onChanged={handleOpenChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ export const AppPage = ({
|
|||||||
|
|
||||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||||
|
|
||||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||||
{ appType: type },
|
{ appType: type },
|
||||||
{
|
{
|
||||||
@@ -264,8 +265,8 @@ export const AppPage = ({
|
|||||||
|
|
||||||
{price !== 0 && (
|
{price !== 0 && (
|
||||||
<span className="block text-right">
|
<span className="block text-right">
|
||||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
|
||||||
{feeType === "monthly" && "/" + t("month")}
|
{feeType === "monthly" && `/${t("month")}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ export const AppPage = ({
|
|||||||
currency: "USD",
|
currency: "USD",
|
||||||
useGrouping: false,
|
useGrouping: false,
|
||||||
}).format(price)}
|
}).format(price)}
|
||||||
{feeType === "monthly" && "/" + t("month")}
|
{feeType === "monthly" && `/${t("month")}`}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -322,7 +323,7 @@ export const AppPage = ({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
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 -mt-px mr-1 inline h-4 w-4" />
|
<Mail className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||||
|
|
||||||
{email}
|
{email}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
title={t("something_went_wrong")}
|
title={t("something_went_wrong")}
|
||||||
message={
|
message={
|
||||||
<span>
|
<span>
|
||||||
<Link href={"/apps/" + item.integration.slug}>{item.integration.name}</Link>:{" "}
|
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
|
||||||
{t("calendar_error")}
|
{t("calendar_error")}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const InstallAppButtonChild = ({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
className="w-auto"
|
||||||
onInteractOutside={(event) => {
|
onInteractOutside={(event) => {
|
||||||
if (mutation.isLoading) event.preventDefault();
|
if (mutation.isLoading) event.preventDefault();
|
||||||
}}>
|
}}>
|
||||||
@@ -94,6 +95,7 @@ export const InstallAppButtonChild = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
|
className="flex"
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||||
key={team.id}
|
key={team.id}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
@@ -26,11 +26,11 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
MeetingTimeInTimezones,
|
MeetingTimeInTimezones,
|
||||||
showToast,
|
showToast,
|
||||||
Tooltip,
|
|
||||||
TableActions,
|
TableActions,
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
|
Tooltip,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Check, Clock, MapPin, RefreshCcw, Send, Ban, X, CreditCard } from "@calcom/ui/components/icon";
|
import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
|
||||||
@@ -58,7 +58,6 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
i18n: { language },
|
i18n: { language },
|
||||||
} = useLocale();
|
} = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const router = useRouter();
|
|
||||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||||
@@ -259,14 +258,16 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
||||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||||
|
|
||||||
const onClickTableData = () => {
|
const buildBookingLink = () => {
|
||||||
const urlSearchParams = new URLSearchParams({
|
const urlSearchParams = new URLSearchParams({
|
||||||
allRemainingBookings: isTabRecurring.toString(),
|
allRemainingBookings: isTabRecurring.toString(),
|
||||||
});
|
});
|
||||||
if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email);
|
if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email);
|
||||||
router.push(`/booking/${booking.uid}?${urlSearchParams.toString()}`);
|
return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bookingLink = buildBookingLink();
|
||||||
|
|
||||||
const title = booking.title;
|
const title = booking.title;
|
||||||
// To be used after we run query on legacy bookings
|
// To be used after we run query on legacy bookings
|
||||||
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
|
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
|
||||||
@@ -337,54 +338,11 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||||
<td
|
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
|
||||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
<Link href={bookingLink}>
|
||||||
onClick={onClickTableData}>
|
<div className="cursor-pointer py-4">
|
||||||
<div className="cursor-pointer py-4">
|
|
||||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
|
||||||
<div className="text-subtle text-sm">
|
|
||||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
|
||||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
|
||||||
<MeetingTimeInTimezones
|
|
||||||
timeFormat={user?.timeFormat}
|
|
||||||
userTimezone={user?.timeZone}
|
|
||||||
startTime={booking.startTime}
|
|
||||||
endTime={booking.endTime}
|
|
||||||
attendees={booking.attendees}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isPending && (
|
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
|
||||||
{t("unconfirmed")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{booking.eventType?.team && (
|
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
|
||||||
{booking.eventType.team.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{booking.paid && !booking.payment[0] ? (
|
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
|
||||||
{t("error_collecting_card")}
|
|
||||||
</Badge>
|
|
||||||
) : booking.paid ? (
|
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
|
|
||||||
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{recurringDates !== undefined && (
|
|
||||||
<div className="text-muted mt-2 text-sm">
|
|
||||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
|
|
||||||
{/* Time and Badges for mobile */}
|
|
||||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
|
||||||
<div className="flex w-full items-center justify-between sm:hidden">
|
|
||||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||||
<div className="text-subtle pr-2 text-sm">
|
<div className="text-subtle text-sm">
|
||||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||||
<MeetingTimeInTimezones
|
<MeetingTimeInTimezones
|
||||||
@@ -395,66 +353,111 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
attendees={booking.attendees}
|
attendees={booking.attendees}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isPending && (
|
||||||
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||||
|
{t("unconfirmed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{booking.eventType?.team && (
|
||||||
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||||
|
{booking.eventType.team.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{booking.paid && !booking.payment[0] ? (
|
||||||
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||||
|
{t("error_collecting_card")}
|
||||||
|
</Badge>
|
||||||
|
) : booking.paid ? (
|
||||||
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
|
||||||
|
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{recurringDates !== undefined && (
|
||||||
|
<div className="text-muted mt-2 text-sm">
|
||||||
|
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
{isPending && (
|
</td>
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||||
{t("unconfirmed")}
|
<Link href={bookingLink}>
|
||||||
</Badge>
|
{/* Time and Badges for mobile */}
|
||||||
)}
|
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||||
{booking.eventType?.team && (
|
<div className="flex w-full items-center justify-between sm:hidden">
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||||
{booking.eventType.team.name}
|
<div className="text-subtle pr-2 text-sm">
|
||||||
</Badge>
|
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||||
)}
|
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
<MeetingTimeInTimezones
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
timeFormat={user?.timeFormat}
|
||||||
{t("pending_payment")}
|
userTimezone={user?.timeZone}
|
||||||
</Badge>
|
startTime={booking.startTime}
|
||||||
)}
|
endTime={booking.endTime}
|
||||||
{recurringDates !== undefined && (
|
attendees={booking.attendees}
|
||||||
<div className="text-muted text-sm sm:hidden">
|
/>
|
||||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="cursor-pointer py-4">
|
{isPending && (
|
||||||
<div
|
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||||
title={title}
|
{t("unconfirmed")}
|
||||||
className={classNames(
|
</Badge>
|
||||||
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
|
)}
|
||||||
isCancelled ? "line-through" : ""
|
{booking.eventType?.team && (
|
||||||
)}>
|
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||||
{title}
|
{booking.eventType.team.name}
|
||||||
<span> </span>
|
</Badge>
|
||||||
|
)}
|
||||||
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
<Badge className="me-2 ms-2 hidden sm:inline-flex" variant="orange">
|
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||||
{t("pending_payment")}
|
{t("pending_payment")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{recurringDates !== undefined && (
|
||||||
|
<div className="text-muted text-sm sm:hidden">
|
||||||
|
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{booking.description && (
|
|
||||||
|
<div className="cursor-pointer py-4">
|
||||||
<div
|
<div
|
||||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
title={title}
|
||||||
title={booking.description}>
|
className={classNames(
|
||||||
"{booking.description}"
|
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
|
||||||
|
isCancelled ? "line-through" : ""
|
||||||
|
)}>
|
||||||
|
{title}
|
||||||
|
<span> </span>
|
||||||
|
|
||||||
|
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
|
||||||
|
<Badge className="me-2 ms-2 hidden sm:inline-flex" variant="orange">
|
||||||
|
{t("pending_payment")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{booking.description && (
|
||||||
{booking.attendees.length !== 0 && (
|
<div
|
||||||
<DisplayAttendees
|
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||||
attendees={booking.attendees}
|
title={booking.description}>
|
||||||
user={booking.user}
|
"{booking.description}"
|
||||||
currentEmail={user?.email}
|
</div>
|
||||||
/>
|
)}
|
||||||
)}
|
{booking.attendees.length !== 0 && (
|
||||||
{isCancelled && booking.rescheduled && (
|
<DisplayAttendees
|
||||||
<div className="mt-2 inline-block md:hidden">
|
attendees={booking.attendees}
|
||||||
<RequestSentMessage />
|
user={booking.user}
|
||||||
</div>
|
currentEmail={user?.email}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
|
{isCancelled && booking.rescheduled && (
|
||||||
|
<div className="mt-2 inline-block md:hidden">
|
||||||
|
<RequestSentMessage />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||||
{isUpcoming && !isCancelled ? (
|
{isUpcoming && !isCancelled ? (
|
||||||
@@ -573,7 +576,7 @@ const FirstAttendee = ({
|
|||||||
<a
|
<a
|
||||||
key={user.email}
|
key={user.email}
|
||||||
className=" hover:text-blue-500"
|
className=" hover:text-blue-500"
|
||||||
href={"mailto:" + user.email}
|
href={`mailto:${user.email}`}
|
||||||
onClick={(e) => e.stopPropagation()}>
|
onClick={(e) => e.stopPropagation()}>
|
||||||
{user.name}
|
{user.name}
|
||||||
</a>
|
</a>
|
||||||
@@ -587,7 +590,7 @@ type AttendeeProps = {
|
|||||||
|
|
||||||
const Attendee = ({ email, name }: AttendeeProps) => {
|
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||||
return (
|
return (
|
||||||
<a className="hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
|
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
|
||||||
{name || email}
|
{name || email}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: `Invalid URL for ${eventLocationType.label}. ${
|
message: `Invalid URL for ${eventLocationType.label}. ${
|
||||||
sampleUrl ? "Sample URL: " + sampleUrl : ""
|
sampleUrl ? `Sample URL: ${sampleUrl}` : ""
|
||||||
}`,
|
}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Copy, Edit } from "@calcom/ui/components/icon";
|
import { Copy, Edit, Info } from "@calcom/ui/components/icon";
|
||||||
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
|
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
|
||||||
|
|
||||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||||
@@ -67,7 +67,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("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`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventNameObject: EventNameObjectType = {
|
const eventNameObject: EventNameObjectType = {
|
||||||
@@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
|
|
||||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-8">
|
<div className="flex flex-col space-y-4">
|
||||||
{/**
|
{/**
|
||||||
* Only display calendar selector if user has connected calendars AND if it's not
|
* Only display calendar selector if user has connected calendars AND if it's not
|
||||||
* a team event. Since we don't have logic to handle each attendee calendar (for now).
|
* a team event. Since we don't have logic to handle each attendee calendar (for now).
|
||||||
* This will fallback to each user selected destination calendar.
|
* This will fallback to each user selected destination calendar.
|
||||||
*/}
|
*/}
|
||||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||||
<div className="flex flex-col">
|
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col">
|
||||||
<Label>{t("add_to_calendar")}</Label>
|
<div className="flex justify-between">
|
||||||
<Link
|
<Label className="font-medium">{t("add_to_calendar")}</Label>
|
||||||
href="/apps/categories/calendar"
|
<Link
|
||||||
target="_blank"
|
href="/apps/categories/calendar"
|
||||||
className="hover:text-emphasis text-default text-sm">
|
target="_blank"
|
||||||
{t("add_another_calendar")}
|
className="hover:text-emphasis text-default text-sm">
|
||||||
</Link>
|
{t("add_another_calendar")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="-mt-1 w-full">
|
||||||
|
<Controller
|
||||||
|
control={formMethods.control}
|
||||||
|
name="destinationCalendar"
|
||||||
|
defaultValue={eventType.destinationCalendar || undefined}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<DestinationCalendarSelector
|
||||||
|
destinationCalendar={eventType.destinationCalendar}
|
||||||
|
value={value ? value.externalId : undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
hidePlaceholder
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="-mt-1 w-full">
|
)}
|
||||||
<Controller
|
<div className="w-full">
|
||||||
control={formMethods.control}
|
<TextField
|
||||||
name="destinationCalendar"
|
label={t("event_name_in_calendar")}
|
||||||
defaultValue={eventType.destinationCalendar || undefined}
|
type="text"
|
||||||
render={({ field: { onChange, value } }) => (
|
{...shouldLockDisableProps("eventName")}
|
||||||
<DestinationCalendarSelector
|
placeholder={eventNamePlaceholder}
|
||||||
destinationCalendar={eventType.destinationCalendar}
|
defaultValue={eventType.eventName || ""}
|
||||||
value={value ? value.externalId : undefined}
|
{...formMethods.register("eventName")}
|
||||||
onChange={onChange}
|
addOnSuffix={
|
||||||
hidePlaceholder
|
<Button
|
||||||
/>
|
color="minimal"
|
||||||
)}
|
size="sm"
|
||||||
/>
|
aria-label="edit custom name"
|
||||||
</div>
|
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||||
<p className="text-default text-sm">{t("select_which_cal")}</p>
|
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="w-full">
|
|
||||||
<TextField
|
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||||
label={t("event_name_in_calendar")}
|
|
||||||
type="text"
|
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||||
{...shouldLockDisableProps("eventName")}
|
<FormBuilder
|
||||||
placeholder={eventNamePlaceholder}
|
title={t("booking_questions_title")}
|
||||||
defaultValue={eventType.eventName || ""}
|
description={t("booking_questions_description")}
|
||||||
{...formMethods.register("eventName")}
|
addFieldLabel={t("add_a_booking_question")}
|
||||||
addOnSuffix={
|
formProp="bookingFields"
|
||||||
<Button
|
{...shouldLockDisableProps("bookingFields")}
|
||||||
color="minimal"
|
dataStore={{
|
||||||
size="sm"
|
options: {
|
||||||
aria-label="edit custom name"
|
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
},
|
||||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
}}
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
|
|
||||||
<div>
|
|
||||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
|
||||||
</div>
|
|
||||||
<hr className="border-subtle" />
|
|
||||||
<FormBuilder
|
|
||||||
title={t("booking_questions_title")}
|
|
||||||
description={t("booking_questions_description")}
|
|
||||||
addFieldLabel={t("add_a_booking_question")}
|
|
||||||
formProp="bookingFields"
|
|
||||||
{...shouldLockDisableProps("bookingFields")}
|
|
||||||
dataStore={{
|
|
||||||
options: {
|
|
||||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<hr className="border-subtle" />
|
|
||||||
<RequiresConfirmationController
|
<RequiresConfirmationController
|
||||||
eventType={eventType}
|
eventType={eventType}
|
||||||
seatsEnabled={seatsEnabled}
|
seatsEnabled={seatsEnabled}
|
||||||
@@ -204,13 +206,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
requiresConfirmation={requiresConfirmation}
|
requiresConfirmation={requiresConfirmation}
|
||||||
onRequiresConfirmation={setRequiresConfirmation}
|
onRequiresConfirmation={setRequiresConfirmation}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="requiresBookerEmailVerification"
|
name="requiresBookerEmailVerification"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.requiresBookerEmailVerification}
|
defaultValue={eventType.requiresBookerEmailVerification}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("requires_booker_email_verification")}
|
title={t("requires_booker_email_verification")}
|
||||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||||
description={t("description_requires_booker_email_verification")}
|
description={t("description_requires_booker_email_verification")}
|
||||||
@@ -219,13 +223,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="hideCalendarNotes"
|
name="hideCalendarNotes"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.hideCalendarNotes}
|
defaultValue={eventType.hideCalendarNotes}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_notes")}
|
title={t("disable_notes")}
|
||||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||||
description={t("disable_notes_description")}
|
description={t("disable_notes_description")}
|
||||||
@@ -234,13 +240,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="successRedirectUrl"
|
name="successRedirectUrl"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName={classNames(
|
||||||
|
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||||
|
redirectUrlVisible && "rounded-b-none"
|
||||||
|
)}
|
||||||
|
childrenClassName="lg:ml-0"
|
||||||
title={t("redirect_success_booking")}
|
title={t("redirect_success_booking")}
|
||||||
{...successRedirectUrlLocked}
|
{...successRedirectUrlLocked}
|
||||||
description={t("redirect_url_description")}
|
description={t("redirect_url_description")}
|
||||||
@@ -249,8 +261,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
setRedirectUrlVisible(e);
|
setRedirectUrlVisible(e);
|
||||||
onChange(e ? value : "");
|
onChange(e ? value : "");
|
||||||
}}>
|
}}>
|
||||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
<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")}
|
||||||
@@ -274,10 +285,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName={classNames(
|
||||||
|
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||||
|
hashedLinkVisible && "rounded-b-none"
|
||||||
|
)}
|
||||||
|
childrenClassName="lg:ml-0"
|
||||||
data-testid="hashedLinkCheck"
|
data-testid="hashedLinkCheck"
|
||||||
title={t("private_link")}
|
title={t("private_link")}
|
||||||
|
Badge={
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
|
||||||
|
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
{...shouldLockDisableProps("hashedLinkCheck")}
|
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||||
description={t("private_link_description", { appName: APP_NAME })}
|
description={t("private_link_description", { appName: APP_NAME })}
|
||||||
checked={hashedLinkVisible}
|
checked={hashedLinkVisible}
|
||||||
@@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||||
setHashedLinkVisible(e);
|
setHashedLinkVisible(e);
|
||||||
}}>
|
}}>
|
||||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
<div className="lg:-ml-2">
|
|
||||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||||
<TextField
|
<TextField
|
||||||
disabled
|
disabled
|
||||||
@@ -321,7 +345,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="seatsPerTimeSlotEnabled"
|
name="seatsPerTimeSlotEnabled"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -329,6 +353,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName={classNames(
|
||||||
|
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||||
|
value && "rounded-b-none"
|
||||||
|
)}
|
||||||
|
childrenClassName="lg:ml-0"
|
||||||
data-testid="offer-seats-toggle"
|
data-testid="offer-seats-toggle"
|
||||||
title={t("offer_seats")}
|
title={t("offer_seats")}
|
||||||
{...seatsLocked}
|
{...seatsLocked}
|
||||||
@@ -349,45 +379,49 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
}
|
}
|
||||||
onChange(e);
|
onChange(e);
|
||||||
}}>
|
}}>
|
||||||
<Controller
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
name="seatsPerTimeSlot"
|
<Controller
|
||||||
control={formMethods.control}
|
name="seatsPerTimeSlot"
|
||||||
defaultValue={eventType.seatsPerTimeSlot}
|
control={formMethods.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
defaultValue={eventType.seatsPerTimeSlot}
|
||||||
<div className="lg:-ml-2">
|
render={({ field: { value, onChange } }) => (
|
||||||
<TextField
|
<div className="lg:-ml-2">
|
||||||
required
|
<TextField
|
||||||
name="seatsPerTimeSlot"
|
required
|
||||||
labelSrOnly
|
name="seatsPerTimeSlot"
|
||||||
label={t("number_of_seats")}
|
labelSrOnly
|
||||||
type="number"
|
label={t("number_of_seats")}
|
||||||
disabled={seatsLocked.disabled}
|
type="number"
|
||||||
defaultValue={value || 2}
|
|
||||||
min={1}
|
|
||||||
addOnSuffix={<>{t("seats")}</>}
|
|
||||||
onChange={(e) => {
|
|
||||||
onChange(Math.abs(Number(e.target.value)));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="mt-2">
|
|
||||||
<CheckboxField
|
|
||||||
description={t("show_attendees")}
|
|
||||||
disabled={seatsLocked.disabled}
|
disabled={seatsLocked.disabled}
|
||||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
defaultValue={value || 2}
|
||||||
defaultChecked={!!eventType.seatsShowAttendees}
|
min={1}
|
||||||
|
addOnSuffix={<>{t("seats")}</>}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(Math.abs(Number(e.target.value)));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CheckboxField
|
||||||
|
description={t("show_attendees")}
|
||||||
|
disabled={seatsLocked.disabled}
|
||||||
|
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||||
|
defaultChecked={!!eventType.seatsShowAttendees}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CheckboxField
|
||||||
|
description={t("show_available_seats_count")}
|
||||||
|
disabled={seatsLocked.disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)
|
||||||
|
}
|
||||||
|
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
)}
|
||||||
<CheckboxField
|
/>
|
||||||
description={t("show_available_seats_count")}
|
</div>
|
||||||
disabled={seatsLocked.disabled}
|
|
||||||
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
|
|
||||||
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
||||||
</>
|
</>
|
||||||
@@ -395,13 +429,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
/>
|
/>
|
||||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_attendees_confirmation_emails")}
|
title={t("disable_attendees_confirmation_emails")}
|
||||||
description={t("disable_attendees_confirmation_emails_description")}
|
description={t("disable_attendees_confirmation_emails_description")}
|
||||||
checked={value || false}
|
checked={value || false}
|
||||||
@@ -417,7 +452,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
)}
|
)}
|
||||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.disableStandardEmails.confirmation.host"
|
name="metadata.disableStandardEmails.confirmation.host"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -425,6 +459,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_host_confirmation_emails")}
|
title={t("disable_host_confirmation_emails")}
|
||||||
description={t("disable_host_confirmation_emails_description")}
|
description={t("disable_host_confirmation_emails_description")}
|
||||||
checked={value || false}
|
checked={value || false}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!shouldLockDisableProps("apps").disabled && (
|
{!shouldLockDisableProps("apps").disabled && (
|
||||||
<div className="bg-muted rounded-md p-8">
|
<div className="bg-muted mt-6 rounded-md p-8">
|
||||||
{!isLoading && notInstalledApps?.length ? (
|
{!isLoading && notInstalledApps?.length ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
|
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
|
||||||
@@ -166,7 +166,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-default mb-6 text-sm font-normal">
|
<p className="text-default mb-6 text-sm font-normal">
|
||||||
<Trans i18nKey="available_apps_desc">
|
<Trans i18nKey="available_apps_desc">
|
||||||
You have no apps installed. View popular apps below and explore more in our
|
View popular apps below and explore more in our
|
||||||
<Link className="cursor-pointer underline" href="/apps">
|
<Link className="cursor-pointer underline" href="/apps">
|
||||||
App Store
|
App Store
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||||
import { useState, memo } from "react";
|
import { useState, memo, useEffect } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import type { OptionProps, SingleValueProps } from "react-select";
|
import type { OptionProps, SingleValueProps } from "react-select";
|
||||||
import { components } from "react-select";
|
import { components } from "react-select";
|
||||||
@@ -98,42 +98,43 @@ const EventTypeScheduleDetails = memo(
|
|||||||
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-default space-y-4 rounded border px-6 pb-4">
|
<div>
|
||||||
<ol className="table border-collapse text-sm">
|
<div className="border-subtle space-y-4 border-x p-6">
|
||||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
<ol className="table border-collapse text-sm">
|
||||||
const isAvailable = !!filterDays(index).length;
|
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||||
return (
|
const isAvailable = !!filterDays(index).length;
|
||||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
return (
|
||||||
<span
|
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||||
className={classNames(
|
<span
|
||||||
"w-20 font-medium sm:w-32 ",
|
className={classNames(
|
||||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
"w-20 font-medium sm:w-32 ",
|
||||||
)}>
|
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||||
{day}
|
)}>
|
||||||
</span>
|
{day}
|
||||||
{isLoading ? (
|
</span>
|
||||||
<SkeletonText className="block h-5 w-60" />
|
{isLoading ? (
|
||||||
) : isAvailable ? (
|
<SkeletonText className="block h-5 w-60" />
|
||||||
<div className="space-y-3 text-right">
|
) : isAvailable ? (
|
||||||
{filterDays(index).map((dayRange, i) => (
|
<div className="space-y-3 text-right">
|
||||||
<div key={i} className="text-default flex items-center leading-4">
|
{filterDays(index).map((dayRange, i) => (
|
||||||
<span className="w-16 sm:w-28 sm:text-left">
|
<div key={i} className="text-default flex items-center leading-4">
|
||||||
{format(dayRange.startTime, timeFormat === 12)}
|
<span className="w-16 sm:w-28 sm:text-left">
|
||||||
</span>
|
{format(dayRange.startTime, timeFormat === 12)}
|
||||||
<span className="ms-4">-</span>
|
</span>
|
||||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
<span className="ms-4">-</span>
|
||||||
</div>
|
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
) : (
|
||||||
)}
|
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||||
</li>
|
)}
|
||||||
);
|
</li>
|
||||||
})}
|
);
|
||||||
</ol>
|
})}
|
||||||
<hr className="border-subtle" />
|
</ol>
|
||||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
</div>
|
||||||
|
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||||
@@ -164,9 +165,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
t("locked_fields_admin_description"),
|
t("locked_fields_admin_description"),
|
||||||
t("locked_fields_member_description")
|
t("locked_fields_member_description")
|
||||||
);
|
);
|
||||||
const { watch } = useFormContext<FormValues>();
|
const { watch, setValue, getValues } = useFormContext<FormValues>();
|
||||||
const watchSchedule = watch("schedule");
|
const watchSchedule = watch("schedule");
|
||||||
const formMethods = useFormContext<FormValues>();
|
|
||||||
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
||||||
|
|
||||||
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
|
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
|
||||||
@@ -214,7 +214,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
|
|
||||||
setOptions(options);
|
setOptions(options);
|
||||||
|
|
||||||
const scheduleId = formMethods.getValues("schedule");
|
const scheduleId = getValues("schedule");
|
||||||
const value = options.find((option) =>
|
const value = options.find((option) =>
|
||||||
scheduleId
|
scheduleId
|
||||||
? option.value === scheduleId
|
? option.value === scheduleId
|
||||||
@@ -223,15 +223,20 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
||||||
);
|
);
|
||||||
|
|
||||||
formMethods.setValue("availability", value);
|
setValue("availability", value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const availabilityValue = formMethods.watch("availability");
|
const availabilityValue = watch("availability");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availabilityValue?.value) return;
|
||||||
|
setValue("schedule", availabilityValue.value);
|
||||||
|
}, [availabilityValue, setValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div>
|
<div className="border-subtle rounded-t-md border p-6">
|
||||||
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||||
{t("availability")}
|
{t("availability")}
|
||||||
{shouldLockIndicator("availability")}
|
{shouldLockIndicator("availability")}
|
||||||
@@ -248,7 +253,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
onChange={(selected) => {
|
onChange={(selected) => {
|
||||||
field.onChange(selected?.value || null);
|
field.onChange(selected?.value || null);
|
||||||
if (selected?.value) formMethods.setValue("availability", selected);
|
if (selected?.value) setValue("availability", selected);
|
||||||
}}
|
}}
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
value={availabilityValue}
|
value={availabilityValue}
|
||||||
@@ -276,7 +281,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
|
|
||||||
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { resetField, setValue } = useFormContext<FormValues>();
|
const { setValue } = useFormContext<FormValues>();
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||||
@@ -285,9 +290,7 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
|
|||||||
checked={!value}
|
checked={!value}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
onChange(!checked);
|
onChange(!checked);
|
||||||
if (checked) {
|
if (!checked) {
|
||||||
resetField("schedule");
|
|
||||||
} else {
|
|
||||||
setValue("schedule", null);
|
setValue("schedule", null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
|
|||||||
import type { PeriodType } from "@calcom/prisma/enums";
|
import type { PeriodType } from "@calcom/prisma/enums";
|
||||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||||
import { Plus, Trash } from "@calcom/ui/components/icon";
|
import { Plus, Trash2 } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
const MinimumBookingNoticeInput = React.forwardRef<
|
const MinimumBookingNoticeInput = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
@@ -83,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
|||||||
type="number"
|
type="number"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
min={0}
|
min={0}
|
||||||
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
className="mb-0 h-9 rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" ref={ref} {...passThroughProps} />
|
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isDisabled={passThroughProps.disabled}
|
isDisabled={passThroughProps.disabled}
|
||||||
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||||
defaultValue={durationTypeOptions.find(
|
defaultValue={durationTypeOptions.find(
|
||||||
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
||||||
)}
|
)}
|
||||||
@@ -170,8 +170,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
|
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div>
|
||||||
<div className="space-y-4 lg:space-y-8">
|
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
<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">
|
||||||
@@ -189,7 +189,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
value: 0,
|
value: 0,
|
||||||
},
|
},
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||||
label: minutes + " " + t("minutes"),
|
label: `${minutes} ${t("minutes")}`,
|
||||||
value: minutes,
|
value: minutes,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -225,7 +225,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
value: 0,
|
value: 0,
|
||||||
},
|
},
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||||
label: minutes + " " + t("minutes"),
|
label: `${minutes} ${t("minutes")}`,
|
||||||
value: minutes,
|
value: minutes,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -272,7 +272,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
value: -1,
|
value: -1,
|
||||||
},
|
},
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||||
label: minutes + " " + t("minutes"),
|
label: `${minutes} ${t("minutes")}`,
|
||||||
value: minutes,
|
value: minutes,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -295,159 +295,195 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="bookingLimits"
|
name="bookingLimits"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => {
|
||||||
<SettingsToggle
|
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||||
title={t("limit_booking_frequency")}
|
return (
|
||||||
{...bookingLimitsLocked}
|
<SettingsToggle
|
||||||
description={t("limit_booking_frequency_description")}
|
toggleSwitchAtTheEnd={true}
|
||||||
checked={Object.keys(value ?? {}).length > 0}
|
title={t("limit_booking_frequency")}
|
||||||
onCheckedChange={(active) => {
|
{...bookingLimitsLocked}
|
||||||
if (active) {
|
description={t("limit_booking_frequency_description")}
|
||||||
formMethods.setValue("bookingLimits", {
|
checked={isChecked}
|
||||||
PER_DAY: 1,
|
onCheckedChange={(active) => {
|
||||||
});
|
if (active) {
|
||||||
} else {
|
formMethods.setValue("bookingLimits", {
|
||||||
formMethods.setValue("bookingLimits", {});
|
PER_DAY: 1,
|
||||||
}
|
});
|
||||||
}}>
|
} else {
|
||||||
<IntervalLimitsManager
|
formMethods.setValue("bookingLimits", {});
|
||||||
disabled={bookingLimitsLocked.disabled}
|
}
|
||||||
propertyName="bookingLimits"
|
}}
|
||||||
defaultLimit={1}
|
switchContainerClassName={classNames(
|
||||||
step={1}
|
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||||
/>
|
isChecked && "rounded-b-none"
|
||||||
</SettingsToggle>
|
)}
|
||||||
)}
|
childrenClassName="lg:ml-0">
|
||||||
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
|
<IntervalLimitsManager
|
||||||
|
disabled={bookingLimitsLocked.disabled}
|
||||||
|
propertyName="bookingLimits"
|
||||||
|
defaultLimit={1}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsToggle>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="durationLimits"
|
name="durationLimits"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => {
|
||||||
<SettingsToggle
|
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||||
title={t("limit_total_booking_duration")}
|
return (
|
||||||
description={t("limit_total_booking_duration_description")}
|
<SettingsToggle
|
||||||
{...durationLimitsLocked}
|
toggleSwitchAtTheEnd={true}
|
||||||
checked={Object.keys(value ?? {}).length > 0}
|
switchContainerClassName={classNames(
|
||||||
onCheckedChange={(active) => {
|
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||||
if (active) {
|
isChecked && "rounded-b-none"
|
||||||
formMethods.setValue("durationLimits", {
|
)}
|
||||||
PER_DAY: 60,
|
childrenClassName="lg:ml-0"
|
||||||
});
|
title={t("limit_total_booking_duration")}
|
||||||
} else {
|
description={t("limit_total_booking_duration_description")}
|
||||||
formMethods.setValue("durationLimits", {});
|
{...durationLimitsLocked}
|
||||||
}
|
checked={isChecked}
|
||||||
}}>
|
onCheckedChange={(active) => {
|
||||||
<IntervalLimitsManager
|
if (active) {
|
||||||
propertyName="durationLimits"
|
formMethods.setValue("durationLimits", {
|
||||||
defaultLimit={60}
|
PER_DAY: 60,
|
||||||
disabled={durationLimitsLocked.disabled}
|
});
|
||||||
step={15}
|
} else {
|
||||||
textFieldSuffix={t("minutes")}
|
formMethods.setValue("durationLimits", {});
|
||||||
/>
|
}
|
||||||
</SettingsToggle>
|
}}>
|
||||||
)}
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
|
<IntervalLimitsManager
|
||||||
|
propertyName="durationLimits"
|
||||||
|
defaultLimit={60}
|
||||||
|
disabled={durationLimitsLocked.disabled}
|
||||||
|
step={15}
|
||||||
|
textFieldSuffix={t("minutes")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsToggle>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="periodType"
|
name="periodType"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => {
|
||||||
<SettingsToggle
|
const isChecked = value && value !== "UNLIMITED";
|
||||||
title={t("limit_future_bookings")}
|
|
||||||
description={t("limit_future_bookings_description")}
|
return (
|
||||||
{...periodTypeLocked}
|
<SettingsToggle
|
||||||
checked={value && value !== "UNLIMITED"}
|
toggleSwitchAtTheEnd={true}
|
||||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
switchContainerClassName={classNames(
|
||||||
<RadioGroup.Root
|
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||||
defaultValue={watchPeriodType}
|
isChecked && "rounded-b-none"
|
||||||
value={watchPeriodType}
|
)}
|
||||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
childrenClassName="lg:ml-0"
|
||||||
{PERIOD_TYPES.filter((opt) =>
|
title={t("limit_future_bookings")}
|
||||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
description={t("limit_future_bookings_description")}
|
||||||
).map((period) => {
|
{...periodTypeLocked}
|
||||||
if (period.type === "UNLIMITED") return null;
|
checked={isChecked}
|
||||||
return (
|
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||||
<div
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
className={classNames(
|
<RadioGroup.Root
|
||||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
defaultValue={watchPeriodType}
|
||||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
value={watchPeriodType}
|
||||||
)}
|
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||||
key={period.type}>
|
{PERIOD_TYPES.filter((opt) =>
|
||||||
{!periodTypeLocked.disabled && (
|
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||||
<RadioGroup.Item
|
).map((period) => {
|
||||||
id={period.type}
|
if (period.type === "UNLIMITED") return null;
|
||||||
value={period.type}
|
return (
|
||||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
<div
|
||||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
className={classNames(
|
||||||
</RadioGroup.Item>
|
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||||
)}
|
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
)}
|
||||||
{period.type === "ROLLING" && (
|
key={period.type}>
|
||||||
<div className="flex items-center">
|
{!periodTypeLocked.disabled && (
|
||||||
<TextField
|
<RadioGroup.Item
|
||||||
labelSrOnly
|
id={period.type}
|
||||||
type="number"
|
value={period.type}
|
||||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||||
placeholder="30"
|
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||||
disabled={periodTypeLocked.disabled}
|
</RadioGroup.Item>
|
||||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
)}
|
||||||
defaultValue={eventType.periodDays || 30}
|
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||||
/>
|
{period.type === "ROLLING" && (
|
||||||
<Select
|
<div className="flex items-center">
|
||||||
options={optionsPeriod}
|
<TextField
|
||||||
isSearchable={false}
|
labelSrOnly
|
||||||
isDisabled={periodTypeLocked.disabled}
|
type="number"
|
||||||
onChange={(opt) => {
|
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||||
formMethods.setValue(
|
placeholder="30"
|
||||||
"periodCountCalendarDays",
|
|
||||||
opt?.value.toString() as "0" | "1"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
defaultValue={
|
|
||||||
optionsPeriod.find(
|
|
||||||
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
|
||||||
) ?? optionsPeriod[0]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{period.type === "RANGE" && (
|
|
||||||
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
|
||||||
<Controller
|
|
||||||
name="periodDates"
|
|
||||||
control={formMethods.control}
|
|
||||||
defaultValue={periodDates}
|
|
||||||
render={() => (
|
|
||||||
<DateRangePicker
|
|
||||||
startDate={formMethods.getValues("periodDates").startDate}
|
|
||||||
endDate={formMethods.getValues("periodDates").endDate}
|
|
||||||
disabled={periodTypeLocked.disabled}
|
disabled={periodTypeLocked.disabled}
|
||||||
onDatesChange={({ startDate, endDate }) => {
|
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||||
formMethods.setValue("periodDates", {
|
defaultValue={eventType.periodDays || 30}
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<Select
|
||||||
/>
|
options={optionsPeriod}
|
||||||
|
isSearchable={false}
|
||||||
|
isDisabled={periodTypeLocked.disabled}
|
||||||
|
onChange={(opt) => {
|
||||||
|
formMethods.setValue(
|
||||||
|
"periodCountCalendarDays",
|
||||||
|
opt?.value.toString() as "0" | "1"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
defaultValue={
|
||||||
|
optionsPeriod.find(
|
||||||
|
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
||||||
|
) ?? optionsPeriod[0]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{period.type === "RANGE" && (
|
||||||
|
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||||
|
<Controller
|
||||||
|
name="periodDates"
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={periodDates}
|
||||||
|
render={() => (
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={formMethods.getValues("periodDates").startDate}
|
||||||
|
endDate={formMethods.getValues("periodDates").endDate}
|
||||||
|
disabled={periodTypeLocked.disabled}
|
||||||
|
onDatesChange={({ startDate, endDate }) => {
|
||||||
|
formMethods.setValue("periodDates", {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
})}
|
||||||
</div>
|
</RadioGroup.Root>
|
||||||
);
|
</div>
|
||||||
})}
|
</SettingsToggle>
|
||||||
</RadioGroup.Root>
|
);
|
||||||
</SettingsToggle>
|
}}
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName={classNames(
|
||||||
|
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||||
|
offsetToggle && "rounded-b-none"
|
||||||
|
)}
|
||||||
|
childrenClassName="lg:ml-0"
|
||||||
title={t("offset_toggle")}
|
title={t("offset_toggle")}
|
||||||
description={t("offset_toggle_description")}
|
description={t("offset_toggle_description")}
|
||||||
{...offsetStartLockedProps}
|
{...offsetStartLockedProps}
|
||||||
@@ -458,18 +494,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
formMethods.setValue("offsetStart", 0);
|
formMethods.setValue("offsetStart", 0);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<TextField
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
required
|
<TextField
|
||||||
type="number"
|
required
|
||||||
{...offsetStartLockedProps}
|
type="number"
|
||||||
label={t("offset_start")}
|
{...offsetStartLockedProps}
|
||||||
{...formMethods.register("offsetStart")}
|
label={t("offset_start")}
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
{...formMethods.register("offsetStart")}
|
||||||
hint={t("offset_start_description", {
|
addOnSuffix={<>{t("minutes")}</>}
|
||||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
hint={t("offset_start_description", {
|
||||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||||
})}
|
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||||
/>
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -509,19 +547,19 @@ const IntervalLimitItem = ({
|
|||||||
onIntervalSelect,
|
onIntervalSelect,
|
||||||
}: IntervalLimitItemProps) => {
|
}: IntervalLimitItemProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
<div className="mb-4 flex max-h-9 items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||||
className="mb-0 !h-auto"
|
className="mb-0"
|
||||||
placeholder={`${value}`}
|
placeholder={`${value}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={step}
|
min={step}
|
||||||
step={step}
|
step={step}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
addOnSuffix={textFieldSuffix}
|
addOnSuffix={textFieldSuffix}
|
||||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
|
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
@@ -529,9 +567,16 @@ const IntervalLimitItem = ({
|
|||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||||
onChange={onIntervalSelect}
|
onChange={onIntervalSelect}
|
||||||
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
{hasDeleteButton && !disabled && (
|
{hasDeleteButton && !disabled && (
|
||||||
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
StartIcon={Trash2}
|
||||||
|
color="destructive"
|
||||||
|
className="border-none"
|
||||||
|
onClick={() => onDelete(limitKey)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import type { EventLocationType } from "@calcom/app-store/locations";
|
|||||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import { slugify } from "@calcom/lib/slugify";
|
import { slugify } from "@calcom/lib/slugify";
|
||||||
import turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
@@ -131,12 +133,12 @@ export const EventSetupTab = (
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180].map(
|
const multipleDurationOptions = [
|
||||||
(mins) => ({
|
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 480,
|
||||||
value: mins,
|
].map((mins) => ({
|
||||||
label: t("multiple_duration_mins", { count: mins }),
|
value: mins,
|
||||||
})
|
label: t("multiple_duration_mins", { count: mins }),
|
||||||
);
|
}));
|
||||||
|
|
||||||
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||||
MultiValue<{
|
MultiValue<{
|
||||||
@@ -292,7 +294,6 @@ export const EventSetupTab = (
|
|||||||
|
|
||||||
const eventLabel =
|
const eventLabel =
|
||||||
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
|
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={`${location.type}${index}`}
|
key={`${location.type}${index}`}
|
||||||
@@ -301,7 +302,10 @@ export const EventSetupTab = (
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<img
|
<img
|
||||||
src={eventLocationType.iconUrl}
|
src={eventLocationType.iconUrl}
|
||||||
className="h-4 w-4 dark:invert-[.65]"
|
className={classNames(
|
||||||
|
"h-4 w-4",
|
||||||
|
classNames(invertLogoOnDark(eventLocationType.iconUrl))
|
||||||
|
)}
|
||||||
alt={`${eventLocationType.label} logo`}
|
alt={`${eventLocationType.label} logo`}
|
||||||
/>
|
/>
|
||||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||||
@@ -387,178 +391,185 @@ export const EventSetupTab = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-8">
|
<div className="space-y-4">
|
||||||
<TextField
|
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||||
required
|
|
||||||
label={t("title")}
|
|
||||||
{...shouldLockDisableProps("title")}
|
|
||||||
defaultValue={eventType.title}
|
|
||||||
{...formMethods.register("title")}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
{t("description")}
|
|
||||||
{shouldLockIndicator("description")}
|
|
||||||
</Label>
|
|
||||||
<DescriptionEditor
|
|
||||||
description={eventType?.description}
|
|
||||||
editable={!descriptionLockedProps.disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
required
|
|
||||||
label={t("URL")}
|
|
||||||
{...shouldLockDisableProps("slug")}
|
|
||||||
defaultValue={eventType.slug}
|
|
||||||
addOnLeading={
|
|
||||||
<>
|
|
||||||
{urlPrefix}/
|
|
||||||
{!isManagedEventType
|
|
||||||
? team
|
|
||||||
? (orgBranding ? "" : "team/") + team.slug
|
|
||||||
: eventType.users[0].username
|
|
||||||
: t("username_placeholder")}
|
|
||||||
/
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
{...formMethods.register("slug", {
|
|
||||||
setValueAs: (v) => slugify(v),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{multipleDuration ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
|
||||||
{t("available_durations")}
|
|
||||||
</Skeleton>
|
|
||||||
<Select
|
|
||||||
isMulti
|
|
||||||
defaultValue={selectedMultipleDuration}
|
|
||||||
name="metadata.multipleDuration"
|
|
||||||
isSearchable={false}
|
|
||||||
className="h-auto !min-h-[36px] text-sm"
|
|
||||||
options={multipleDurationOptions}
|
|
||||||
value={selectedMultipleDuration}
|
|
||||||
onChange={(options) => {
|
|
||||||
let newOptions = [...options];
|
|
||||||
newOptions = newOptions.sort((a, b) => {
|
|
||||||
return a?.value - b?.value;
|
|
||||||
});
|
|
||||||
const values = newOptions.map((opt) => opt.value);
|
|
||||||
setMultipleDuration(values);
|
|
||||||
setSelectedMultipleDuration(newOptions);
|
|
||||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
|
||||||
if (newOptions.length > 0) {
|
|
||||||
setDefaultDuration(newOptions[0]);
|
|
||||||
formMethods.setValue("length", newOptions[0].value);
|
|
||||||
} else {
|
|
||||||
setDefaultDuration(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (newOptions.length === 1 && defaultDuration === null) {
|
|
||||||
setDefaultDuration(newOptions[0]);
|
|
||||||
formMethods.setValue("length", newOptions[0].value);
|
|
||||||
}
|
|
||||||
formMethods.setValue("metadata.multipleDuration", values);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
|
||||||
{t("default_duration")}
|
|
||||||
{shouldLockIndicator("length")}
|
|
||||||
</Skeleton>
|
|
||||||
<Select
|
|
||||||
value={defaultDuration}
|
|
||||||
isSearchable={false}
|
|
||||||
name="length"
|
|
||||||
className="text-sm"
|
|
||||||
isDisabled={lengthLockedProps.disabled}
|
|
||||||
noOptionsMessage={() => t("default_duration_no_options")}
|
|
||||||
options={selectedMultipleDuration}
|
|
||||||
onChange={(option) => {
|
|
||||||
setDefaultDuration(
|
|
||||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
|
||||||
);
|
|
||||||
if (option) formMethods.setValue("length", option.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
type="number"
|
label={t("title")}
|
||||||
{...lengthLockedProps}
|
{...shouldLockDisableProps("title")}
|
||||||
label={t("duration")}
|
defaultValue={eventType.title}
|
||||||
defaultValue={eventType.length ?? 15}
|
{...formMethods.register("title")}
|
||||||
{...formMethods.register("length")}
|
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
|
||||||
min={1}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<div>
|
||||||
{!lengthLockedProps.disabled && (
|
<Label>
|
||||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
{t("description")}
|
||||||
<SettingsToggle
|
{shouldLockIndicator("description")}
|
||||||
title={t("allow_booker_to_select_duration")}
|
</Label>
|
||||||
checked={multipleDuration !== undefined}
|
<DescriptionEditor
|
||||||
disabled={seatsEnabled}
|
description={eventType?.description}
|
||||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
editable={!descriptionLockedProps.disabled}
|
||||||
onCheckedChange={() => {
|
|
||||||
if (multipleDuration !== undefined) {
|
|
||||||
setMultipleDuration(undefined);
|
|
||||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
|
||||||
formMethods.setValue("length", eventType.length);
|
|
||||||
} else {
|
|
||||||
setMultipleDuration([]);
|
|
||||||
formMethods.setValue("metadata.multipleDuration", []);
|
|
||||||
formMethods.setValue("length", 0);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<TextField
|
||||||
<div>
|
required
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
label={t("URL")}
|
||||||
{t("location")}
|
{...shouldLockDisableProps("slug")}
|
||||||
{shouldLockIndicator("locations")}
|
defaultValue={eventType.slug}
|
||||||
</Skeleton>
|
addOnLeading={
|
||||||
|
<>
|
||||||
<Controller
|
{urlPrefix}/
|
||||||
name="locations"
|
{!isManagedEventType
|
||||||
control={formMethods.control}
|
? team
|
||||||
defaultValue={eventType.locations || []}
|
? (orgBranding ? "" : "team/") + team.slug
|
||||||
render={() => <Locations />}
|
: eventType.users[0].username
|
||||||
|
: t("username_placeholder")}
|
||||||
|
/
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{...formMethods.register("slug", {
|
||||||
|
setValueAs: (v) => slugify(v),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="border-subtle rounded-md border p-6">
|
||||||
|
{multipleDuration ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
|
{t("available_durations")}
|
||||||
|
</Skeleton>
|
||||||
|
<Select
|
||||||
|
isMulti
|
||||||
|
defaultValue={selectedMultipleDuration}
|
||||||
|
name="metadata.multipleDuration"
|
||||||
|
isSearchable={false}
|
||||||
|
className="h-auto !min-h-[36px] text-sm"
|
||||||
|
options={multipleDurationOptions}
|
||||||
|
value={selectedMultipleDuration}
|
||||||
|
onChange={(options) => {
|
||||||
|
let newOptions = [...options];
|
||||||
|
newOptions = newOptions.sort((a, b) => {
|
||||||
|
return a?.value - b?.value;
|
||||||
|
});
|
||||||
|
const values = newOptions.map((opt) => opt.value);
|
||||||
|
setMultipleDuration(values);
|
||||||
|
setSelectedMultipleDuration(newOptions);
|
||||||
|
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||||
|
if (newOptions.length > 0) {
|
||||||
|
setDefaultDuration(newOptions[0]);
|
||||||
|
formMethods.setValue("length", newOptions[0].value);
|
||||||
|
} else {
|
||||||
|
setDefaultDuration(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newOptions.length === 1 && defaultDuration === null) {
|
||||||
|
setDefaultDuration(newOptions[0]);
|
||||||
|
formMethods.setValue("length", newOptions[0].value);
|
||||||
|
}
|
||||||
|
formMethods.setValue("metadata.multipleDuration", values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
|
{t("default_duration")}
|
||||||
|
{shouldLockIndicator("length")}
|
||||||
|
</Skeleton>
|
||||||
|
<Select
|
||||||
|
value={defaultDuration}
|
||||||
|
isSearchable={false}
|
||||||
|
name="length"
|
||||||
|
className="text-sm"
|
||||||
|
isDisabled={lengthLockedProps.disabled}
|
||||||
|
noOptionsMessage={() => t("default_duration_no_options")}
|
||||||
|
options={selectedMultipleDuration}
|
||||||
|
onChange={(option) => {
|
||||||
|
setDefaultDuration(
|
||||||
|
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||||
|
);
|
||||||
|
if (option) formMethods.setValue("length", option.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
{...lengthLockedProps}
|
||||||
|
label={t("duration")}
|
||||||
|
defaultValue={eventType.length ?? 15}
|
||||||
|
{...formMethods.register("length")}
|
||||||
|
addOnSuffix={<>{t("minutes")}</>}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!lengthLockedProps.disabled && (
|
||||||
|
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||||
|
<SettingsToggle
|
||||||
|
title={t("allow_booker_to_select_duration")}
|
||||||
|
checked={multipleDuration !== undefined}
|
||||||
|
disabled={seatsEnabled}
|
||||||
|
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (multipleDuration !== undefined) {
|
||||||
|
setMultipleDuration(undefined);
|
||||||
|
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||||
|
formMethods.setValue("length", eventType.length);
|
||||||
|
} else {
|
||||||
|
setMultipleDuration([]);
|
||||||
|
formMethods.setValue("metadata.multipleDuration", []);
|
||||||
|
formMethods.setValue("length", 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
<div className="border-subtle rounded-md border p-6">
|
||||||
<EditLocationDialog
|
<div>
|
||||||
isOpenDialog={showLocationModal}
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
setShowLocationModal={setShowLocationModal}
|
{t("location")}
|
||||||
saveLocation={saveLocation}
|
{shouldLockIndicator("locations")}
|
||||||
defaultValues={formMethods.getValues("locations")}
|
</Skeleton>
|
||||||
selection={
|
|
||||||
selectedLocation
|
<Controller
|
||||||
? selectedLocation.address
|
name="locations"
|
||||||
? {
|
control={formMethods.control}
|
||||||
value: selectedLocation.value,
|
defaultValue={eventType.locations || []}
|
||||||
label: t(selectedLocation.label),
|
render={() => <Locations />}
|
||||||
icon: selectedLocation.icon,
|
/>
|
||||||
address: selectedLocation.address,
|
</div>
|
||||||
}
|
</div>
|
||||||
: {
|
|
||||||
value: selectedLocation.value,
|
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||||
label: t(selectedLocation.label),
|
<EditLocationDialog
|
||||||
icon: selectedLocation.icon,
|
isOpenDialog={showLocationModal}
|
||||||
}
|
setShowLocationModal={setShowLocationModal}
|
||||||
: undefined
|
saveLocation={saveLocation}
|
||||||
}
|
defaultValues={formMethods.getValues("locations")}
|
||||||
setSelectedLocation={setSelectedLocation}
|
selection={
|
||||||
setEditingLocationType={setEditingLocationType}
|
selectedLocation
|
||||||
teamId={eventType.team?.id}
|
? selectedLocation.address
|
||||||
/>
|
? {
|
||||||
|
value: selectedLocation.value,
|
||||||
|
label: t(selectedLocation.label),
|
||||||
|
icon: selectedLocation.icon,
|
||||||
|
address: selectedLocation.address,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: selectedLocation.value,
|
||||||
|
label: t(selectedLocation.label),
|
||||||
|
icon: selectedLocation.icon,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
setSelectedLocation={setSelectedLocation}
|
||||||
|
setEditingLocationType={setEditingLocationType}
|
||||||
|
teamId={eventType.team?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
|
|||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
backPath="/event-types"
|
backPath="/event-types"
|
||||||
title={eventType.title + " | " + t("event_type")}
|
title={`${eventType.title} | ${t("event_type")}`}
|
||||||
heading={eventType.title}
|
heading={eventType.title}
|
||||||
CTA={
|
CTA={
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Webhook } from "@prisma/client";
|
import type { Webhook } from "@prisma/client";
|
||||||
import { Webhook as TbWebhook } from "lucide-react";
|
import { Webhook as TbWebhook } from "lucide-react";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -8,6 +10,7 @@ import { WebhookForm } from "@calcom/features/webhooks/components";
|
|||||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||||
|
import { APP_NAME } 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 { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||||
@@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
|||||||
)}
|
)}
|
||||||
{webhooks.length ? (
|
{webhooks.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 rounded-md border">
|
<div className="border-subtle mb-2 rounded-md border p-8">
|
||||||
{webhooks.map((webhook, index) => {
|
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||||
return (
|
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||||
<WebhookListItem
|
{t("add_webhook_description", { appName: APP_NAME })}
|
||||||
key={webhook.id}
|
</p>
|
||||||
webhook={webhook}
|
|
||||||
lastItem={webhooks.length === index + 1}
|
<div className="border-subtle mt-8 rounded-md border">
|
||||||
canEditWebhook={!webhookLockedStatus.disabled}
|
{webhooks.map((webhook, index) => {
|
||||||
onEditWebhook={() => {
|
return (
|
||||||
setEditModalOpen(true);
|
<WebhookListItem
|
||||||
setWebhookToEdit(webhook);
|
key={webhook.id}
|
||||||
}}
|
webhook={webhook}
|
||||||
/>
|
lastItem={webhooks.length === index + 1}
|
||||||
);
|
canEditWebhook={!webhookLockedStatus.disabled}
|
||||||
})}
|
onEditWebhook={() => {
|
||||||
|
setEditModalOpen(true);
|
||||||
|
setWebhookToEdit(webhook);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-default mt-8 text-sm font-normal">
|
||||||
|
<Trans i18nKey="edit_or_manage_webhooks">
|
||||||
|
If you wish to edit or manage your web hooks, please head over to
|
||||||
|
<Link
|
||||||
|
className="cursor-pointer font-semibold underline"
|
||||||
|
href="/settings/developer/webhooks">
|
||||||
|
webhooks settings
|
||||||
|
</Link>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewWebhookButton />
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } 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 { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
@@ -46,7 +47,18 @@ export default function RecurringEventController({
|
|||||||
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Alert
|
||||||
|
className="mb-4"
|
||||||
|
severity="warning"
|
||||||
|
title="Experimental: Recurring Events are currently experimental and causes some issues sometimes when checking for availability. We are working on fixing this."
|
||||||
|
/>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName={classNames(
|
||||||
|
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||||
|
recurringEventState !== null && "rounded-b-none"
|
||||||
|
)}
|
||||||
|
childrenClassName="lg:ml-0"
|
||||||
title={t("recurring_event")}
|
title={t("recurring_event")}
|
||||||
{...recurringLocked}
|
{...recurringLocked}
|
||||||
description={t("recurring_event_description")}
|
description={t("recurring_event_description")}
|
||||||
@@ -66,68 +78,70 @@ export default function RecurringEventController({
|
|||||||
setRecurringEventState(newVal);
|
setRecurringEventState(newVal);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{recurringEventState && (
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
{recurringEventState && (
|
||||||
<div className="flex items-center">
|
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
<div className="flex items-center">
|
||||||
<TextField
|
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||||
disabled={recurringLocked.disabled}
|
<TextField
|
||||||
type="number"
|
disabled={recurringLocked.disabled}
|
||||||
min="1"
|
type="number"
|
||||||
max="20"
|
min="1"
|
||||||
className="mb-0"
|
max="20"
|
||||||
defaultValue={recurringEventState.interval}
|
className="mb-0"
|
||||||
onChange={(event) => {
|
defaultValue={recurringEventState.interval}
|
||||||
const newVal = {
|
onChange={(event) => {
|
||||||
...recurringEventState,
|
const newVal = {
|
||||||
interval: parseInt(event?.target.value),
|
...recurringEventState,
|
||||||
};
|
interval: parseInt(event?.target.value),
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
};
|
||||||
setRecurringEventState(newVal);
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
}}
|
setRecurringEventState(newVal);
|
||||||
/>
|
}}
|
||||||
<Select
|
/>
|
||||||
options={recurringEventFreqOptions}
|
<Select
|
||||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
options={recurringEventFreqOptions}
|
||||||
isSearchable={false}
|
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
isSearchable={false}
|
||||||
isDisabled={recurringLocked.disabled}
|
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||||
onChange={(event) => {
|
isDisabled={recurringLocked.disabled}
|
||||||
const newVal = {
|
onChange={(event) => {
|
||||||
...recurringEventState,
|
const newVal = {
|
||||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
...recurringEventState,
|
||||||
};
|
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
};
|
||||||
setRecurringEventState(newVal);
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
}}
|
setRecurringEventState(newVal);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||||
|
<TextField
|
||||||
|
disabled={recurringLocked.disabled}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
defaultValue={recurringEventState.count}
|
||||||
|
className="mb-0"
|
||||||
|
onChange={(event) => {
|
||||||
|
const newVal = {
|
||||||
|
...recurringEventState,
|
||||||
|
count: parseInt(event?.target.value),
|
||||||
|
};
|
||||||
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
|
setRecurringEventState(newVal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||||
|
{t("events", {
|
||||||
|
count: recurringEventState.count,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center">
|
)}
|
||||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
</div>
|
||||||
<TextField
|
|
||||||
disabled={recurringLocked.disabled}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
defaultValue={recurringEventState.count}
|
|
||||||
className="mb-0"
|
|
||||||
onChange={(event) => {
|
|
||||||
const newVal = {
|
|
||||||
...recurringEventState,
|
|
||||||
count: parseInt(event?.target.value),
|
|
||||||
};
|
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
|
||||||
setRecurringEventState(newVal);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
|
||||||
{t("events", {
|
|
||||||
count: recurringEventState.count,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export default function RequiresConfirmationController({
|
|||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={() => (
|
render={() => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName={classNames(
|
||||||
|
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||||
|
requiresConfirmation && "rounded-b-none"
|
||||||
|
)}
|
||||||
|
childrenClassName="lg:ml-0"
|
||||||
title={t("requires_confirmation")}
|
title={t("requires_confirmation")}
|
||||||
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||||
@@ -77,107 +83,111 @@ export default function RequiresConfirmationController({
|
|||||||
formMethods.setValue("requiresConfirmation", val);
|
formMethods.setValue("requiresConfirmation", val);
|
||||||
onRequiresConfirmation(val);
|
onRequiresConfirmation(val);
|
||||||
}}>
|
}}>
|
||||||
<RadioGroup.Root
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
defaultValue={
|
<RadioGroup.Root
|
||||||
requiresConfirmation
|
defaultValue={
|
||||||
? requiresConfirmationSetup === undefined
|
requiresConfirmation
|
||||||
? "always"
|
? requiresConfirmationSetup === undefined
|
||||||
: "notice"
|
? "always"
|
||||||
: undefined
|
: "notice"
|
||||||
}
|
: undefined
|
||||||
onValueChange={(val) => {
|
|
||||||
if (val === "always") {
|
|
||||||
formMethods.setValue("requiresConfirmation", true);
|
|
||||||
onRequiresConfirmation(true);
|
|
||||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
|
||||||
setRequiresConfirmationSetup(undefined);
|
|
||||||
} else if (val === "notice") {
|
|
||||||
formMethods.setValue("requiresConfirmation", true);
|
|
||||||
onRequiresConfirmation(true);
|
|
||||||
formMethods.setValue(
|
|
||||||
"metadata.requiresConfirmationThreshold",
|
|
||||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}>
|
onValueChange={(val) => {
|
||||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
if (val === "always") {
|
||||||
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
|
formMethods.setValue("requiresConfirmation", true);
|
||||||
<RadioField
|
onRequiresConfirmation(true);
|
||||||
label={t("always_requires_confirmation")}
|
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||||
disabled={requiresConfirmationLockedProps.disabled}
|
setRequiresConfirmationSetup(undefined);
|
||||||
id="always"
|
} else if (val === "notice") {
|
||||||
value="always"
|
formMethods.setValue("requiresConfirmation", true);
|
||||||
/>
|
onRequiresConfirmation(true);
|
||||||
)}
|
formMethods.setValue(
|
||||||
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
|
"metadata.requiresConfirmationThreshold",
|
||||||
<RadioField
|
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||||
disabled={requiresConfirmationLockedProps.disabled}
|
);
|
||||||
className="items-center"
|
}
|
||||||
label={
|
}}>
|
||||||
<>
|
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||||
<Trans
|
{(requiresConfirmationSetup === undefined ||
|
||||||
i18nKey="when_booked_with_less_than_notice"
|
!requiresConfirmationLockedProps.disabled) && (
|
||||||
defaults="When booked with less than <time></time> notice"
|
<RadioField
|
||||||
components={{
|
label={t("always_requires_confirmation")}
|
||||||
time: (
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
<div className="mx-2 inline-flex">
|
id="always"
|
||||||
<Input
|
value="always"
|
||||||
type="number"
|
/>
|
||||||
min={1}
|
)}
|
||||||
disabled={requiresConfirmationLockedProps.disabled}
|
{(requiresConfirmationSetup !== undefined ||
|
||||||
onChange={(evt) => {
|
!requiresConfirmationLockedProps.disabled) && (
|
||||||
const val = Number(evt.target?.value);
|
<RadioField
|
||||||
setRequiresConfirmationSetup({
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
unit:
|
className="items-center"
|
||||||
requiresConfirmationSetup?.unit ??
|
label={
|
||||||
defaultRequiresConfirmationSetup.unit,
|
<>
|
||||||
time: val,
|
<Trans
|
||||||
});
|
i18nKey="when_booked_with_less_than_notice"
|
||||||
formMethods.setValue(
|
defaults="When booked with less than <time></time> notice"
|
||||||
"metadata.requiresConfirmationThreshold.time",
|
components={{
|
||||||
val
|
time: (
|
||||||
);
|
<div className="mx-2 inline-flex">
|
||||||
}}
|
<Input
|
||||||
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
type="number"
|
||||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
min={1}
|
||||||
/>
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
<label
|
onChange={(evt) => {
|
||||||
className={classNames(
|
const val = Number(evt.target?.value);
|
||||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
|
||||||
)}>
|
|
||||||
<Select
|
|
||||||
inputId="notice"
|
|
||||||
options={options}
|
|
||||||
isSearchable={false}
|
|
||||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
|
||||||
className="ml-2"
|
|
||||||
onChange={(opt) => {
|
|
||||||
setRequiresConfirmationSetup({
|
setRequiresConfirmationSetup({
|
||||||
time:
|
unit:
|
||||||
requiresConfirmationSetup?.time ??
|
requiresConfirmationSetup?.unit ??
|
||||||
defaultRequiresConfirmationSetup.time,
|
defaultRequiresConfirmationSetup.unit,
|
||||||
unit: opt?.value as UnitTypeLongPlural,
|
time: val,
|
||||||
});
|
});
|
||||||
formMethods.setValue(
|
formMethods.setValue(
|
||||||
"metadata.requiresConfirmationThreshold.unit",
|
"metadata.requiresConfirmationThreshold.time",
|
||||||
opt?.value as UnitTypeLongPlural
|
val
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
defaultValue={defaultValue}
|
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield]"
|
||||||
|
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||||
/>
|
/>
|
||||||
</label>
|
<label
|
||||||
</div>
|
className={classNames(
|
||||||
),
|
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||||
}}
|
)}>
|
||||||
/>
|
<Select
|
||||||
</>
|
inputId="notice"
|
||||||
}
|
options={options}
|
||||||
id="notice"
|
isSearchable={false}
|
||||||
value="notice"
|
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||||
/>
|
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
|
||||||
)}
|
onChange={(opt) => {
|
||||||
</div>
|
setRequiresConfirmationSetup({
|
||||||
</RadioGroup.Root>
|
time:
|
||||||
|
requiresConfirmationSetup?.time ??
|
||||||
|
defaultRequiresConfirmationSetup.time,
|
||||||
|
unit: opt?.value as UnitTypeLongPlural,
|
||||||
|
});
|
||||||
|
formMethods.setValue(
|
||||||
|
"metadata.requiresConfirmationThreshold.unit",
|
||||||
|
opt?.value as UnitTypeLongPlural
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
id="notice"
|
||||||
|
value="notice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
@@ -22,7 +23,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
const { nextStep } = props;
|
const { nextStep } = props;
|
||||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const userSettingsSchema = z.object({
|
const userSettingsSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { Avatar } from "@calcom/ui";
|
|||||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||||
type MembersType = TeamType["members"];
|
type MembersType = TeamType["members"];
|
||||||
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
|
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
|
||||||
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 routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
@@ -23,7 +22,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
|||||||
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 size="md" alt={member.name || ""} imageSrc={"/" + member.username + "/avatar.png"} />
|
<Avatar size="md" alt={member.name || ""} imageSrc={`/${member.username}/avatar.png`} />
|
||||||
<section className="mt-2 line-clamp-4 w-full space-y-1">
|
<section className="mt-2 line-clamp-4 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="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
|
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
|
||||||
@@ -50,7 +49,9 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
<section
|
||||||
|
data-testid="team-members-container"
|
||||||
|
className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
|
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
|
||||||
})}
|
})}
|
||||||
@@ -58,10 +59,10 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Team = ({ team }: { team: TeamTypeWithSafeHtml }) => {
|
const Team = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Members members={team.members} teamName={team.name} />
|
<Members members={members} teamName={teamName} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { components } from "react-select";
|
|||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
|
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
||||||
import { Select } from "@calcom/ui";
|
import { Select } from "@calcom/ui";
|
||||||
|
|
||||||
export type LocationOption = {
|
export type LocationOption = {
|
||||||
@@ -22,7 +23,7 @@ export type GroupOptionType = GroupBase<LocationOption>;
|
|||||||
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5 dark:invert-[.65]" />}
|
{icon && <img src={icon} alt="cover" className={classNames("h-3.5 w-3.5", invertLogoOnDark(icon))} />}
|
||||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,14 +179,14 @@ function getThemeProviderProps({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const appearanceIdSuffix = themeBasis ? ":" + themeBasis : "";
|
const appearanceIdSuffix = themeBasis ? `:${themeBasis}` : "";
|
||||||
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
|
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
|
||||||
let embedExplicitlySetThemeSuffix = "";
|
let embedExplicitlySetThemeSuffix = "";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const embedTheme = window.getEmbedTheme();
|
const embedTheme = window.getEmbedTheme();
|
||||||
if (embedTheme) {
|
if (embedTheme) {
|
||||||
embedExplicitlySetThemeSuffix = ":" + embedTheme;
|
embedExplicitlySetThemeSuffix = `:${embedTheme}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const initClock = () => {
|
|||||||
// This only sets browser locale if there's no preference on localStorage.
|
// This only sets browser locale if there's no preference on localStorage.
|
||||||
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
||||||
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
|
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
|
||||||
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
timeOptions.inviteeTimeZone =
|
||||||
|
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London";
|
||||||
};
|
};
|
||||||
|
|
||||||
const is24h = (is24hClock?: boolean) => {
|
const is24h = (is24hClock?: boolean) => {
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ function getCspPolicy(nonce: string) {
|
|||||||
script-src ${
|
script-src ${
|
||||||
IS_PRODUCTION
|
IS_PRODUCTION
|
||||||
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
|
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
|
||||||
"'nonce-" + nonce + "' 'strict-dynamic' 'self' 'unsafe-inline' https:"
|
`'nonce-${nonce}' 'strict-dynamic' 'self' 'unsafe-inline' https:`
|
||||||
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
|
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
|
||||||
"'unsafe-inline' 'unsafe-eval' https: http:"
|
"'unsafe-inline' 'unsafe-eval' https: http:"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,14 +15,9 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
|||||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
|
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
|
||||||
|
|
||||||
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
|
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
|
||||||
const newDestinationUrl =
|
const newDestinationUrl = `${
|
||||||
destinationUrlObj.pathname +
|
destinationUrlObj.pathname
|
||||||
"/embed?" +
|
}/embed?${destinationUrlObj.searchParams.toString()}&layout=${layout}&embed=${embed}`;
|
||||||
destinationUrlObj.searchParams.toString() +
|
|
||||||
"&layout=" +
|
|
||||||
layout +
|
|
||||||
"&embed=" +
|
|
||||||
embed;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...ssrResponse,
|
...ssrResponse,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const middleware: NextMiddleware = async (req) => {
|
|||||||
requestHeaders.set("x-cal-timezone", req.headers.get("x-vercel-ip-timezone") ?? "");
|
requestHeaders.set("x-cal-timezone", req.headers.get("x-vercel-ip-timezone") ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith("/auth/login")) {
|
if (url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login")) {
|
||||||
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
|
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
|
||||||
requestHeaders.set("x-csp-enforce", "true");
|
requestHeaders.set("x-csp-enforce", "true");
|
||||||
}
|
}
|
||||||
@@ -68,6 +68,7 @@ export const config = {
|
|||||||
matcher: [
|
matcher: [
|
||||||
"/:path*/embed",
|
"/:path*/embed",
|
||||||
"/api/trpc/:path*",
|
"/api/trpc/:path*",
|
||||||
|
"/login",
|
||||||
"/auth/login",
|
"/auth/login",
|
||||||
/**
|
/**
|
||||||
* Paths required by routingForms.handle
|
* Paths required by routingForms.handle
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
|||||||
|
|
||||||
// So we can test deploy previews preview
|
// So we can test deploy previews preview
|
||||||
if (process.env.VERCEL_URL && !process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
if (process.env.VERCEL_URL && !process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
||||||
process.env.NEXT_PUBLIC_WEBAPP_URL = "https://" + process.env.VERCEL_URL;
|
process.env.NEXT_PUBLIC_WEBAPP_URL = `https://${process.env.VERCEL_URL}`;
|
||||||
}
|
}
|
||||||
// Check for configuration of NEXTAUTH_URL before overriding
|
// Check for configuration of NEXTAUTH_URL before overriding
|
||||||
if (!process.env.NEXTAUTH_URL && process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
if (!process.env.NEXTAUTH_URL && process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
||||||
process.env.NEXTAUTH_URL = process.env.NEXT_PUBLIC_WEBAPP_URL + "/api/auth";
|
process.env.NEXTAUTH_URL = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth`;
|
||||||
}
|
}
|
||||||
if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
|
if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
|
||||||
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||||
@@ -226,6 +226,10 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const beforeFiles = [
|
const beforeFiles = [
|
||||||
|
{
|
||||||
|
source: "/login",
|
||||||
|
destination: "/auth/login",
|
||||||
|
},
|
||||||
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||||
...(isOrganizationsEnabled
|
...(isOrganizationsEnabled
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.3.2",
|
"version": "3.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.0.0",
|
"@radix-ui/react-collapsible": "^1.0.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -250,11 +250,8 @@ export default function Custom404() {
|
|||||||
) : IS_CALCOM ? (
|
) : IS_CALCOM ? (
|
||||||
<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(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||||
<strong className="text-blue-500">
|
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
|
||||||
{new URL(WEBSITE_URL).hostname}
|
<span className="text-blue-500">{t("register_now")}</span>.
|
||||||
{username}
|
|
||||||
</strong>{" "}
|
|
||||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="mt-2 inline-block text-lg">
|
<span className="mt-2 inline-block text-lg">
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
|||||||
{user.away ? (
|
{user.away ? (
|
||||||
<div className="overflow-hidden rounded-sm border ">
|
<div className="overflow-hidden rounded-sm border ">
|
||||||
<div className="text-muted p-8 text-center">
|
<div className="text-muted p-8 text-center">
|
||||||
<h2 className="font-cal text-default mb-2 text-3xl">😴{" " + t("user_away")}</h2>
|
<h2 className="font-cal text-default mb-2 text-3xl">😴{` ${t("user_away")}`}</h2>
|
||||||
<p className="mx-auto max-w-md">{t("user_away_description") as string}</p>
|
<p className="mx-auto max-w-md">{t("user_away_description") as string}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
getMultipleDurationValue,
|
getMultipleDurationValue,
|
||||||
} from "@calcom/features/bookings/lib/get-booking";
|
} from "@calcom/features/bookings/lib/get-booking";
|
||||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
||||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -152,7 +151,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username,
|
username,
|
||||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
away: true,
|
away: true,
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ class MyDocument extends Document<Props> {
|
|||||||
const asPath = ctx.asPath || "";
|
const asPath = ctx.asPath || "";
|
||||||
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
||||||
const parsedUrl = new URL(asPath, "https://dummyurl");
|
const parsedUrl = new URL(asPath, "https://dummyurl");
|
||||||
const isEmbed = parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null;
|
const isEmbedSnippetGeneratorPath = parsedUrl.pathname.startsWith("/event-types");
|
||||||
|
// FIXME: Revisit this logic to remove embedType query param check completely. Ideally, /embed should always be there at the end of the URL. Test properly and then remove it.
|
||||||
|
const isEmbed =
|
||||||
|
(parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null) &&
|
||||||
|
!isEmbedSnippetGeneratorPath;
|
||||||
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||||
const initialProps = await Document.getInitialProps(ctx);
|
const initialProps = await Document.getInitialProps(ctx);
|
||||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import isAuthorized from "@calcom/features/auth/lib/oAuthAuthorization";
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const requriedScopes = ["READ_PROFILE"];
|
||||||
|
|
||||||
|
const account = await isAuthorized(req, requriedScopes);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
return res.status(201).json({ username: account.name });
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import type { OAuthTokenPayload } from "pages/api/auth/oauth/token";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.status(405).json({ message: "Invalid method" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = req.headers.authorization?.split(" ")[1] || "";
|
||||||
|
|
||||||
|
const { client_id, client_secret, grant_type } = req.body;
|
||||||
|
|
||||||
|
if (grant_type !== "refresh_token") {
|
||||||
|
res.status(400).json({ message: "grant type invalid" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hashedSecret] = generateSecret(client_secret);
|
||||||
|
|
||||||
|
const client = await prisma.oAuthClient.findFirst({
|
||||||
|
where: {
|
||||||
|
clientId: client_id,
|
||||||
|
clientSecret: hashedSecret,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
redirectUri: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||||
|
|
||||||
|
let decodedRefreshToken: OAuthTokenPayload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload;
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: OAuthTokenPayload = {
|
||||||
|
userId: decodedRefreshToken.userId,
|
||||||
|
scope: decodedRefreshToken.scope,
|
||||||
|
token_type: "Access Token",
|
||||||
|
clientId: client_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const access_token = jwt.sign(payload, secretKey, {
|
||||||
|
expiresIn: 1800, // 30 min
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ access_token });
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
||||||
|
|
||||||
|
export type OAuthTokenPayload = {
|
||||||
|
userId?: number | null;
|
||||||
|
teamId?: number | null;
|
||||||
|
token_type: string;
|
||||||
|
scope: string[];
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.status(405).json({ message: "Invalid method" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, client_id, client_secret, grant_type, redirect_uri } = req.body;
|
||||||
|
|
||||||
|
if (grant_type !== "authorization_code") {
|
||||||
|
res.status(400).json({ message: "grant_type invalid" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hashedSecret] = generateSecret(client_secret);
|
||||||
|
|
||||||
|
const client = await prisma.oAuthClient.findFirst({
|
||||||
|
where: {
|
||||||
|
clientId: client_id,
|
||||||
|
clientSecret: hashedSecret,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
redirectUri: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client || client.redirectUri !== redirect_uri) {
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessCode = await prisma.accessCode.findFirst({
|
||||||
|
where: {
|
||||||
|
code: code,
|
||||||
|
clientId: client_id,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//delete all expired accessCodes + the one that is used here
|
||||||
|
await prisma.accessCode.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
expiresAt: {
|
||||||
|
lt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: code,
|
||||||
|
clientId: client_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessCode) {
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||||
|
|
||||||
|
const payloadAuthToken: OAuthTokenPayload = {
|
||||||
|
userId: accessCode.userId,
|
||||||
|
teamId: accessCode.teamId,
|
||||||
|
scope: accessCode.scopes,
|
||||||
|
token_type: "Access Token",
|
||||||
|
clientId: client_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadRefreshToken: OAuthTokenPayload = {
|
||||||
|
userId: accessCode.userId,
|
||||||
|
teamId: accessCode.teamId,
|
||||||
|
scope: accessCode.scopes,
|
||||||
|
token_type: "Refresh Token",
|
||||||
|
clientId: client_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const access_token = jwt.sign(payloadAuthToken, secretKey, {
|
||||||
|
expiresIn: 1800, // 30 min
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
|
||||||
|
expiresIn: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ access_token, refresh_token });
|
||||||
|
}
|
||||||
@@ -130,9 +130,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
// Accept any child team invites for orgs and create a membership for the org itself
|
// Accept any child team invites for orgs and create a membership for the org itself
|
||||||
if (team.parentId) {
|
if (team.parentId) {
|
||||||
// Create a membership for the organization itself
|
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
|
||||||
await prisma.membership.create({
|
await prisma.membership.upsert({
|
||||||
data: {
|
where: {
|
||||||
|
userId_teamId: { userId: user.id, teamId: team.parentId },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team.parentId,
|
teamId: team.parentId,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, config } from "@calcom/app-store/alby/api/webhook";
|
||||||
@@ -1 +1 @@
|
|||||||
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
|
export { default, config } from "@calcom/app-store/paypal/api/webhook";
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
return res.status(400).json({ message: "Google client_secret missing." });
|
return res.status(400).json({ message: "Google client_secret missing." });
|
||||||
|
|
||||||
// use differnt callback to normal calendar connection
|
// use differnt callback to normal calendar connection
|
||||||
const redirect_uri = WEBAPP_URL + "/api/teams/googleworkspace/callback";
|
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
|
|
||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
if (!client_secret || typeof client_secret !== "string")
|
if (!client_secret || typeof client_secret !== "string")
|
||||||
return res.status(400).json({ message: "Google client_secret missing." });
|
return res.status(400).json({ message: "Google client_secret missing." });
|
||||||
|
|
||||||
const redirect_uri = WEBAPP_URL + "/api/teams/googleworkspace/callback";
|
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
@@ -54,11 +54,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
res.redirect(getSafeRedirectUrl(WEBAPP_URL + "/settings") ?? `${WEBAPP_URL}/teams`);
|
res.redirect(getSafeRedirectUrl(`${WEBAPP_URL}/settings`) ?? `${WEBAPP_URL}/teams`);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.redirect(
|
res.redirect(
|
||||||
getSafeRedirectUrl(WEBAPP_URL + `/settings/teams/${teamId}/members?inviteModal=true&bulk=true`) ??
|
getSafeRedirectUrl(`${WEBAPP_URL}/settings/teams/${teamId}/members?inviteModal=true&bulk=true`) ??
|
||||||
`${WEBAPP_URL}/teams`
|
`${WEBAPP_URL}/teams`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
|
||||||
import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router";
|
|
||||||
|
|
||||||
export default createNextApiHandler(kycVerificationRouter);
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||||
|
import { oAuthRouter } from "@calcom/trpc/server/routers/viewer/oAuth/_router";
|
||||||
|
|
||||||
|
export default createNextApiHandler(oAuthRouter);
|
||||||
@@ -15,12 +15,10 @@ const appCredentialWebhookRequestBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
/** */
|
/** */
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// Check that credential sharing is enabled
|
|
||||||
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
||||||
return res.status(403).json({ message: "Credential sharing is not enabled" });
|
return res.status(403).json({ message: "Credential sharing is not enabled" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that the webhook secret matches
|
|
||||||
if (
|
if (
|
||||||
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
|
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
|
||||||
process.env.CALCOM_WEBHOOK_SECRET
|
process.env.CALCOM_WEBHOOK_SECRET
|
||||||
@@ -30,7 +28,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
|
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
|
||||||
|
|
||||||
// Check that the user exists
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
|
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -39,26 +36,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const app = await prisma.app.findUnique({
|
const app = await prisma.app.findUnique({
|
||||||
where: { slug: reqBody.appSlug },
|
where: { slug: reqBody.appSlug },
|
||||||
select: { slug: true },
|
select: { dirName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
return res.status(404).json({ message: "App not found" });
|
return res.status(404).json({ message: "App not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for the app's slug and type
|
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
||||||
const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata];
|
|
||||||
|
|
||||||
if (!appMetadata) {
|
if (!appMetadata) {
|
||||||
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
|
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the keys
|
|
||||||
const keys = JSON.parse(
|
const keys = JSON.parse(
|
||||||
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Can't use prisma upsert as we don't know the id of the credential
|
// INFO: Can't use prisma upsert as we don't know the id of the credential
|
||||||
const appCredential = await prisma.credential.findFirst({
|
const appCredential = await prisma.credential.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: reqBody.userId,
|
userId: reqBody.userId,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
|
import type { InferGetServerSidePropsType } from "next";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||||
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
|
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
||||||
import { HeadSeo } from "@calcom/ui";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
|
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const slug = searchParams?.get("slug") as string;
|
const slug = searchParams?.get("slug") as string;
|
||||||
@@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
|||||||
|
|
||||||
SetupInformation.PageWrapper = PageWrapper;
|
SetupInformation.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = async () => {
|
export { getServerSideProps };
|
||||||
return {
|
|
||||||
paths: [],
|
|
||||||
fallback: "blocking",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getStaticProps };
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function Apps({ categories }: inferSSRProps<typeof getServerSideP
|
|||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Link
|
<Link
|
||||||
key={category.name}
|
key={category.name}
|
||||||
href={"/apps/categories/" + category.name}
|
href={`/apps/categories/${category.name}`}
|
||||||
data-testid={`app-store-category-${category.name}`}
|
data-testid={`app-store-category-${category.name}`}
|
||||||
className="bg-subtle relative flex rounded-sm px-6 py-4 sm:block">
|
className="bg-subtle relative flex rounded-sm px-6 py-4 sm:block">
|
||||||
<div className="self-center">
|
<div className="self-center">
|
||||||
|
|||||||
@@ -1,35 +1,13 @@
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useReducer, useState } from "react";
|
import { useReducer } from "react";
|
||||||
import z from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
|
||||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
|
||||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
|
||||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
|
||||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { AppCategories } from "@calcom/prisma/enums";
|
import { AppCategories } from "@calcom/prisma/enums";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { App } from "@calcom/types/App";
|
|
||||||
import type { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
|
import type { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
|
||||||
import {
|
import { Button, EmptyScreen, AppSkeletonLoader as SkeletonLoader, ShellSubHeading } from "@calcom/ui";
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
EmptyScreen,
|
|
||||||
List,
|
|
||||||
AppSkeletonLoader as SkeletonLoader,
|
|
||||||
ShellSubHeading,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
Dropdown,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownItem,
|
|
||||||
showToast,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -38,231 +16,24 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Grid,
|
Grid,
|
||||||
Mail,
|
Mail,
|
||||||
MoreHorizontal,
|
|
||||||
Plus,
|
Plus,
|
||||||
Share2,
|
Share2,
|
||||||
Trash,
|
|
||||||
Video,
|
Video,
|
||||||
} from "@calcom/ui/components/icon";
|
} from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
import AppListCard from "@components/AppListCard";
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
import { AppList } from "@components/apps/AppList";
|
||||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
||||||
|
|
||||||
function ConnectOrDisconnectIntegrationMenuItem(props: {
|
|
||||||
credentialId: number;
|
|
||||||
type: App["type"];
|
|
||||||
isGlobal?: boolean;
|
|
||||||
installed?: boolean;
|
|
||||||
invalidCredentialIds?: number[];
|
|
||||||
teamId?: number;
|
|
||||||
handleDisconnect: (credentialId: number, teamId?: number) => void;
|
|
||||||
}) {
|
|
||||||
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
const handleOpenChange = () => {
|
|
||||||
utils.viewer.integrations.invalidate();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (credentialId || type === "stripe_payment" || isGlobal) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<DropdownItem
|
|
||||||
color="destructive"
|
|
||||||
onClick={() => handleDisconnect(credentialId, teamId)}
|
|
||||||
disabled={isGlobal}
|
|
||||||
StartIcon={Trash}>
|
|
||||||
{t("remove_app")}
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!installed) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center truncate">
|
|
||||||
<Alert severity="warning" title={t("not_installed")} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InstallAppButton
|
|
||||||
type={type}
|
|
||||||
render={(buttonProps) => (
|
|
||||||
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
|
|
||||||
{t("install")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
onChanged={handleOpenChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IntegrationsContainerProps {
|
interface IntegrationsContainerProps {
|
||||||
variant?: AppCategories;
|
variant?: AppCategories;
|
||||||
exclude?: AppCategories[];
|
exclude?: AppCategories[];
|
||||||
handleDisconnect: (credentialId: number) => void;
|
handleDisconnect: (credentialId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IntegrationsListProps {
|
|
||||||
variant?: IntegrationsContainerProps["variant"];
|
|
||||||
data: RouterOutputs["viewer"]["integrations"];
|
|
||||||
handleDisconnect: (credentialId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
|
|
||||||
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
|
||||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSuccessCallback = useCallback(() => {
|
|
||||||
setBulkUpdateModal(true);
|
|
||||||
showToast("Default app updated successfully", "success");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
showToast("Default app updated successfully", "success");
|
|
||||||
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
showToast(`Error: ${error.message}`, "error");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ChildAppCard = ({
|
|
||||||
item,
|
|
||||||
}: {
|
|
||||||
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
|
|
||||||
credentialOwner?: CredentialOwner;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const appSlug = item?.slug;
|
|
||||||
const appIsDefault =
|
|
||||||
appSlug === defaultConferencingApp?.appSlug ||
|
|
||||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
|
||||||
return (
|
|
||||||
<AppListCard
|
|
||||||
key={item.name}
|
|
||||||
description={item.description}
|
|
||||||
title={item.name}
|
|
||||||
logo={item.logo}
|
|
||||||
isDefault={appIsDefault}
|
|
||||||
shouldHighlight
|
|
||||||
slug={item.slug}
|
|
||||||
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
|
|
||||||
credentialOwner={item?.credentialOwner}
|
|
||||||
actions={
|
|
||||||
!item.credentialOwner?.readOnly ? (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Dropdown modal={false}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<DropdownItem
|
|
||||||
type="button"
|
|
||||||
color="secondary"
|
|
||||||
StartIcon={Video}
|
|
||||||
onClick={() => {
|
|
||||||
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
|
|
||||||
if (locationType?.linkType === "static") {
|
|
||||||
setLocationType({ ...locationType, slug: appSlug });
|
|
||||||
} else {
|
|
||||||
updateDefaultAppMutation.mutate({
|
|
||||||
appSlug,
|
|
||||||
});
|
|
||||||
setBulkUpdateModal(true);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{t("set_as_default")}
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<ConnectOrDisconnectIntegrationMenuItem
|
|
||||||
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
|
|
||||||
type={item.type}
|
|
||||||
isGlobal={item.isGlobal}
|
|
||||||
installed
|
|
||||||
invalidCredentialIds={item.invalidCredentialIds}
|
|
||||||
handleDisconnect={handleDisconnect}
|
|
||||||
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}>
|
|
||||||
<AppSettings slug={item.slug} />
|
|
||||||
</AppListCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
|
|
||||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
|
||||||
const appCards = [];
|
|
||||||
|
|
||||||
if (app.userCredentialIds.length) {
|
|
||||||
appCards.push(<ChildAppCard item={app} />);
|
|
||||||
}
|
|
||||||
for (const team of app.teams) {
|
|
||||||
if (team) {
|
|
||||||
appCards.push(
|
|
||||||
<ChildAppCard
|
|
||||||
item={{
|
|
||||||
...app,
|
|
||||||
credentialOwner: {
|
|
||||||
name: team.name,
|
|
||||||
avatar: team.logo,
|
|
||||||
teamId: team.teamId,
|
|
||||||
credentialId: team.credentialId,
|
|
||||||
readOnly: !team.isAdmin,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return appCards;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useLocale();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List>
|
|
||||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
|
||||||
{data.items
|
|
||||||
.filter((item) => item.invalidCredentialIds)
|
|
||||||
.map((item) => {
|
|
||||||
if (!item.teams.length) return <ChildAppCard item={item} />;
|
|
||||||
})}
|
|
||||||
</List>
|
|
||||||
{locationType && (
|
|
||||||
<AppSetDefaultLinkDialog
|
|
||||||
locationType={locationType}
|
|
||||||
setLocationType={() => setLocationType(undefined)}
|
|
||||||
onSuccess={onSuccessCallback}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{bulkUpdateModal && (
|
|
||||||
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const IntegrationsContainer = ({
|
const IntegrationsContainer = ({
|
||||||
variant,
|
variant,
|
||||||
exclude,
|
exclude,
|
||||||
@@ -329,7 +100,8 @@ const IntegrationsContainer = ({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
|
||||||
|
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Avatar, Button, Select } from "@calcom/ui";
|
||||||
|
import { Plus, Info } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
|
export default function Authorize() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { status } = useSession();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const client_id = searchParams?.get("client_id") as string;
|
||||||
|
const state = searchParams?.get("state") as string;
|
||||||
|
const scope = searchParams?.get("scope") as string;
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>();
|
||||||
|
const scopes = scope ? scope.toString().split(",") : [];
|
||||||
|
|
||||||
|
const { data: client, isLoading: isLoadingGetClient } = trpc.viewer.oAuth.getClient.useQuery(
|
||||||
|
{
|
||||||
|
clientId: client_id as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: status !== "loading",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading: isLoadingProfiles } = trpc.viewer.teamsAndUserProfilesQuery.useQuery();
|
||||||
|
|
||||||
|
const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
window.location.href = `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappedProfiles = data
|
||||||
|
? data
|
||||||
|
.filter((profile) => !profile.readOnly)
|
||||||
|
.map((profile) => ({
|
||||||
|
label: profile.name || profile.slug || "",
|
||||||
|
value: profile.slug || "",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mappedProfiles.length > 0) {
|
||||||
|
setSelectedAccount(mappedProfiles[0]);
|
||||||
|
}
|
||||||
|
}, [isLoadingProfiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
const urlSearchParams = new URLSearchParams({
|
||||||
|
callbackUrl: `auth/oauth2/authorize?${queryString}`,
|
||||||
|
});
|
||||||
|
router.replace(`/auth/login?${urlSearchParams.toString()}`);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const isLoading = isLoadingGetClient || isLoadingProfiles || status !== "authenticated";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return <div>{t("unauthorized")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="mt-2 max-w-xl rounded-md bg-white px-9 pb-3 pt-2">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Avatar
|
||||||
|
alt=""
|
||||||
|
fallback={<Plus className="text-subtle h-6 w-6" />}
|
||||||
|
className="items-center"
|
||||||
|
imageSrc={client.logo}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div className="relative -ml-6 h-24 w-24">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="flex h-[70px] w-[70px] items-center justify-center rounded-full bg-white">
|
||||||
|
<img src="/cal-com-icon.svg" alt="Logo" className="h-16 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="px-5 pb-5 pt-3 text-center text-2xl font-bold tracking-tight">
|
||||||
|
{t("access_cal_account", { clientName: client.name, appName: APP_NAME })}
|
||||||
|
</h1>
|
||||||
|
<div className="mb-1 text-sm font-medium">{t("select_account_team")}</div>
|
||||||
|
<Select
|
||||||
|
isSearchable={true}
|
||||||
|
id="account-select"
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedAccount(value);
|
||||||
|
}}
|
||||||
|
className="w-52"
|
||||||
|
defaultValue={selectedAccount || mappedProfiles[0]}
|
||||||
|
options={mappedProfiles}
|
||||||
|
/>
|
||||||
|
<div className="mb-4 mt-5 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
|
||||||
|
<ul className="space-y-4 text-sm">
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span>{" "}
|
||||||
|
{t("associate_with_cal_account", { clientName: client.name })}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("see_personal_info")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("see_primary_email_address")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("connect_installed_apps")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("access_event_type")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("access_availability")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("access_bookings")}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="bg-subtle mb-8 mt-8 flex rounded-md p-3">
|
||||||
|
<div>
|
||||||
|
<Info className="mr-1 mt-0.5 h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-1 ">
|
||||||
|
<div className="mb-1 text-sm font-medium">
|
||||||
|
{t("allow_client_to_do", { clientName: client.name })}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{t("oauth_access_information", { appName: APP_NAME })}</div>{" "}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-subtle border- -mx-9 mb-4 border-b" />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
className="mr-2"
|
||||||
|
color="minimal"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `${client.redirectUri}`;
|
||||||
|
}}>
|
||||||
|
{t("go_back")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
generateAuthCodeMutation.mutate({
|
||||||
|
clientId: client_id as string,
|
||||||
|
scopes,
|
||||||
|
teamSlug: selectedAccount?.value.startsWith("team/")
|
||||||
|
? selectedAccount?.value.substring(5)
|
||||||
|
: undefined, // team account starts with /team/<slug>
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
data-testid="allow-button">
|
||||||
|
{t("allow")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Authorize.PageWrapper = PageWrapper;
|
||||||
@@ -30,12 +30,12 @@ export default function Provider(props: SSOProviderPageProps) {
|
|||||||
const email = searchParams?.get("email");
|
const email = searchParams?.get("email");
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
router.push("/auth/error?error=" + "Email not provided");
|
router.push(`/auth/error?error=Email not provided`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.isSAMLLoginEnabled) {
|
if (!props.isSAMLLoginEnabled) {
|
||||||
router.push("/auth/error?error=" + "SAML login not enabled");
|
router.push(`/auth/error?error=SAML login not enabled`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
const providerParam = asStringOrNull(context.query.provider);
|
const providerParam = asStringOrNull(context.query.provider);
|
||||||
const emailParam = asStringOrNull(context.query.email);
|
const emailParam = asStringOrNull(context.query.email);
|
||||||
const usernameParam = asStringOrNull(context.query.username);
|
const usernameParam = asStringOrNull(context.query.username);
|
||||||
const successDestination = "/getting-started" + (usernameParam ? `?username=${usernameParam}` : "");
|
const successDestination = `/getting-started${usernameParam ? `?username=${usernameParam}` : ""}`;
|
||||||
if (!providerParam) {
|
if (!providerParam) {
|
||||||
throw new Error(`File is not named sso/[provider]`);
|
throw new Error(`File is not named sso/[provider]`);
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
if (error) {
|
if (error) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: "/auth/error?error=" + error,
|
destination: `/auth/error?error=${error}`,
|
||||||
permanent: false,
|
permanent: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,10 +60,16 @@ export default function Verify() {
|
|||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||||
const [secondsLeft, setSecondsLeft] = useState(30);
|
const [secondsLeft, setSecondsLeft] = useState(30);
|
||||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({
|
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
|
||||||
stripeCustomerId,
|
{
|
||||||
checkoutSessionId: sessionId,
|
stripeCustomerId,
|
||||||
});
|
checkoutSessionId: sessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!stripeCustomerId || !!sessionId,
|
||||||
|
staleTime: Infinity,
|
||||||
|
}
|
||||||
|
);
|
||||||
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
|
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
|
||||||
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
|
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
|
||||||
// to avoid refresh to skip waiting 30 seconds to re-send email
|
// to avoid refresh to skip waiting 30 seconds to re-send email
|
||||||
@@ -114,7 +120,7 @@ export default function Verify() {
|
|||||||
? "Your payment failed"
|
? "Your payment failed"
|
||||||
: sessionId
|
: sessionId
|
||||||
? "Payment successful!"
|
? "Payment successful!"
|
||||||
: "Verify your email" + " | " + APP_NAME}
|
: `Verify your email | ${APP_NAME}`}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-6">
|
<div className="flex min-h-screen flex-col items-center justify-center px-6">
|
||||||
@@ -123,12 +129,12 @@ export default function Verify() {
|
|||||||
{hasPaymentFailed ? (
|
{hasPaymentFailed ? (
|
||||||
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||||
) : sessionId ? (
|
) : sessionId ? (
|
||||||
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight dark:text-white" />
|
||||||
) : (
|
) : (
|
||||||
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-cal my-6 text-3xl font-normal">
|
<h3 className="font-cal text-inverted my-6 text-3xl font-normal dark:text-white">
|
||||||
{hasPaymentFailed
|
{hasPaymentFailed
|
||||||
? "Your payment failed"
|
? "Your payment failed"
|
||||||
: sessionId
|
: sessionId
|
||||||
@@ -138,7 +144,7 @@ export default function Verify() {
|
|||||||
{hasPaymentFailed && (
|
{hasPaymentFailed && (
|
||||||
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
|
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
|
||||||
)}
|
)}
|
||||||
<p>
|
<p className="text-inverted dark:text-white">
|
||||||
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
|
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
|
||||||
{hasPaymentFailed &&
|
{hasPaymentFailed &&
|
||||||
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
|
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
|
||||||
@@ -158,7 +164,7 @@ export default function Verify() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSecondsLeft(30);
|
setSecondsLeft(30);
|
||||||
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
||||||
const _searchParams = new URLSearchParams(searchParams);
|
const _searchParams = new URLSearchParams(searchParams.toString());
|
||||||
_searchParams.set("t", `${Date.now()}`);
|
_searchParams.set("t", `${Date.now()}`);
|
||||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||||
return await sendVerificationLogin(customer.email, customer.username);
|
return await sendVerificationLogin(customer.email, customer.username);
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export default function Availability() {
|
|||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
backPath={fromEventType ? true : "/availability"}
|
backPath={fromEventType ? true : "/availability"}
|
||||||
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
|
title={schedule?.name ? `${schedule.name} | ${t("availability")}` : t("availability")}
|
||||||
heading={
|
heading={
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ Troubleshoot.PageWrapper = PageWrapper;
|
|||||||
function convertMinsToHrsMins(mins: number) {
|
function convertMinsToHrsMins(mins: number) {
|
||||||
const h = Math.floor(mins / 60);
|
const h = Math.floor(mins / 60);
|
||||||
const m = mins % 60;
|
const m = mins % 60;
|
||||||
const hs = h < 10 ? "0" + h : h;
|
const hs = h < 10 ? `0${h}` : h;
|
||||||
const ms = m < 10 ? "0" + m : m;
|
const ms = m < 10 ? `0${m}` : m;
|
||||||
return `${hs}:${ms}`;
|
return `${hs}:${ms}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
|
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||||
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
|
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
|
||||||
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||||
@@ -93,7 +94,7 @@ const querySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function Success(props: SuccessProps) {
|
export default function Success(props: SuccessProps) {
|
||||||
const { t, i18n } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -132,7 +133,9 @@ export default function Success(props: SuccessProps) {
|
|||||||
|
|
||||||
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
|
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
|
||||||
|
|
||||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
const [is24h, setIs24h] = useState(
|
||||||
|
props?.userTimeFormat ? props.userTimeFormat === 24 : isBrowserLocale24h()
|
||||||
|
);
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
|
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
|
||||||
@@ -214,8 +217,10 @@ export default function Success(props: SuccessProps) {
|
|||||||
confirmed: !needsConfirmation,
|
confirmed: !needsConfirmation,
|
||||||
// TODO: Add payment details
|
// TODO: Add payment details
|
||||||
});
|
});
|
||||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
setDate(
|
||||||
setIs24h(!!getIs24hClockFromLocalStorage());
|
date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London")
|
||||||
|
);
|
||||||
|
setIs24h(props?.userTimeFormat ? props.userTimeFormat === 24 : !!getIs24hClockFromLocalStorage());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [eventType, needsConfirmation]);
|
}, [eventType, needsConfirmation]);
|
||||||
|
|
||||||
@@ -264,13 +269,13 @@ export default function Success(props: SuccessProps) {
|
|||||||
}
|
}
|
||||||
if (needsConfirmation) {
|
if (needsConfirmation) {
|
||||||
if (props.profile.name !== null) {
|
if (props.profile.name !== null) {
|
||||||
return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, {
|
return t(`user_needs_to_confirm_or_reject_booking${titleSuffix}`, {
|
||||||
user: props.profile.name,
|
user: props.profile.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return t("needs_to_be_confirmed_or_rejected" + titleSuffix);
|
return t(`needs_to_be_confirmed_or_rejected${titleSuffix}`);
|
||||||
}
|
}
|
||||||
return t("emailed_you_and_attendees" + titleSuffix);
|
return t(`emailed_you_and_attendees${titleSuffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a weird case where the same route can be opened in booking flow as a success page or as a booking detail page from the app
|
// This is a weird case where the same route can be opened in booking flow as a success page or as a booking detail page from the app
|
||||||
@@ -353,10 +358,10 @@ export default function Success(props: SuccessProps) {
|
|||||||
<img src={giphyImage} alt="Gif from Giphy" />
|
<img src={giphyImage} alt="Gif from Giphy" />
|
||||||
)}
|
)}
|
||||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
{!giphyImage && !needsConfirmation && !isCancelled && (
|
||||||
<Check className="h-5 w-5 text-green-600" />
|
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
)}
|
)}
|
||||||
{needsConfirmation && !isCancelled && <Calendar className="text-emphasis h-5 w-5" />}
|
{needsConfirmation && !isCancelled && <Calendar className="text-emphasis h-5 w-5" />}
|
||||||
{isCancelled && <X className="h-5 w-5 text-red-600" />}
|
{isCancelled && <X className="h-5 w-5 text-red-600 dark:text-red-200" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-8 mt-6 text-center last:mb-0">
|
<div className="mb-8 mt-6 text-center last:mb-0">
|
||||||
<h3
|
<h3
|
||||||
@@ -486,10 +491,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
: t("payment")}
|
: t("payment")}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 mb-2 mt-3">
|
<div className="col-span-2 mb-2 mt-3">
|
||||||
{new Intl.NumberFormat(i18n.language, {
|
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
|
||||||
style: "currency",
|
|
||||||
currency: props.paymentStatus.currency,
|
|
||||||
}).format(props.paymentStatus.amount / 100.0)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -590,23 +592,24 @@ export default function Success(props: SuccessProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
.utc()
|
||||||
.utc()
|
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
.add(calculatedDuration, "minute")
|
||||||
.add(calculatedDuration, "minute")
|
.utc()
|
||||||
.utc()
|
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
props.eventType.description
|
||||||
props.eventType.description
|
}${
|
||||||
}` +
|
typeof locationVideoCallUrl === "string"
|
||||||
(typeof locationVideoCallUrl === "string"
|
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
|
||||||
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
: ""
|
||||||
: "") +
|
}${
|
||||||
(props.eventType.recurringEvent
|
props.eventType.recurringEvent
|
||||||
? "&recur=" +
|
? `&recur=${encodeURIComponent(
|
||||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
new RRule(props.eventType.recurringEvent).toString()
|
||||||
: "")
|
)}`
|
||||||
}
|
: ""
|
||||||
|
}`}
|
||||||
className="text-default border-subtle h-10 w-10 rounded-sm border px-3 py-2 ltr:mr-2 rtl:ml-2">
|
className="text-default border-subtle h-10 w-10 rounded-sm border px-3 py-2 ltr:mr-2 rtl:ml-2">
|
||||||
<svg
|
<svg
|
||||||
className="-mt-1.5 inline-block h-4 w-4"
|
className="-mt-1.5 inline-block h-4 w-4"
|
||||||
@@ -620,17 +623,17 @@ export default function Success(props: SuccessProps) {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
encodeURI(
|
encodeURI(
|
||||||
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
`https://outlook.live.com/calendar/0/deeplink/compose?body=${
|
||||||
props.eventType.description +
|
props.eventType.description
|
||||||
"&enddt=" +
|
}&enddt=${date
|
||||||
date.add(calculatedDuration, "minute").utc().format() +
|
.add(calculatedDuration, "minute")
|
||||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
.utc()
|
||||||
date.utc().format() +
|
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
|
||||||
"&subject=" +
|
.utc()
|
||||||
eventName
|
.format()}&subject=${eventName}`
|
||||||
) +
|
) +
|
||||||
(locationVideoCallUrl
|
(locationVideoCallUrl
|
||||||
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
|
||||||
: "")
|
: "")
|
||||||
}
|
}
|
||||||
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||||
@@ -647,17 +650,17 @@ export default function Success(props: SuccessProps) {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
encodeURI(
|
encodeURI(
|
||||||
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
`https://outlook.office.com/calendar/0/deeplink/compose?body=${
|
||||||
props.eventType.description +
|
props.eventType.description
|
||||||
"&enddt=" +
|
}&enddt=${date
|
||||||
date.add(calculatedDuration, "minute").utc().format() +
|
.add(calculatedDuration, "minute")
|
||||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
.utc()
|
||||||
date.utc().format() +
|
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
|
||||||
"&subject=" +
|
.utc()
|
||||||
eventName
|
.format()}&subject=${eventName}`
|
||||||
) +
|
) +
|
||||||
(locationVideoCallUrl
|
(locationVideoCallUrl
|
||||||
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
|
||||||
: "")
|
: "")
|
||||||
}
|
}
|
||||||
className="text-default border-subtle mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
className="text-default border-subtle mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||||
@@ -672,9 +675,9 @@ export default function Success(props: SuccessProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={"data:text/calendar," + eventLink()}
|
href={`data:text/calendar,${eventLink()}`}
|
||||||
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||||
download={props.eventType.title + ".ics"}>
|
download={`${props.eventType.title}.ics`}>
|
||||||
<svg
|
<svg
|
||||||
version="1.1"
|
version="1.1"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -1006,10 +1009,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
const session = await getServerSession(context);
|
const session = await getServerSession(context);
|
||||||
let tz: string | null = null;
|
let tz: string | null = null;
|
||||||
|
let userTimeFormat: number | null = null;
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
const user = await ssr.viewer.me.fetch();
|
const user = await ssr.viewer.me.fetch();
|
||||||
tz = user.timeZone;
|
tz = user.timeZone;
|
||||||
|
userTimeFormat = user.timeFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedQuery = querySchema.safeParse(context.query);
|
const parsedQuery = querySchema.safeParse(context.query);
|
||||||
@@ -1150,6 +1155,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
bookingInfo,
|
bookingInfo,
|
||||||
paymentStatus: payment,
|
paymentStatus: payment,
|
||||||
...(tz && { tz }),
|
...(tz && { tz }),
|
||||||
|
userTimeFormat,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function Type({
|
|||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
entity,
|
entity,
|
||||||
duration,
|
duration,
|
||||||
|
hashedLink,
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
return (
|
return (
|
||||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||||
@@ -46,6 +47,7 @@ export default function Type({
|
|||||||
isTeamEvent={isTeamEvent}
|
isTeamEvent={isTeamEvent}
|
||||||
entity={entity}
|
entity={entity}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
|
hashedLink={hashedLink}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -149,6 +151,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
// Sending the team event from the server, because this template file
|
// Sending the team event from the server, because this template file
|
||||||
// is reused for both team and user events.
|
// is reused for both team and user events.
|
||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
|
hashedLink: link,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,7 +449,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
availability={availability}
|
availability={availability}
|
||||||
isUpdateMutationLoading={updateMutation.isLoading}
|
isUpdateMutationLoading={updateMutation.isLoading}
|
||||||
formMethods={formMethods}
|
formMethods={formMethods}
|
||||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||||
|
disableBorder={true}
|
||||||
currentUserMembership={currentUserMembership}
|
currentUserMembership={currentUserMembership}
|
||||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
|||||||
readOnly={events[0].metadata.readOnly}
|
readOnly={events[0].metadata.readOnly}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CreateFirstEventTypeView />
|
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -140,13 +140,13 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
|||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
|
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
|
||||||
data-testid={"event-type-title-" + type.id}>
|
data-testid={`event-type-title-${type.id}`}>
|
||||||
{type.title}
|
{type.title}
|
||||||
</span>
|
</span>
|
||||||
{group.profile.slug ? (
|
{group.profile.slug ? (
|
||||||
<small
|
<small
|
||||||
className="text-subtle hidden font-normal leading-4 sm:inline"
|
className="text-subtle hidden font-normal leading-4 sm:inline"
|
||||||
data-testid={"event-type-slug-" + type.id}>
|
data-testid={`event-type-slug-${type.id}`}>
|
||||||
{`/${
|
{`/${
|
||||||
type.schedulingType !== SchedulingType.MANAGED ? group.profile.slug : t("username_placeholder")
|
type.schedulingType !== SchedulingType.MANAGED ? group.profile.slug : t("username_placeholder")
|
||||||
}/${type.slug}`}
|
}/${type.slug}`}
|
||||||
@@ -177,13 +177,13 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
|||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
|
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
|
||||||
data-testid={"event-type-title-" + type.id}>
|
data-testid={`event-type-title-${type.id}`}>
|
||||||
{type.title}
|
{type.title}
|
||||||
</span>
|
</span>
|
||||||
{group.profile.slug ? (
|
{group.profile.slug ? (
|
||||||
<small
|
<small
|
||||||
className="text-subtle hidden font-normal leading-4 sm:inline"
|
className="text-subtle hidden font-normal leading-4 sm:inline"
|
||||||
data-testid={"event-type-slug-" + type.id}>
|
data-testid={`event-type-slug-${type.id}`}>
|
||||||
{`/${group.profile.slug}/${type.slug}`}
|
{`/${group.profile.slug}/${type.slug}`}
|
||||||
</small>
|
</small>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -363,7 +363,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!types.length) {
|
if (!types.length) {
|
||||||
return group.teamId ? <EmptyEventTypeList group={group} /> : <CreateFirstEventTypeView />;
|
return group.teamId ? (
|
||||||
|
<EmptyEventTypeList group={group} />
|
||||||
|
) : (
|
||||||
|
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstItem = types[0];
|
const firstItem = types[0];
|
||||||
@@ -475,7 +479,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Dropdown modal={false}>
|
<Dropdown modal={false}>
|
||||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
@@ -489,9 +493,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={"event-type-edit-" + type.id}
|
data-testid={`event-type-edit-${type.id}`}
|
||||||
StartIcon={Edit2}
|
StartIcon={Edit2}
|
||||||
onClick={() => router.push("/event-types/" + type.id)}>
|
onClick={() => router.push(`/event-types/${type.id}`)}>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -501,7 +505,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={"event-type-duplicate-" + type.id}
|
data-testid={`event-type-duplicate-${type.id}`}
|
||||||
StartIcon={Copy}
|
StartIcon={Copy}
|
||||||
onClick={() => openDuplicateModal(type, group)}>
|
onClick={() => openDuplicateModal(type, group)}>
|
||||||
{t("duplicate")}
|
{t("duplicate")}
|
||||||
@@ -551,7 +555,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-9 mx-5 flex sm:hidden">
|
<div className="min-w-9 mx-5 flex sm:hidden">
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
|
||||||
<Button type="button" variant="icon" color="secondary" StartIcon={MoreHorizontal} />
|
<Button type="button" variant="icon" color="secondary" StartIcon={MoreHorizontal} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
@@ -569,7 +573,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
data-testid={"event-type-duplicate-" + type.id}
|
data-testid={`event-type-duplicate-${type.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(calLink);
|
navigator.clipboard.writeText(calLink);
|
||||||
showToast(t("link_copied"), "success");
|
showToast(t("link_copied"), "success");
|
||||||
@@ -584,7 +588,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
{isNativeShare ? (
|
{isNativeShare ? (
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
data-testid={"event-type-duplicate-" + type.id}
|
data-testid={`event-type-duplicate-${type.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator
|
navigator
|
||||||
.share({
|
.share({
|
||||||
@@ -604,7 +608,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onClick={() => router.push("/event-types/" + type.id)}
|
onClick={() => router.push(`/event-types/${type.id}`)}
|
||||||
StartIcon={Edit}
|
StartIcon={Edit}
|
||||||
className="w-full rounded-none">
|
className="w-full rounded-none">
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
@@ -616,7 +620,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
<DropdownItem
|
<DropdownItem
|
||||||
onClick={() => openDuplicateModal(type, group)}
|
onClick={() => openDuplicateModal(type, group)}
|
||||||
StartIcon={Copy}
|
StartIcon={Copy}
|
||||||
data-testid={"event-type-duplicate-" + type.id}>
|
data-testid={`event-type-duplicate-${type.id}`}>
|
||||||
{t("duplicate")}
|
{t("duplicate")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -764,7 +768,7 @@ const EventTypeListHeading = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreateFirstEventTypeView = () => {
|
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -774,7 +778,7 @@ const CreateFirstEventTypeView = () => {
|
|||||||
description={t("new_event_type_description")}
|
description={t("new_event_type_description")}
|
||||||
className="mb-16"
|
className="mb-16"
|
||||||
buttonRaw={
|
buttonRaw={
|
||||||
<Button href="?dialog=new" variant="button">
|
<Button href={`?dialog=new&eventPage=${slug}`} variant="button">
|
||||||
{t("create")}
|
{t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -915,7 +919,7 @@ const Main = ({
|
|||||||
) : group.teamId ? (
|
) : group.teamId ? (
|
||||||
<EmptyEventTypeList group={group} />
|
<EmptyEventTypeList group={group} />
|
||||||
) : (
|
) : (
|
||||||
<CreateFirstEventTypeView />
|
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -931,7 +935,7 @@ const Main = ({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
|
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
|
||||||
<EventTypeEmbedDialog />
|
<EventTypeEmbedDialog />
|
||||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -63,14 +63,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||||
|
|
||||||
const eventPage =
|
const eventPage = `${
|
||||||
(eventType.team
|
eventType.team
|
||||||
? "team/" + eventType.team.slug
|
? `team/${eventType.team.slug}`
|
||||||
: dynamicEventSlugRef
|
: dynamicEventSlugRef
|
||||||
? booking.dynamicGroupSlugRef
|
? booking.dynamicGroupSlugRef
|
||||||
: booking.user?.username || "rick") /* This shouldn't happen */ +
|
: booking.user?.username || "rick" /* This shouldn't happen */
|
||||||
"/" +
|
}/${eventType?.slug}`;
|
||||||
eventType?.slug;
|
|
||||||
const destinationUrl = new URLSearchParams();
|
const destinationUrl = new URLSearchParams();
|
||||||
|
|
||||||
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
|
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import PageWrapper from "@components/PageWrapper";
|
|
||||||
import { getLayout } from "@components/auth/layouts/AdminLayout";
|
|
||||||
|
|
||||||
import KYCVerificationView from "./kycVerificationView";
|
|
||||||
|
|
||||||
const KYCVerificationPage = () => <KYCVerificationView />;
|
|
||||||
|
|
||||||
KYCVerificationPage.getLayout = getLayout;
|
|
||||||
KYCVerificationPage.PageWrapper = PageWrapper;
|
|
||||||
|
|
||||||
export default KYCVerificationPage;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { trpc } from "@calcom/trpc";
|
|
||||||
import { Meta, Form, Button, TextField, showToast } from "@calcom/ui";
|
|
||||||
|
|
||||||
type FormValues = {
|
|
||||||
name?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function KYCVerificationView() {
|
|
||||||
const teamForm = useForm<FormValues>();
|
|
||||||
const userForm = useForm<FormValues>();
|
|
||||||
|
|
||||||
const mutation = trpc.viewer.kycVerification.verify.useMutation({
|
|
||||||
onSuccess: async (data) => {
|
|
||||||
showToast(`Successfully verified ${data.isTeam ? "team" : "user"} ${data.name}`, "success");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
showToast(`Verification failed: ${error.message}`, "error");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Meta
|
|
||||||
title="KYC Verification"
|
|
||||||
description="Here you can verify users and teams. This verification is needed for sending sms/whatsapp messages to attendees."
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 font-medium">Verify Team</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
form={teamForm}
|
|
||||||
handleSubmit={(values) => {
|
|
||||||
mutation.mutate({
|
|
||||||
name: values.name || "",
|
|
||||||
isTeam: true,
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<TextField
|
|
||||||
{...teamForm.register("name")}
|
|
||||||
label=""
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
placeholder="team slug"
|
|
||||||
className="-mt-2 "
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button type="submit">Verify</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 mt-6 font-medium">Verify User</div>
|
|
||||||
<Form
|
|
||||||
form={userForm}
|
|
||||||
handleSubmit={(values) => {
|
|
||||||
mutation.mutate({
|
|
||||||
name: values.name || "",
|
|
||||||
isTeam: false,
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<TextField
|
|
||||||
{...userForm.register("name")}
|
|
||||||
label=""
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
placeholder="user name"
|
|
||||||
className="-mt-2"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button type="submit">Verify</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
import { getLayout } from "@components/auth/layouts/AdminLayout";
|
||||||
|
|
||||||
|
import OAuthView from "./oAuthView";
|
||||||
|
|
||||||
|
const OAuthPage = () => <OAuthView />;
|
||||||
|
|
||||||
|
OAuthPage.getLayout = getLayout;
|
||||||
|
OAuthPage.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
|
export default OAuthPage;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc";
|
||||||
|
import { Meta, Form, Button, TextField, showToast, Tooltip, ImageUploader, Avatar } from "@calcom/ui";
|
||||||
|
import { Clipboard } from "@calcom/ui/components/icon";
|
||||||
|
import { Plus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
redirectUri: string;
|
||||||
|
logo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OAuthView() {
|
||||||
|
const oAuthForm = useForm<FormValues>();
|
||||||
|
const [clientSecret, setClientSecret] = useState("");
|
||||||
|
const [clientId, setClientId] = useState("");
|
||||||
|
const [logo, setLogo] = useState("");
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const mutation = trpc.viewer.oAuth.addClient.useMutation({
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
setClientSecret(data.clientSecret);
|
||||||
|
setClientId(data.clientId);
|
||||||
|
showToast(`Successfully added ${data.name} as new client`, "success");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showToast(`Adding clientfailed: ${error.message}`, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Meta title="OAuth" description="Add new OAuth Clients" />
|
||||||
|
{!clientId ? (
|
||||||
|
<Form
|
||||||
|
form={oAuthForm}
|
||||||
|
handleSubmit={(values) => {
|
||||||
|
mutation.mutate({
|
||||||
|
name: values.name,
|
||||||
|
redirectUri: values.redirectUri,
|
||||||
|
logo: values.logo,
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<div className="">
|
||||||
|
<TextField
|
||||||
|
{...oAuthForm.register("name")}
|
||||||
|
label="Client name"
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
placeholder=""
|
||||||
|
className="mb-3"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...oAuthForm.register("redirectUri")}
|
||||||
|
label="Redirect URI"
|
||||||
|
type="text"
|
||||||
|
id="redirectUri"
|
||||||
|
placeholder=""
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="mb-5 mt-5 flex items-center">
|
||||||
|
<Avatar
|
||||||
|
alt=""
|
||||||
|
fallback={<Plus className="text-subtle h-6 w-6" />}
|
||||||
|
className="mr-5 items-center"
|
||||||
|
imageSrc={logo}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<ImageUploader
|
||||||
|
target="avatar"
|
||||||
|
id="avatar-upload"
|
||||||
|
buttonMsg="Upload Logo"
|
||||||
|
handleAvatarChange={(newLogo: string) => {
|
||||||
|
setLogo(newLogo);
|
||||||
|
oAuthForm.setValue("logo", newLogo);
|
||||||
|
}}
|
||||||
|
imageSrc={logo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="mt-3">
|
||||||
|
{t("add_client")}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-emphasis mb-5 text-xl font-semibold">{oAuthForm.getValues("name")}</div>
|
||||||
|
<div className="mb-2 font-medium">Client Id</div>
|
||||||
|
<div className="flex">
|
||||||
|
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
|
||||||
|
{" "}
|
||||||
|
{clientId}
|
||||||
|
</code>
|
||||||
|
<Tooltip side="top" content="Copy to Clipboard">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(clientId);
|
||||||
|
showToast("Client ID copied!", "success");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="rounded-l-none text-base"
|
||||||
|
StartIcon={Clipboard}>
|
||||||
|
{t("copy")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{clientSecret ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 mt-4 font-medium">Client Secret</div>
|
||||||
|
<div className="flex">
|
||||||
|
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
|
||||||
|
{" "}
|
||||||
|
{clientSecret}
|
||||||
|
</code>
|
||||||
|
<Tooltip side="top" content="Copy to Clipboard">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(clientSecret);
|
||||||
|
setClientSecret("");
|
||||||
|
showToast("Client secret copied!", "success");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="rounded-l-none text-base"
|
||||||
|
StartIcon={Clipboard}>
|
||||||
|
{t("copy")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="text-subtle text-sm">{t("copy_client_secret_info")}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setClientId("");
|
||||||
|
setLogo("");
|
||||||
|
oAuthForm.reset();
|
||||||
|
}}
|
||||||
|
className="mt-5">
|
||||||
|
{t("add_new_client")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
|
|||||||
<>
|
<>
|
||||||
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
|
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-medium">{title}</h2>
|
<h2 className="text-base font-semibold">{title}</h2>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
|
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
<hr className="border-subtle" />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -45,14 +44,16 @@ const BillingView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("billing")} description={t("manage_billing_description")} />
|
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
|
||||||
<div className="space-y-6 text-sm sm:space-y-8">
|
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
||||||
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||||
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
||||||
{t("billing_portal")}
|
{t("billing_portal")}
|
||||||
</Button>
|
</Button>
|
||||||
</CtaRow>
|
</CtaRow>
|
||||||
|
|
||||||
|
<hr className="border-subtle" />
|
||||||
|
|
||||||
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
||||||
<Button color="secondary" onClick={onContactSupportClick}>
|
<Button color="secondary" onClick={onContactSupportClick}>
|
||||||
{t("contact_support")}
|
{t("contact_support")}
|
||||||
|
|||||||
@@ -14,12 +14,25 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
EmptyScreen,
|
EmptyScreen,
|
||||||
Meta,
|
Meta,
|
||||||
AppSkeletonLoader as SkeletonLoader,
|
SkeletonContainer,
|
||||||
|
SkeletonText,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
|
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
|
return (
|
||||||
|
<SkeletonContainer>
|
||||||
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
|
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
||||||
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
</SkeletonContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ApiKeysView = () => {
|
const ApiKeysView = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -39,49 +52,57 @@ const ApiKeysView = () => {
|
|||||||
setApiKeyToEdit(undefined);
|
setApiKeyToEdit(undefined);
|
||||||
setApiKeyModal(true);
|
setApiKeyModal(true);
|
||||||
}}>
|
}}>
|
||||||
{t("new_api_key")}
|
{t("add")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<SkeletonLoader
|
||||||
|
title={t("api_keys")}
|
||||||
|
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta
|
<Meta
|
||||||
title={t("api_keys")}
|
title={t("api_keys")}
|
||||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||||
|
CTA={<NewApiKeyButton />}
|
||||||
|
borderInShellHeader={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
<>
|
<div>
|
||||||
{isLoading && <SkeletonLoader />}
|
{data?.length ? (
|
||||||
<div>
|
<>
|
||||||
{isLoading ? null : data?.length ? (
|
<div className="border-subtle rounded-b-md border border-t-0">
|
||||||
<>
|
{data.map((apiKey, index) => (
|
||||||
<div className="border-subtle mb-8 mt-6 rounded-md border">
|
<ApiKeyListItem
|
||||||
{data.map((apiKey, index) => (
|
key={apiKey.id}
|
||||||
<ApiKeyListItem
|
apiKey={apiKey}
|
||||||
key={apiKey.id}
|
lastItem={data.length === index + 1}
|
||||||
apiKey={apiKey}
|
onEditClick={() => {
|
||||||
lastItem={data.length === index + 1}
|
setApiKeyToEdit(apiKey);
|
||||||
onEditClick={() => {
|
setApiKeyModal(true);
|
||||||
setApiKeyToEdit(apiKey);
|
}}
|
||||||
setApiKeyModal(true);
|
/>
|
||||||
}}
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
</>
|
||||||
</div>
|
) : (
|
||||||
<NewApiKeyButton />
|
<EmptyScreen
|
||||||
</>
|
Icon={LinkIcon}
|
||||||
) : (
|
headline={t("create_first_api_key")}
|
||||||
<EmptyScreen
|
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||||
Icon={LinkIcon}
|
className="rounded-b-md rounded-t-none border-t-0"
|
||||||
headline={t("create_first_api_key")}
|
buttonRaw={<NewApiKeyButton />}
|
||||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
/>
|
||||||
buttonRaw={<NewApiKeyButton />}
|
)}
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</LicenseRequired>
|
</LicenseRequired>
|
||||||
|
|
||||||
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
|
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||||
@@ -12,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||||
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -22,7 +25,7 @@ import {
|
|||||||
SkeletonButton,
|
SkeletonButton,
|
||||||
SkeletonContainer,
|
SkeletonContainer,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
Switch,
|
SettingsToggle,
|
||||||
UpgradeTeamsBadge,
|
UpgradeTeamsBadge,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
@@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={false} />
|
||||||
<div className="mb-8 mt-6 space-y-6">
|
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center justify-center">
|
||||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||||
@@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
<div className="rounded-b-xl">
|
||||||
|
<SectionBottomActions align="end">
|
||||||
|
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||||
|
</SectionBottomActions>
|
||||||
</div>
|
</div>
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppearanceView = () => {
|
const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
|
||||||
|
const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
|
||||||
|
|
||||||
|
const AppearanceView = ({
|
||||||
|
user,
|
||||||
|
hasPaidPlan,
|
||||||
|
}: {
|
||||||
|
user: RouterOutputs["viewer"]["me"];
|
||||||
|
hasPaidPlan: boolean;
|
||||||
|
}) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
|
||||||
const [darkModeError, setDarkModeError] = useState(false);
|
const [darkModeError, setDarkModeError] = useState(false);
|
||||||
const [lightModeError, setLightModeError] = useState(false);
|
const [lightModeError, setLightModeError] = useState(false);
|
||||||
|
const [isCustomBrandColorChecked, setIsCustomBranColorChecked] = useState(
|
||||||
|
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
|
||||||
|
);
|
||||||
|
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
|
||||||
|
|
||||||
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
|
const userThemeFormMethods = useForm({
|
||||||
|
|
||||||
const formMethods = useForm({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
theme: user?.theme,
|
theme: user.theme,
|
||||||
brandColor: user?.brandColor || "#292929",
|
|
||||||
darkBrandColor: user?.darkBrandColor || "#fafafa",
|
|
||||||
hideBranding: user?.hideBranding,
|
|
||||||
metadata: user?.metadata as z.infer<typeof userMetadata>,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedTheme = formMethods.watch("theme");
|
const {
|
||||||
|
formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty },
|
||||||
|
reset: resetUserThemeReset,
|
||||||
|
} = userThemeFormMethods;
|
||||||
|
|
||||||
|
const bookerLayoutFormMethods = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
metadata: user.metadata as z.infer<typeof userMetadata>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty },
|
||||||
|
reset: resetBookerLayoutThemeReset,
|
||||||
|
} = bookerLayoutFormMethods;
|
||||||
|
|
||||||
|
const brandColorsFormMethods = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
|
||||||
|
darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
|
||||||
|
reset: resetBrandColorsThemeReset,
|
||||||
|
} = brandColorsFormMethods;
|
||||||
|
|
||||||
|
const selectedTheme = userThemeFormMethods.watch("theme");
|
||||||
const selectedThemeIsDark =
|
const selectedThemeIsDark =
|
||||||
selectedTheme === "dark" ||
|
selectedTheme === "dark" ||
|
||||||
(selectedTheme === "" &&
|
(selectedTheme === "" &&
|
||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
document.documentElement.classList.contains("dark"));
|
document.documentElement.classList.contains("dark"));
|
||||||
|
|
||||||
const {
|
|
||||||
formState: { isSubmitting, isDirty },
|
|
||||||
reset,
|
|
||||||
} = formMethods;
|
|
||||||
|
|
||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await utils.viewer.me.invalidate();
|
await utils.viewer.me.invalidate();
|
||||||
showToast(t("settings_updated_successfully"), "success");
|
showToast(t("settings_updated_successfully"), "success");
|
||||||
reset(data);
|
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
|
||||||
|
resetBookerLayoutThemeReset({ metadata: data.metadata });
|
||||||
|
resetUserThemeReset({ theme: data.theme });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
@@ -97,136 +134,180 @@ const AppearanceView = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || isTeamPlanStatusLoading)
|
|
||||||
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
|
|
||||||
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
const isDisabled = isSubmitting || !isDirty;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<div>
|
||||||
form={formMethods}
|
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
||||||
handleSubmit={(values) => {
|
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||||
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
|
|
||||||
if (layoutError) throw new Error(t(layoutError));
|
|
||||||
|
|
||||||
mutation.mutate({
|
|
||||||
...values,
|
|
||||||
// Radio values don't support null as values, therefore we convert an empty string
|
|
||||||
// back to null here.
|
|
||||||
theme: values.theme || null,
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<Meta title={t("appearance")} description={t("appearance_description")} />
|
|
||||||
<div className="mb-6 flex items-center text-sm">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-default font-semibold">{t("theme")}</p>
|
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
||||||
<p className="text-default">{t("theme_applies_note")}</p>
|
<p className="text-default">{t("theme_applies_note")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-between sm:flex-row">
|
<Form
|
||||||
<ThemeLabel
|
form={userThemeFormMethods}
|
||||||
variant="system"
|
handleSubmit={(values) => {
|
||||||
value={null}
|
mutation.mutate({
|
||||||
label={t("theme_system")}
|
// Radio values don't support null as values, therefore we convert an empty string
|
||||||
defaultChecked={user.theme === null}
|
// back to null here.
|
||||||
register={formMethods.register}
|
theme: values.theme || null,
|
||||||
/>
|
});
|
||||||
<ThemeLabel
|
}}>
|
||||||
variant="light"
|
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||||
value="light"
|
<ThemeLabel
|
||||||
label={t("light")}
|
variant="system"
|
||||||
defaultChecked={user.theme === "light"}
|
value={null}
|
||||||
register={formMethods.register}
|
label={t("theme_system")}
|
||||||
/>
|
defaultChecked={user.theme === null}
|
||||||
<ThemeLabel
|
register={userThemeFormMethods.register}
|
||||||
variant="dark"
|
/>
|
||||||
value="dark"
|
<ThemeLabel
|
||||||
label={t("dark")}
|
variant="light"
|
||||||
defaultChecked={user.theme === "dark"}
|
value="light"
|
||||||
register={formMethods.register}
|
label={t("light")}
|
||||||
/>
|
defaultChecked={user.theme === "light"}
|
||||||
</div>
|
register={userThemeFormMethods.register}
|
||||||
|
/>
|
||||||
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
|
<ThemeLabel
|
||||||
<BookerLayoutSelector
|
variant="dark"
|
||||||
isDark={selectedThemeIsDark}
|
value="dark"
|
||||||
name="metadata.defaultBookerLayouts"
|
label={t("dark")}
|
||||||
title={t("bookerlayout_user_settings_title")}
|
defaultChecked={user.theme === "dark"}
|
||||||
description={t("bookerlayout_user_settings_description")}
|
register={userThemeFormMethods.register}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<hr className="border-subtle my-8 border" />
|
|
||||||
<div className="mb-6 flex items-center text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-default font-semibold">{t("custom_brand_colors")}</p>
|
|
||||||
<p className="text-default mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<SectionBottomActions className="mb-6" align="end">
|
||||||
|
<Button
|
||||||
|
disabled={isUserThemeSubmitting || !isUserThemeDirty}
|
||||||
|
type="submit"
|
||||||
|
data-testid="update-theme-btn"
|
||||||
|
color="primary">
|
||||||
|
{t("update")}
|
||||||
|
</Button>
|
||||||
|
</SectionBottomActions>
|
||||||
|
</Form>
|
||||||
|
|
||||||
<div className="block justify-between sm:flex">
|
<Form
|
||||||
<Controller
|
form={bookerLayoutFormMethods}
|
||||||
name="brandColor"
|
handleSubmit={(values) => {
|
||||||
control={formMethods.control}
|
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
|
||||||
defaultValue={user.brandColor}
|
if (layoutError) {
|
||||||
render={() => (
|
showToast(t(layoutError), "error");
|
||||||
<div>
|
return;
|
||||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
} else {
|
||||||
<ColorPicker
|
mutation.mutate(values);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<BookerLayoutSelector
|
||||||
|
isDark={selectedThemeIsDark}
|
||||||
|
name="metadata.defaultBookerLayouts"
|
||||||
|
title={t("bookerlayout_user_settings_title")}
|
||||||
|
description={t("bookerlayout_user_settings_description")}
|
||||||
|
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={brandColorsFormMethods}
|
||||||
|
handleSubmit={(values) => {
|
||||||
|
mutation.mutate(values);
|
||||||
|
}}>
|
||||||
|
<div className="mt-6">
|
||||||
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
title={t("custom_brand_colors")}
|
||||||
|
description={t("customize_your_brand_colors")}
|
||||||
|
checked={isCustomBrandColorChecked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setIsCustomBranColorChecked(checked);
|
||||||
|
if (!checked) {
|
||||||
|
mutation.mutate({
|
||||||
|
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
|
||||||
|
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
childrenClassName="lg:ml-0"
|
||||||
|
switchContainerClassName={classNames(
|
||||||
|
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||||
|
isCustomBrandColorChecked && "rounded-b-none"
|
||||||
|
)}>
|
||||||
|
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
||||||
|
<Controller
|
||||||
|
name="brandColor"
|
||||||
|
control={brandColorsFormMethods.control}
|
||||||
defaultValue={user.brandColor}
|
defaultValue={user.brandColor}
|
||||||
resetDefaultValue="#292929"
|
render={() => (
|
||||||
onChange={(value) => {
|
<div>
|
||||||
if (!checkWCAGContrastColor("#ffffff", value)) {
|
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||||
setLightModeError(true);
|
<ColorPicker
|
||||||
} else {
|
defaultValue={user.brandColor}
|
||||||
setLightModeError(false);
|
resetDefaultValue="#292929"
|
||||||
}
|
onChange={(value) => {
|
||||||
formMethods.setValue("brandColor", value, { shouldDirty: true });
|
try {
|
||||||
}}
|
checkWCAGContrastColor("#ffffff", value);
|
||||||
|
setLightModeError(false);
|
||||||
|
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
|
||||||
|
} catch (err) {
|
||||||
|
setLightModeError(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{lightModeError ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
<Controller
|
||||||
/>
|
name="darkBrandColor"
|
||||||
<Controller
|
control={brandColorsFormMethods.control}
|
||||||
name="darkBrandColor"
|
|
||||||
control={formMethods.control}
|
|
||||||
defaultValue={user.darkBrandColor}
|
|
||||||
render={() => (
|
|
||||||
<div className="mt-6 sm:mt-0">
|
|
||||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
|
||||||
<ColorPicker
|
|
||||||
defaultValue={user.darkBrandColor}
|
defaultValue={user.darkBrandColor}
|
||||||
resetDefaultValue="#fafafa"
|
render={() => (
|
||||||
onChange={(value) => {
|
<div className="mt-6 sm:mt-0">
|
||||||
if (!checkWCAGContrastColor("#101010", value)) {
|
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||||
setDarkModeError(true);
|
<ColorPicker
|
||||||
} else {
|
defaultValue={user.darkBrandColor}
|
||||||
setDarkModeError(false);
|
resetDefaultValue="#fafafa"
|
||||||
}
|
onChange={(value) => {
|
||||||
formMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
try {
|
||||||
}}
|
checkWCAGContrastColor("#101010", value);
|
||||||
|
setDarkModeError(false);
|
||||||
|
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
||||||
|
} catch (err) {
|
||||||
|
setDarkModeError(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{darkModeError ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<SectionBottomActions align="end">
|
||||||
/>
|
<Button
|
||||||
</div>
|
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
|
||||||
{darkModeError ? (
|
color="primary"
|
||||||
<div className="mt-4">
|
type="submit">
|
||||||
<Alert
|
{t("update")}
|
||||||
severity="warning"
|
</Button>
|
||||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
</SectionBottomActions>
|
||||||
/>
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</Form>
|
||||||
{lightModeError ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* TODO future PR to preview brandColors */}
|
{/* TODO future PR to preview brandColors */}
|
||||||
{/* <Button
|
{/* <Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@@ -235,51 +316,37 @@ const AppearanceView = () => {
|
|||||||
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
|
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
|
||||||
Preview
|
Preview
|
||||||
</Button> */}
|
</Button> */}
|
||||||
<hr className="border-subtle my-8 border" />
|
|
||||||
<Controller
|
<SettingsToggle
|
||||||
name="hideBranding"
|
toggleSwitchAtTheEnd={true}
|
||||||
control={formMethods.control}
|
title={t("disable_cal_branding", { appName: APP_NAME })}
|
||||||
defaultValue={user.hideBranding}
|
disabled={!hasPaidPlan || mutation?.isLoading}
|
||||||
render={({ field: { value } }) => (
|
description={t("removes_cal_branding", { appName: APP_NAME })}
|
||||||
<>
|
checked={hasPaidPlan ? hideBrandingValue : false}
|
||||||
<div className="flex w-full text-sm">
|
Badge={<UpgradeTeamsBadge />}
|
||||||
<div className="mr-1 flex-grow">
|
onCheckedChange={(checked) => {
|
||||||
<div className="flex items-center">
|
setHideBrandingValue(checked);
|
||||||
<p className="text-default font-semibold ltr:mr-2 rtl:ml-2">
|
mutation.mutate({ hideBranding: checked });
|
||||||
{t("disable_cal_branding", { appName: APP_NAME })}
|
}}
|
||||||
</p>
|
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||||
<UpgradeTeamsBadge />
|
|
||||||
</div>
|
|
||||||
<p className="text-default mt-0.5">{t("removes_cal_branding", { appName: APP_NAME })}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-none">
|
|
||||||
<Switch
|
|
||||||
id="hideBranding"
|
|
||||||
disabled={!hasPaidPlan}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
formMethods.setValue("hideBranding", checked, { shouldDirty: true })
|
|
||||||
}
|
|
||||||
checked={hasPaidPlan ? value : false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
</div>
|
||||||
disabled={isDisabled}
|
|
||||||
type="submit"
|
|
||||||
loading={mutation.isLoading}
|
|
||||||
color="primary"
|
|
||||||
className="mt-8"
|
|
||||||
data-testid="update-theme-btn">
|
|
||||||
{t("update")}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AppearanceView.getLayout = getLayout;
|
const AppearanceViewWrapper = () => {
|
||||||
AppearanceView.PageWrapper = PageWrapper;
|
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||||
|
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
|
||||||
|
|
||||||
export default AppearanceView;
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
if (isLoading || isTeamPlanStatusLoading || !user)
|
||||||
|
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
|
||||||
|
|
||||||
|
return <AppearanceView user={user} hasPaidPlan={hasPaidPlan} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
AppearanceViewWrapper.getLayout = getLayout;
|
||||||
|
AppearanceViewWrapper.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
|
export default AppearanceViewWrapper;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Trans } from "next-i18next";
|
import { Trans } from "next-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useState, useEffect } from "react";
|
||||||
|
|
||||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
const SkeletonLoader = () => {
|
const SkeletonLoader = () => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<div className="mb-8 mt-6 space-y-6">
|
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
|
||||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
|
||||||
</div>
|
</div>
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
);
|
);
|
||||||
@@ -65,6 +66,21 @@ const CalendarsView = () => {
|
|||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||||
|
|
||||||
|
const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{
|
||||||
|
integration: string;
|
||||||
|
externalId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query?.data?.destinationCalendar) {
|
||||||
|
setSelectedDestinationCalendar({
|
||||||
|
integration: query.data.destinationCalendar.integration,
|
||||||
|
externalId: query.data.destinationCalendar.externalId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [query?.isLoading, query?.data?.destinationCalendar]);
|
||||||
|
|
||||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.viewer.connectedCalendars.invalidate();
|
await utils.viewer.connectedCalendars.invalidate();
|
||||||
@@ -79,43 +95,58 @@ const CalendarsView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("calendars")} description={t("calendars_description")} CTA={<AddCalendarButton />} />
|
<Meta
|
||||||
|
title={t("calendars")}
|
||||||
|
description={t("calendars_description")}
|
||||||
|
CTA={<AddCalendarButton />}
|
||||||
|
borderInShellHeader={false}
|
||||||
|
/>
|
||||||
<QueryCell
|
<QueryCell
|
||||||
query={query}
|
query={query}
|
||||||
customLoader={<SkeletonLoader />}
|
customLoader={<SkeletonLoader />}
|
||||||
success={({ data }) => {
|
success={({ data }) => {
|
||||||
|
const isDestinationUpdateBtnDisabled =
|
||||||
|
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
||||||
return data.connectedCalendars.length ? (
|
return data.connectedCalendars.length ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-muted border-subtle mt-4 flex space-x-4 rounded-md p-2 sm:mx-0 sm:p-10 md:border md:p-6 xl:mt-0">
|
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
||||||
<div className=" bg-default border-subtle flex h-9 w-9 items-center justify-center rounded-md border-2 p-[6px]">
|
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||||
<Calendar className="text-default h-6 w-6" />
|
{t("add_to_calendar")}
|
||||||
</div>
|
</h2>
|
||||||
|
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
|
||||||
<div className="flex w-full flex-col space-y-3">
|
|
||||||
<div>
|
|
||||||
<h4 className=" text-emphasis pb-2 text-base font-semibold leading-5">
|
|
||||||
{t("add_to_calendar")}
|
|
||||||
</h4>
|
|
||||||
<p className=" text-default text-sm leading-5">
|
|
||||||
<Trans i18nKey="add_to_calendar_description">
|
|
||||||
Where to add events when you re booked. You can override this on a per-event basis in
|
|
||||||
advanced settings in the event type.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DestinationCalendarSelector
|
|
||||||
hidePlaceholder
|
|
||||||
value={data.destinationCalendar?.externalId}
|
|
||||||
onChange={mutation.mutate}
|
|
||||||
isLoading={mutation.isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-emphasis mt-12 text-base font-semibold leading-5">
|
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
||||||
{t("check_for_conflicts")}
|
<DestinationCalendarSelector
|
||||||
</h4>
|
hidePlaceholder
|
||||||
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
value={selectedDestinationCalendarOption?.externalId}
|
||||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
onChange={(option) => {
|
||||||
|
setSelectedDestinationCalendar(option);
|
||||||
|
}}
|
||||||
|
isLoading={mutation.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SectionBottomActions align="end">
|
||||||
|
<Button
|
||||||
|
loading={mutation.isLoading}
|
||||||
|
disabled={isDestinationUpdateBtnDisabled}
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedDestinationCalendarOption) mutation.mutate(selectedDestinationCalendarOption);
|
||||||
|
}}>
|
||||||
|
{t("update")}
|
||||||
|
</Button>
|
||||||
|
</SectionBottomActions>
|
||||||
|
|
||||||
|
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
||||||
|
<h4 className="text-emphasis text-base font-semibold leading-5">
|
||||||
|
{t("check_for_conflicts")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
|
||||||
|
noBorderTreatment>
|
||||||
{data.connectedCalendars.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
<Fragment key={item.credentialId}>
|
<Fragment key={item.credentialId}>
|
||||||
{item.error && item.error.message && (
|
{item.error && item.error.message && (
|
||||||
@@ -159,7 +190,7 @@ const CalendarsView = () => {
|
|||||||
}
|
}
|
||||||
<div className="flex-grow truncate pl-2">
|
<div className="flex-grow truncate pl-2">
|
||||||
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
|
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
|
||||||
<Link href={"/apps/" + item.integration.slug}>
|
<Link href={`/apps/${item.integration.slug}`}>
|
||||||
{item.integration.name || item.integration.title}
|
{item.integration.name || item.integration.title}
|
||||||
</Link>
|
</Link>
|
||||||
{data?.destinationCalendar?.credentialId === item.credentialId && (
|
{data?.destinationCalendar?.credentialId === item.credentialId && (
|
||||||
@@ -207,6 +238,7 @@ const CalendarsView = () => {
|
|||||||
description={t("no_calendar_installed_description")}
|
description={t("no_calendar_installed_description")}
|
||||||
buttonText={t("add_a_calendar")}
|
buttonText={t("add_a_calendar")}
|
||||||
buttonOnClick={() => router.push("/apps/categories/calendar")}
|
buttonOnClick={() => router.push("/apps/categories/calendar")}
|
||||||
|
className="mt-6"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,39 +1,22 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useReducer } from "react";
|
||||||
|
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
|
||||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
|
||||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
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 {
|
import { Button, EmptyScreen, Meta, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
Button,
|
import { Calendar, Plus } from "@calcom/ui/components/icon";
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
Dropdown,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
List,
|
|
||||||
Meta,
|
|
||||||
showToast,
|
|
||||||
SkeletonContainer,
|
|
||||||
SkeletonText,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
import { AlertCircle, MoreHorizontal, Trash, Video, Plus } from "@calcom/ui/components/icon";
|
|
||||||
|
|
||||||
import AppListCard from "@components/AppListCard";
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
import { AppList } from "@components/apps/AppList";
|
||||||
|
|
||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="divide-subtle mb-8 mt-6 space-y-6">
|
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
</div>
|
</div>
|
||||||
@@ -45,162 +28,93 @@ const AddConferencingButton = () => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
|
||||||
<Button color="secondary" StartIcon={Plus} href="/apps/categories/video">
|
{t("add")}
|
||||||
{t("add_conferencing_app")}
|
</Button>
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ModalState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
credentialId: null | number;
|
||||||
|
};
|
||||||
|
|
||||||
const ConferencingLayout = () => {
|
const ConferencingLayout = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
|
||||||
|
|
||||||
const { data: defaultConferencingApp, isLoading: defaultConferencingAppLoading } =
|
const [modal, updateModal] = useReducer(
|
||||||
trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
|
||||||
|
{
|
||||||
|
isOpen: false,
|
||||||
|
credentialId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery({
|
const query = trpc.viewer.integrations.useQuery({
|
||||||
variant: "conferencing",
|
variant: "conferencing",
|
||||||
onlyInstalled: true,
|
onlyInstalled: true,
|
||||||
});
|
});
|
||||||
const deleteAppMutation = trpc.viewer.deleteCredential.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
showToast("Integration deleted successfully", "success");
|
|
||||||
utils.viewer.integrations.invalidate({ variant: "conferencing", onlyInstalled: true });
|
|
||||||
setDeleteAppModal(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showToast("Error deleting app", "error");
|
|
||||||
setDeleteAppModal(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSuccessCallback = useCallback(() => {
|
const handleModelClose = () => {
|
||||||
setBulkUpdateModal(true);
|
updateModal({ isOpen: false, credentialId: null });
|
||||||
showToast("Default app updated successfully", "success");
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
const handleDisconnect = (credentialId: number) => {
|
||||||
onSuccess: async () => {
|
updateModal({ isOpen: true, credentialId });
|
||||||
await utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
};
|
||||||
onSuccessCallback();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
showToast(`Error: ${error.message}`, "error");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [deleteAppModal, setDeleteAppModal] = useState(false);
|
|
||||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
|
||||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [deleteCredentialId, setDeleteCredentialId] = useState<number>(0);
|
|
||||||
|
|
||||||
if (isLoading || defaultConferencingAppLoading)
|
|
||||||
return <SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
<>
|
||||||
<Meta
|
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||||
title={t("conferencing")}
|
<Meta
|
||||||
description={t("conferencing_description")}
|
title={t("conferencing")}
|
||||||
CTA={<AddConferencingButton />}
|
description={t("conferencing_description")}
|
||||||
/>
|
CTA={<AddConferencingButton />}
|
||||||
<List>
|
borderInShellHeader={true}
|
||||||
{apps?.items &&
|
/>
|
||||||
apps.items
|
<QueryCell
|
||||||
.map((app) => ({ ...app, title: app.title || app.name }))
|
query={query}
|
||||||
.map((app) => {
|
customLoader={
|
||||||
const appSlug = app?.slug;
|
<SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />
|
||||||
const appIsDefault =
|
}
|
||||||
appSlug === defaultConferencingApp?.appSlug ||
|
success={({ data }) => {
|
||||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug); // Default to cal video if the user doesnt have it set (we do this on new account creation but not old)
|
console.log(data);
|
||||||
|
if (!data.items.length) {
|
||||||
return (
|
return (
|
||||||
<AppListCard
|
<EmptyScreen
|
||||||
description={app.description}
|
Icon={Calendar}
|
||||||
title={app.title}
|
headline={t("no_category_apps", {
|
||||||
logo={app.logo}
|
category: t("conferencing").toLowerCase(),
|
||||||
key={app.title}
|
})}
|
||||||
isDefault={appIsDefault} // @TODO: Handle when a user doesnt have this value set
|
description={t("no_category_apps_description_conferencing")}
|
||||||
actions={
|
buttonRaw={
|
||||||
<div>
|
<Button
|
||||||
<Dropdown>
|
color="secondary"
|
||||||
<DropdownMenuTrigger asChild>
|
data-testid="connect-conferencing-apps"
|
||||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
href="/apps/categories/conferencing">
|
||||||
</DropdownMenuTrigger>
|
{t("connect_conference_apps")}
|
||||||
<DropdownMenuContent>
|
</Button>
|
||||||
{!appIsDefault && (
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<DropdownItem
|
|
||||||
type="button"
|
|
||||||
color="secondary"
|
|
||||||
StartIcon={Video}
|
|
||||||
onClick={() => {
|
|
||||||
const locationType = getEventLocationTypeFromApp(
|
|
||||||
app?.locationOption?.value ?? ""
|
|
||||||
);
|
|
||||||
if (locationType?.linkType === "static") {
|
|
||||||
setLocationType({ ...locationType, slug: appSlug });
|
|
||||||
} else {
|
|
||||||
updateDefaultAppMutation.mutate({
|
|
||||||
appSlug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{t("set_as_default")}
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<DropdownItem
|
|
||||||
type="button"
|
|
||||||
color="destructive"
|
|
||||||
disabled={app.isGlobal}
|
|
||||||
StartIcon={Trash}
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteCredentialId(app.userCredentialIds[0]);
|
|
||||||
setDeleteAppModal(true);
|
|
||||||
}}>
|
|
||||||
{t("remove_app")}
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</List>
|
return (
|
||||||
|
<AppList
|
||||||
<Dialog open={deleteAppModal} onOpenChange={setDeleteAppModal}>
|
listClassName="rounded-xl rounded-t-none border-t-0"
|
||||||
<DialogContent
|
handleDisconnect={handleDisconnect}
|
||||||
title={t("Remove app")}
|
data={data}
|
||||||
description={t("are_you_sure_you_want_to_remove_this_app")}
|
variant="conferencing"
|
||||||
type="confirmation"
|
/>
|
||||||
Icon={AlertCircle}>
|
);
|
||||||
<DialogFooter>
|
}}
|
||||||
<Button color="primary" onClick={() => deleteAppMutation.mutate({ id: deleteCredentialId })}>
|
|
||||||
{t("yes_remove_app")}
|
|
||||||
</Button>
|
|
||||||
<DialogClose />
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{locationType && (
|
|
||||||
<AppSetDefaultLinkDialog
|
|
||||||
locationType={locationType}
|
|
||||||
setLocationType={setLocationType}
|
|
||||||
onSuccess={onSuccessCallback}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
{bulkUpdateModal && (
|
<DisconnectIntegrationModal
|
||||||
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
handleModelClose={handleModelClose}
|
||||||
)}
|
isOpen={modal.isOpen}
|
||||||
</div>
|
credentialId={modal.credentialId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { localeOptions } from "@calcom/lib/i18n";
|
import { localeOptions } from "@calcom/lib/i18n";
|
||||||
@@ -13,12 +15,12 @@ import {
|
|||||||
Label,
|
Label,
|
||||||
Meta,
|
Meta,
|
||||||
Select,
|
Select,
|
||||||
SettingsToggle,
|
|
||||||
showToast,
|
showToast,
|
||||||
SkeletonButton,
|
SkeletonButton,
|
||||||
SkeletonContainer,
|
SkeletonContainer,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
TimezoneSelect,
|
TimezoneSelect,
|
||||||
|
SettingsToggle,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
@@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="mb-8 mt-6 space-y-6">
|
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
|
||||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
|
||||||
</div>
|
</div>
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
);
|
);
|
||||||
@@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { update } = useSession();
|
const { update } = useSession();
|
||||||
|
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
@@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.viewer.me.invalidate();
|
await utils.viewer.me.invalidate();
|
||||||
|
setIsUpdateBtnLoading(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,9 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
value: user.weekStart,
|
value: user.weekStart,
|
||||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||||
},
|
},
|
||||||
allowDynamicBooking: user.allowDynamicBooking ?? true,
|
|
||||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
|
||||||
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail ?? true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
@@ -117,151 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
} = formMethods;
|
} = formMethods;
|
||||||
const isDisabled = isSubmitting || !isDirty;
|
const isDisabled = isSubmitting || !isDirty;
|
||||||
|
|
||||||
|
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState(
|
||||||
|
!!user.allowDynamicBooking
|
||||||
|
);
|
||||||
|
const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing);
|
||||||
|
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
|
||||||
|
!!user.receiveMonthlyDigestEmail
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<div>
|
||||||
form={formMethods}
|
<Form
|
||||||
handleSubmit={(values) => {
|
form={formMethods}
|
||||||
mutation.mutate({
|
handleSubmit={(values) => {
|
||||||
...values,
|
setIsUpdateBtnLoading(true);
|
||||||
locale: values.locale.value,
|
mutation.mutate({
|
||||||
timeFormat: values.timeFormat.value,
|
...values,
|
||||||
weekStart: values.weekStart.value,
|
locale: values.locale.value,
|
||||||
});
|
timeFormat: values.timeFormat.value,
|
||||||
}}>
|
weekStart: values.weekStart.value,
|
||||||
<Meta title={t("general")} description={t("general_description")} />
|
});
|
||||||
<Controller
|
}}>
|
||||||
name="locale"
|
<Meta title={t("general")} description={t("general_description")} borderInShellHeader={true} />
|
||||||
render={({ field: { value, onChange } }) => (
|
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
|
||||||
<>
|
<Controller
|
||||||
<Label className="text-emphasis">
|
name="locale"
|
||||||
<>{t("language")}</>
|
render={({ field: { value, onChange } }) => (
|
||||||
</Label>
|
<>
|
||||||
<Select<{ label: string; value: string }>
|
<Label className="text-emphasis">
|
||||||
className="capitalize"
|
<>{t("language")}</>
|
||||||
options={localeOptions}
|
</Label>
|
||||||
value={value}
|
<Select<{ label: string; value: string }>
|
||||||
onChange={onChange}
|
className="capitalize"
|
||||||
/>
|
options={localeOptions}
|
||||||
</>
|
value={value}
|
||||||
)}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
<Controller
|
</>
|
||||||
name="timeZone"
|
)}
|
||||||
control={formMethods.control}
|
/>
|
||||||
render={({ field: { value } }) => (
|
<Controller
|
||||||
<>
|
name="timeZone"
|
||||||
<Label className="text-emphasis mt-8">
|
control={formMethods.control}
|
||||||
<>{t("timezone")}</>
|
render={({ field: { value } }) => (
|
||||||
</Label>
|
<>
|
||||||
<TimezoneSelect
|
<Label className="text-emphasis mt-6">
|
||||||
id="timezone"
|
<>{t("timezone")}</>
|
||||||
value={value}
|
</Label>
|
||||||
onChange={(event) => {
|
<TimezoneSelect
|
||||||
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
id="timezone"
|
||||||
}}
|
value={value}
|
||||||
/>
|
onChange={(event) => {
|
||||||
</>
|
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
<Controller
|
</>
|
||||||
name="timeFormat"
|
)}
|
||||||
control={formMethods.control}
|
/>
|
||||||
render={({ field: { value } }) => (
|
<Controller
|
||||||
<>
|
name="timeFormat"
|
||||||
<Label className="text-emphasis mt-8">
|
control={formMethods.control}
|
||||||
<>{t("time_format")}</>
|
render={({ field: { value } }) => (
|
||||||
</Label>
|
<>
|
||||||
<Select
|
<Label className="text-emphasis mt-6">
|
||||||
value={value}
|
<>{t("time_format")}</>
|
||||||
options={timeFormatOptions}
|
</Label>
|
||||||
onChange={(event) => {
|
<Select
|
||||||
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
|
value={value}
|
||||||
}}
|
options={timeFormatOptions}
|
||||||
/>
|
onChange={(event) => {
|
||||||
</>
|
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
</>
|
||||||
{t("timeformat_profile_hint")}
|
)}
|
||||||
</div>
|
/>
|
||||||
<Controller
|
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||||
name="weekStart"
|
{t("timeformat_profile_hint")}
|
||||||
control={formMethods.control}
|
</div>
|
||||||
render={({ field: { value } }) => (
|
<Controller
|
||||||
<>
|
name="weekStart"
|
||||||
<Label className="text-emphasis mt-8">
|
control={formMethods.control}
|
||||||
<>{t("start_of_week")}</>
|
render={({ field: { value } }) => (
|
||||||
</Label>
|
<>
|
||||||
<Select
|
<Label className="text-emphasis mt-6">
|
||||||
value={value}
|
<>{t("start_of_week")}</>
|
||||||
options={weekStartOptions}
|
</Label>
|
||||||
onChange={(event) => {
|
<Select
|
||||||
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
value={value}
|
||||||
}}
|
options={weekStartOptions}
|
||||||
/>
|
onChange={(event) => {
|
||||||
</>
|
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mt-8">
|
</>
|
||||||
<Controller
|
)}
|
||||||
name="allowDynamicBooking"
|
/>
|
||||||
control={formMethods.control}
|
</div>
|
||||||
render={() => (
|
|
||||||
<SettingsToggle
|
|
||||||
title={t("dynamic_booking")}
|
|
||||||
description={t("allow_dynamic_booking")}
|
|
||||||
checked={formMethods.getValues("allowDynamicBooking")}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
<SectionBottomActions align="end">
|
||||||
<Controller
|
<Button loading={isUpdateBtnLoading} disabled={isDisabled} color="primary" type="submit">
|
||||||
name="allowSEOIndexing"
|
<>{t("update")}</>
|
||||||
control={formMethods.control}
|
</Button>
|
||||||
render={() => (
|
</SectionBottomActions>
|
||||||
<SettingsToggle
|
</Form>
|
||||||
title={t("seo_indexing")}
|
|
||||||
description={t("allow_seo_indexing")}
|
|
||||||
checked={formMethods.getValues("allowSEOIndexing")}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
<SettingsToggle
|
||||||
<Controller
|
toggleSwitchAtTheEnd={true}
|
||||||
name="receiveMonthlyDigestEmail"
|
title={t("dynamic_booking")}
|
||||||
control={formMethods.control}
|
description={t("allow_dynamic_booking")}
|
||||||
render={() => (
|
disabled={mutation.isLoading}
|
||||||
<SettingsToggle
|
checked={isAllowDynamicBookingChecked}
|
||||||
title={t("monthly_digest_email")}
|
onCheckedChange={(checked) => {
|
||||||
description={t("monthly_digest_email_for_teams")}
|
setIsAllowDynamicBookingChecked(checked);
|
||||||
checked={formMethods.getValues("receiveMonthlyDigestEmail")}
|
mutation.mutate({ allowDynamicBooking: checked });
|
||||||
onCheckedChange={(checked) => {
|
}}
|
||||||
formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true });
|
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<SettingsToggle
|
||||||
loading={mutation.isLoading}
|
toggleSwitchAtTheEnd={true}
|
||||||
disabled={isDisabled}
|
title={t("seo_indexing")}
|
||||||
color="primary"
|
description={t("allow_seo_indexing")}
|
||||||
type="submit"
|
disabled={mutation.isLoading}
|
||||||
className="mt-8">
|
checked={isAllowSEOIndexingChecked}
|
||||||
<>{t("update")}</>
|
onCheckedChange={(checked) => {
|
||||||
</Button>
|
setIsAllowSEOIndexingChecked(checked);
|
||||||
</Form>
|
mutation.mutate({ allowSEOIndexing: checked });
|
||||||
|
}}
|
||||||
|
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
title={t("monthly_digest_email")}
|
||||||
|
description={t("monthly_digest_email_for_teams")}
|
||||||
|
disabled={mutation.isLoading}
|
||||||
|
checked={isReceiveMonthlyDigestEmailChecked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setIsReceiveMonthlyDigestEmailChecked(checked);
|
||||||
|
mutation.mutate({ receiveMonthlyDigestEmail: checked });
|
||||||
|
}}
|
||||||
|
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||||
|
import { AVATAR_FALLBACK } 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 turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
@@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
|||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="mb-8 space-y-6">
|
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||||
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
||||||
@@ -69,18 +71,37 @@ interface DeleteAccountValues {
|
|||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
|
||||||
|
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||||
|
};
|
||||||
|
|
||||||
const ProfileView = () => {
|
const ProfileView = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { update } = useSession();
|
const { update } = useSession();
|
||||||
|
|
||||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
|
||||||
|
onSuccess: async (userData) => {
|
||||||
|
try {
|
||||||
|
if (!userData.organization) {
|
||||||
|
const res = await fetch(userData.avatar);
|
||||||
|
if (res.url) setFetchedImgSrc(res.url);
|
||||||
|
} else {
|
||||||
|
setFetchedImgSrc("");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setFetchedImgSrc("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
await update(res);
|
await update(res);
|
||||||
@@ -204,7 +225,7 @@ const ProfileView = () => {
|
|||||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || !user)
|
if (isLoading || !user || fetchedImgSrc === undefined)
|
||||||
return (
|
return (
|
||||||
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||||
);
|
);
|
||||||
@@ -219,11 +240,17 @@ const ProfileView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
<Meta
|
||||||
|
title={t("profile")}
|
||||||
|
description={t("profile_description", { appName: APP_NAME })}
|
||||||
|
borderInShellHeader={true}
|
||||||
|
/>
|
||||||
<ProfileForm
|
<ProfileForm
|
||||||
key={JSON.stringify(defaultValues)}
|
key={JSON.stringify(defaultValues)}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
isLoading={updateProfileMutation.isLoading}
|
isLoading={updateProfileMutation.isLoading}
|
||||||
|
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
|
||||||
|
userAvatar={user.avatar}
|
||||||
userOrganization={user.organization}
|
userOrganization={user.organization}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
if (values.email !== user.email && isCALIdentityProvider) {
|
if (values.email !== user.email && isCALIdentityProvider) {
|
||||||
@@ -238,7 +265,7 @@ const ProfileView = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
extraField={
|
extraField={
|
||||||
<div className="mt-8">
|
<div className="mt-6">
|
||||||
<UsernameAvailabilityField
|
<UsernameAvailabilityField
|
||||||
onSuccessMutation={async () => {
|
onSuccessMutation={async () => {
|
||||||
showToast(t("settings_updated_successfully"), "success");
|
showToast(t("settings_updated_successfully"), "success");
|
||||||
@@ -252,16 +279,19 @@ const ProfileView = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<hr className="border-subtle my-6" />
|
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
|
||||||
|
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||||
<Label>{t("danger_zone")}</Label>
|
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||||
|
</div>
|
||||||
{/* Delete account Dialog */}
|
{/* Delete account Dialog */}
|
||||||
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||||
<DialogTrigger asChild>
|
<SectionBottomActions align="end">
|
||||||
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
<DialogTrigger asChild>
|
||||||
{t("delete_account")}
|
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
||||||
</Button>
|
{t("delete_account")}
|
||||||
</DialogTrigger>
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</SectionBottomActions>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
title={t("delete_account_modal_title")}
|
title={t("delete_account_modal_title")}
|
||||||
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
|
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
|
||||||
@@ -269,9 +299,7 @@ const ProfileView = () => {
|
|||||||
Icon={AlertTriangle}>
|
Icon={AlertTriangle}>
|
||||||
<>
|
<>
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
<p className="text-default mb-4">
|
<p className="text-default mb-4">{t("delete_account_confirmation_message")}</p>
|
||||||
{t("delete_account_confirmation_message", { appName: APP_NAME })}
|
|
||||||
</p>
|
|
||||||
{isCALIdentityProvider && (
|
{isCALIdentityProvider && (
|
||||||
<PasswordField
|
<PasswordField
|
||||||
data-testid="password"
|
data-testid="password"
|
||||||
@@ -366,12 +394,16 @@ const ProfileForm = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
extraField,
|
extraField,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isFallbackImg,
|
||||||
|
userAvatar,
|
||||||
userOrganization,
|
userOrganization,
|
||||||
}: {
|
}: {
|
||||||
defaultValues: FormValues;
|
defaultValues: FormValues;
|
||||||
onSubmit: (values: FormValues) => void;
|
onSubmit: (values: FormValues) => void;
|
||||||
extraField?: React.ReactNode;
|
extraField?: React.ReactNode;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isFallbackImg: boolean;
|
||||||
|
userAvatar: string;
|
||||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -379,7 +411,7 @@ const ProfileForm = ({
|
|||||||
|
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
avatar: z.string(),
|
avatar: z.string().nullable(),
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -404,56 +436,77 @@ const ProfileForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form form={formMethods} handleSubmit={onSubmit}>
|
<Form form={formMethods} handleSubmit={onSubmit}>
|
||||||
<div className="flex items-center">
|
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
|
||||||
<Controller
|
<div className="flex items-center">
|
||||||
control={formMethods.control}
|
<Controller
|
||||||
name="avatar"
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
name="avatar"
|
||||||
<>
|
render={({ field: { value } }) => {
|
||||||
<OrganizationAvatar
|
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
|
||||||
alt={formMethods.getValues("username")}
|
return (
|
||||||
imageSrc={value}
|
<>
|
||||||
size="lg"
|
<OrganizationAvatar
|
||||||
organizationSlug={userOrganization.slug}
|
alt={formMethods.getValues("username")}
|
||||||
/>
|
imageSrc={value}
|
||||||
<div className="ms-4">
|
size="lg"
|
||||||
<ImageUploader
|
organizationSlug={userOrganization.slug}
|
||||||
target="avatar"
|
/>
|
||||||
id="avatar-upload"
|
<div className="ms-4">
|
||||||
buttonMsg={t("change_avatar")}
|
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
|
||||||
handleAvatarChange={(newAvatar) => {
|
<div className="flex gap-2">
|
||||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
<ImageUploader
|
||||||
}}
|
target="avatar"
|
||||||
imageSrc={value || undefined}
|
id="avatar-upload"
|
||||||
/>
|
buttonMsg={t("upload_avatar")}
|
||||||
</div>
|
handleAvatarChange={(newAvatar) => {
|
||||||
</>
|
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||||
)}
|
}}
|
||||||
/>
|
imageSrc={value || undefined}
|
||||||
|
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showRemoveAvatarButton && (
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
formMethods.setValue("avatar", null, { shouldDirty: true });
|
||||||
|
}}>
|
||||||
|
{t("remove")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{extraField}
|
||||||
|
<div className="mt-6">
|
||||||
|
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Label>{t("about")}</Label>
|
||||||
|
<Editor
|
||||||
|
getText={() => md.render(formMethods.getValues("bio") || "")}
|
||||||
|
setText={(value: string) => {
|
||||||
|
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
excludedToolbarItems={["blockType"]}
|
||||||
|
disableLists
|
||||||
|
firstRender={firstRender}
|
||||||
|
setFirstRender={setFirstRender}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{extraField}
|
<SectionBottomActions align="end">
|
||||||
<div className="mt-8">
|
<Button loading={isLoading} disabled={isDisabled} color="primary" type="submit">
|
||||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
{t("update")}
|
||||||
</div>
|
</Button>
|
||||||
<div className="mt-8">
|
</SectionBottomActions>
|
||||||
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
|
||||||
<Label>{t("about")}</Label>
|
|
||||||
<Editor
|
|
||||||
getText={() => md.render(formMethods.getValues("bio") || "")}
|
|
||||||
setText={(value: string) => {
|
|
||||||
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
excludedToolbarItems={["blockType"]}
|
|
||||||
disableLists
|
|
||||||
firstRender={firstRender}
|
|
||||||
setFirstRender={setFirstRender}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button loading={isLoading} disabled={isDisabled} color="primary" className="mt-8" type="submit">
|
|
||||||
{t("update")}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
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 { Button, Form, Label, Meta, showToast, Skeleton, Switch } from "@calcom/ui";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
|
import { Meta, showToast, SettingsToggle, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
|
return (
|
||||||
|
<SkeletonContainer>
|
||||||
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
|
<div className="border-subtle space-y-6 border border-t-0 px-4 py-8 sm:px-6">
|
||||||
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
</SkeletonContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ProfileImpersonationView = () => {
|
const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"] }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { data: user } = trpc.viewer.me.useQuery();
|
const [disableImpersonation, setDisableImpersonation] = useState<boolean | undefined>(
|
||||||
|
user?.disableImpersonation
|
||||||
|
);
|
||||||
|
|
||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showToast(t("profile_updated_successfully"), "success");
|
showToast(t("profile_updated_successfully"), "success");
|
||||||
reset(getValues());
|
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
utils.viewer.me.invalidate();
|
utils.viewer.me.invalidate();
|
||||||
@@ -26,83 +37,54 @@ const ProfileImpersonationView = () => {
|
|||||||
await utils.viewer.me.cancel();
|
await utils.viewer.me.cancel();
|
||||||
const previousValue = utils.viewer.me.getData();
|
const previousValue = utils.viewer.me.getData();
|
||||||
|
|
||||||
if (previousValue && disableImpersonation) {
|
setDisableImpersonation(disableImpersonation);
|
||||||
utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation });
|
|
||||||
}
|
|
||||||
return { previousValue };
|
return { previousValue };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (error, variables, context) => {
|
||||||
if (context?.previousValue) {
|
if (context?.previousValue) {
|
||||||
utils.viewer.me.setData(undefined, context.previousValue);
|
utils.viewer.me.setData(undefined, context.previousValue);
|
||||||
|
setDisableImpersonation(context.previousValue?.disableImpersonation);
|
||||||
}
|
}
|
||||||
showToast(`${t("error")}, ${error.message}`, "error");
|
showToast(`${t("error")}, ${error.message}`, "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const formMethods = useForm<{ disableImpersonation: boolean }>({
|
|
||||||
defaultValues: {
|
|
||||||
disableImpersonation: user?.disableImpersonation,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
formState: { isSubmitting, isDirty },
|
|
||||||
setValue,
|
|
||||||
reset,
|
|
||||||
getValues,
|
|
||||||
watch,
|
|
||||||
} = formMethods;
|
|
||||||
|
|
||||||
const isDisabled = isSubmitting || !isDirty;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("impersonation")} description={t("impersonation_description")} />
|
<Meta
|
||||||
<Form
|
title={t("impersonation")}
|
||||||
form={formMethods}
|
description={t("impersonation_description")}
|
||||||
handleSubmit={({ disableImpersonation }) => {
|
borderInShellHeader={true}
|
||||||
mutation.mutate({ disableImpersonation });
|
/>
|
||||||
}}>
|
<div>
|
||||||
<div className="flex space-x-3">
|
<SettingsToggle
|
||||||
<Switch
|
toggleSwitchAtTheEnd={true}
|
||||||
onCheckedChange={(e) => {
|
title={t("user_impersonation_heading")}
|
||||||
setValue("disableImpersonation", !e, { shouldDirty: true });
|
description={t("user_impersonation_description")}
|
||||||
}}
|
checked={!disableImpersonation}
|
||||||
fitToHeight={true}
|
onCheckedChange={(checked) => {
|
||||||
checked={!watch("disableImpersonation")}
|
mutation.mutate({ disableImpersonation: !checked });
|
||||||
/>
|
}}
|
||||||
<div className="flex flex-col">
|
disabled={mutation.isLoading}
|
||||||
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
|
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
|
||||||
{t("user_impersonation_heading")}
|
/>
|
||||||
</Skeleton>
|
</div>
|
||||||
<Skeleton as="p" className="text-default -mt-2 text-sm leading-normal">
|
|
||||||
{t("user_impersonation_description")}
|
|
||||||
</Skeleton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
loading={mutation.isLoading}
|
|
||||||
className="mt-8"
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled}>
|
|
||||||
{t("update")}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProfileImpersonationView.getLayout = getLayout;
|
const ProfileImpersonationViewWrapper = () => {
|
||||||
ProfileImpersonationView.PageWrapper = PageWrapper;
|
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
if (isLoading || !user)
|
||||||
const ssr = await ssrInit(context);
|
return <SkeletonLoader title={t("impersonation")} description={t("impersonation_description")} />;
|
||||||
await ssr.viewer.me.prefetch();
|
|
||||||
return {
|
return <ProfileImpersonationView user={user} />;
|
||||||
props: {
|
|
||||||
trpcState: ssr.dehydrate(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfileImpersonationView;
|
ProfileImpersonationViewWrapper.getLayout = getLayout;
|
||||||
|
ProfileImpersonationViewWrapper.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
|
export default ProfileImpersonationViewWrapper;
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
|
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui";
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Meta,
|
||||||
|
PasswordField,
|
||||||
|
Select,
|
||||||
|
SettingsToggle,
|
||||||
|
showToast,
|
||||||
|
SkeletonButton,
|
||||||
|
SkeletonContainer,
|
||||||
|
SkeletonText,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
@@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = {
|
|||||||
apiError: string;
|
apiError: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PasswordView = () => {
|
interface PasswordViewProps {
|
||||||
|
user: RouterOutputs["viewer"]["me"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
|
return (
|
||||||
|
<SkeletonContainer>
|
||||||
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
|
<div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6">
|
||||||
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-b-xl">
|
||||||
|
<SectionBottomActions align="end">
|
||||||
|
<SkeletonButton className="ml-auto h-8 w-20 rounded-md" />
|
||||||
|
</SectionBottomActions>
|
||||||
|
</div>
|
||||||
|
</SkeletonContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PasswordView = ({ user }: PasswordViewProps) => {
|
||||||
const { data } = useSession();
|
const { data } = useSession();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { data: user } = trpc.viewer.me.useQuery();
|
const metadata = userMetadataSchema.safeParse(user?.metadata);
|
||||||
const metadata = userMetadata.safeParse(user?.metadata);
|
const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
||||||
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
|
||||||
|
const [sessionTimeout, setSessionTimeout] = useState<number | undefined>(initialSessionTimeout);
|
||||||
|
|
||||||
const sessionMutation = trpc.viewer.updateProfile.useMutation({
|
const sessionMutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
showToast(t("session_timeout_changed"), "success");
|
showToast(t("session_timeout_changed"), "success");
|
||||||
formMethods.reset(formMethods.getValues());
|
formMethods.reset(formMethods.getValues());
|
||||||
|
setSessionTimeout(data.metadata?.sessionTimeout);
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
utils.viewer.me.invalidate();
|
utils.viewer.me.invalidate();
|
||||||
},
|
},
|
||||||
onMutate: async () => {
|
onMutate: async () => {
|
||||||
await utils.viewer.me.cancel();
|
await utils.viewer.me.cancel();
|
||||||
const previousValue = utils.viewer.me.getData();
|
const previousValue = await utils.viewer.me.getData();
|
||||||
const previousMetadata = userMetadata.parse(previousValue?.metadata);
|
const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata);
|
||||||
|
|
||||||
if (previousValue && sessionTimeout) {
|
if (previousValue && sessionTimeout && previousMetadata.success) {
|
||||||
utils.viewer.me.setData(undefined, {
|
utils.viewer.me.setData(undefined, {
|
||||||
...previousValue,
|
...previousValue,
|
||||||
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
|
metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout },
|
||||||
});
|
});
|
||||||
|
return { previousValue };
|
||||||
}
|
}
|
||||||
return { previousValue };
|
|
||||||
},
|
},
|
||||||
onError: (error, _, context) => {
|
onError: (error, _, context) => {
|
||||||
if (context?.previousValue) {
|
if (context?.previousValue) {
|
||||||
@@ -84,20 +124,30 @@ const PasswordView = () => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
oldPassword: "",
|
oldPassword: "",
|
||||||
newPassword: "",
|
newPassword: "",
|
||||||
sessionTimeout,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
|
|
||||||
|
|
||||||
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
|
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
|
||||||
const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
|
const { oldPassword, newPassword } = values;
|
||||||
|
|
||||||
|
if (!oldPassword.length) {
|
||||||
|
formMethods.setError(
|
||||||
|
"oldPassword",
|
||||||
|
{ type: "required", message: t("error_required_field") },
|
||||||
|
{ shouldFocus: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!newPassword.length) {
|
||||||
|
formMethods.setError(
|
||||||
|
"newPassword",
|
||||||
|
{ type: "required", message: t("error_required_field") },
|
||||||
|
{ shouldFocus: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (oldPassword && newPassword) {
|
if (oldPassword && newPassword) {
|
||||||
passwordMutation.mutate({ oldPassword, newPassword });
|
passwordMutation.mutate({ oldPassword, newPassword });
|
||||||
}
|
}
|
||||||
if (sessionTimeout !== newSessionTimeout) {
|
|
||||||
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeoutOptions = [5, 10, 15].map((mins) => ({
|
const timeoutOptions = [5, 10, 15].map((mins) => ({
|
||||||
@@ -112,7 +162,7 @@ const PasswordView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("password")} description={t("password_description")} />
|
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -130,87 +180,127 @@ const PasswordView = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||||
{formMethods.formState.errors.apiError && (
|
<div className="border-x px-4 py-6 sm:px-6">
|
||||||
<div className="pb-6">
|
{formMethods.formState.errors.apiError && (
|
||||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
<div className="pb-6">
|
||||||
</div>
|
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<div className="max-w-[38rem] sm:grid sm:grid-cols-2 sm:gap-x-4">
|
<div className="w-full sm:grid sm:grid-cols-2 sm:gap-x-6">
|
||||||
<div>
|
<div>
|
||||||
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
{...formMethods.register("newPassword", {
|
{...formMethods.register("newPassword", {
|
||||||
minLength: {
|
minLength: {
|
||||||
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
||||||
value: passwordMinLength,
|
value: passwordMinLength,
|
||||||
},
|
},
|
||||||
pattern: {
|
pattern: {
|
||||||
message: "Should contain a number, uppercase and lowercase letters",
|
message: "Should contain a number, uppercase and lowercase letters",
|
||||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
label={t("new_password")}
|
label={t("new_password")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-default mt-4 w-full text-sm">
|
||||||
|
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-default mt-4 max-w-[38rem] text-sm">
|
<SectionBottomActions align="end">
|
||||||
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
<Button
|
||||||
</p>
|
color="primary"
|
||||||
<div className="border-subtle mt-8 border-t py-8">
|
type="submit"
|
||||||
|
loading={passwordMutation.isLoading}
|
||||||
|
onClick={() => formMethods.clearErrors("apiError")}
|
||||||
|
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
|
||||||
|
{t("update")}
|
||||||
|
</Button>
|
||||||
|
</SectionBottomActions>
|
||||||
|
<div className="mt-6">
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
title={t("session_timeout")}
|
title={t("session_timeout")}
|
||||||
description={t("session_timeout_description")}
|
description={t("session_timeout_description")}
|
||||||
checked={sessionTimeoutWatch !== undefined}
|
checked={sessionTimeout !== undefined}
|
||||||
data-testid="session-check"
|
data-testid="session-check"
|
||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true });
|
setSessionTimeout(undefined);
|
||||||
|
|
||||||
|
if (metadata.success) {
|
||||||
|
sessionMutation.mutate({
|
||||||
|
metadata: { ...metadata.data, sessionTimeout: undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
formMethods.setValue("sessionTimeout", 10, { shouldDirty: true });
|
setSessionTimeout(10);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
childrenClassName="lg:ml-0"
|
||||||
{sessionTimeoutWatch && (
|
switchContainerClassName={classNames(
|
||||||
<div className="mt-4 text-sm">
|
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||||
<div className="flex items-center">
|
!!sessionTimeout && "rounded-b-none"
|
||||||
<p className="text-default ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
|
)}>
|
||||||
<Select
|
<>
|
||||||
options={timeoutOptions}
|
<div className="border-subtle border-x p-6 pb-8">
|
||||||
defaultValue={
|
<div className="flex flex-col">
|
||||||
sessionTimeout
|
<p className="text-default mb-2 font-medium">{t("session_timeout_after")}</p>
|
||||||
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
<Select
|
||||||
: timeoutOptions[1]
|
options={timeoutOptions}
|
||||||
}
|
defaultValue={
|
||||||
isSearchable={false}
|
sessionTimeout
|
||||||
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
|
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
||||||
onChange={(event) => {
|
: timeoutOptions[1]
|
||||||
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
|
}
|
||||||
}}
|
isSearchable={false}
|
||||||
/>
|
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
|
||||||
|
onChange={(event) => {
|
||||||
|
setSessionTimeout(event?.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<SectionBottomActions align="end">
|
||||||
)}
|
<Button
|
||||||
|
color="primary"
|
||||||
|
loading={sessionMutation.isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
sessionMutation.mutate({
|
||||||
|
metadata: { ...metadata, sessionTimeout },
|
||||||
|
});
|
||||||
|
formMethods.clearErrors("apiError");
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
initialSessionTimeout === sessionTimeout ||
|
||||||
|
passwordMutation.isLoading ||
|
||||||
|
sessionMutation.isLoading
|
||||||
|
}>
|
||||||
|
{t("update")}
|
||||||
|
</Button>
|
||||||
|
</SectionBottomActions>
|
||||||
|
</>
|
||||||
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
{/* TODO: Why is this Form not submitting? Hacky fix but works */}
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
className="mt-8"
|
|
||||||
type="submit"
|
|
||||||
loading={passwordMutation.isLoading || sessionMutation.isLoading}
|
|
||||||
onClick={() => formMethods.clearErrors("apiError")}
|
|
||||||
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
|
|
||||||
{t("update")}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PasswordView.getLayout = getLayout;
|
const PasswordViewWrapper = () => {
|
||||||
PasswordView.PageWrapper = PageWrapper;
|
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||||
|
const { t } = useLocale();
|
||||||
|
if (isLoading || !user)
|
||||||
|
return <SkeletonLoader title={t("password")} description={t("password_description")} />;
|
||||||
|
|
||||||
export default PasswordView;
|
return <PasswordView user={user} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
PasswordViewWrapper.getLayout = getLayout;
|
||||||
|
PasswordViewWrapper.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
|
export default PasswordViewWrapper;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user