Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f942e1a13 | ||
|
|
38f389bce9 | ||
|
|
39c3ef0d94 | ||
|
|
0f92afb4bd | ||
|
|
6d0bcb709d | ||
|
|
ff3a9d03a4 | ||
|
|
303073822c | ||
|
|
0cfbf7159e | ||
|
|
150dc3acb9 | ||
|
|
778485b31d | ||
|
|
b4c6388ce0 | ||
|
|
f9af15175d | ||
|
|
84eda78c2c | ||
|
|
2faf24fb98 | ||
|
|
1456e2d4d5 | ||
|
|
a53ea33168 | ||
|
|
6c8c3de87c | ||
|
|
b4ab710b35 | ||
|
|
b5cf4e50a9 | ||
|
|
d8f6400add | ||
|
|
018b0e0543 | ||
|
|
a5993061f6 | ||
|
|
0b310c0ea8 | ||
|
|
da62984b5c | ||
|
|
16d73fab89 | ||
|
|
873a4e2000 | ||
|
|
d0d37ba34f | ||
|
|
76d476441f | ||
|
|
9857b46121 | ||
|
|
a35750734c | ||
|
|
5b7224e47e | ||
|
|
f3623096e4 | ||
|
|
88d438f227 | ||
|
|
8a8d284113 | ||
|
|
b4315b186a | ||
|
|
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_SECRET=""
|
||||
|
||||
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -21,10 +21,18 @@ jobs:
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1.13.0
|
||||
with:
|
||||
# upload sources
|
||||
upload_sources: true
|
||||
|
||||
# upload translations (& auto-approve 'em)
|
||||
upload_translations_args: '--auto-approve-imported'
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
push_translations: true
|
||||
|
||||
# download translations
|
||||
download_translations: true
|
||||
|
||||
# GH config
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
localization_branch_name: main
|
||||
create_pull_request: false
|
||||
|
||||
@@ -44,4 +44,5 @@ jobs:
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
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 }}
|
||||
pr-message: |-
|
||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
||||
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: |
|
||||
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) &&
|
||||
calendso_encryption_key=$(openssl rand -base64 24) &&
|
||||
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
||||
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
|
||||
-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
|
||||
|
||||
ports:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
_
|
||||
+72
-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 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
|
||||
yarn dev
|
||||
@@ -120,6 +135,16 @@ This will run and test all flows in multiple Chromium windows to verify that no
|
||||
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
|
||||
|
||||
To check the formatting of your code:
|
||||
@@ -135,4 +160,49 @@ 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.
|
||||
- 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.
|
||||
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||
|
||||
## Guidelines for committing yarn lockfile
|
||||
|
||||
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
|
||||
|
||||
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
||||
```bash
|
||||
git checkout HEAD~1 yarn.lock
|
||||
git commit -m "Revert yarn.lock changes"
|
||||
```
|
||||
If you've pushed the commit with the `yarn.lock`:
|
||||
1. Correct the commit locally using the above method.
|
||||
2. Carefully force push:
|
||||
|
||||
```bash
|
||||
git push origin <your-branch-name> --force
|
||||
```
|
||||
|
||||
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
|
||||
|
||||
1. **Checkout a Previous Version**:
|
||||
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
|
||||
```bash
|
||||
git log yarn.lock
|
||||
```
|
||||
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
|
||||
```bash
|
||||
git checkout <commit_hash> yarn.lock
|
||||
```
|
||||
|
||||
2. **Commit the Reverted Version**:
|
||||
- After checking out the previous version of the `yarn.lock`, commit this change:
|
||||
```bash
|
||||
git commit -m "Revert yarn.lock to its state before unintended changes"
|
||||
```
|
||||
|
||||
3. **Proceed with Caution**:
|
||||
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
|
||||
```bash
|
||||
git pull origin <your-branch-name>
|
||||
```
|
||||
- Then push the updated branch:
|
||||
```bash
|
||||
git push origin <your-branch-name>
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
> 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
|
||||
cd cal.com
|
||||
```
|
||||
|
||||
1. Install packages with yarn
|
||||
3. Install packages with yarn
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Set up your `.env` file
|
||||
4. Set up your `.env` file
|
||||
|
||||
- Duplicate `.env.example` to `.env`
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
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`
|
||||
|
||||
> - **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
|
||||
|
||||
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
|
||||
|
||||
```sh
|
||||
@@ -236,6 +253,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
#### Setting up your first user
|
||||
|
||||
##### Approach 1
|
||||
|
||||
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
||||
|
||||
```sh
|
||||
@@ -247,6 +266,17 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
|
||||
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
||||
|
||||
##### Approach 2
|
||||
|
||||
Seed the local db by running
|
||||
|
||||
```sh
|
||||
cd packages/prisma
|
||||
yarn db-seed
|
||||
```
|
||||
|
||||
The above command will populate the local db with dummy users.
|
||||
|
||||
### E2E-Testing
|
||||
|
||||
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
||||
@@ -259,6 +289,16 @@ yarn test-e2e
|
||||
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
|
||||
|
||||
1. Pull the current version:
|
||||
@@ -470,9 +510,8 @@ following
|
||||
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.
|
||||
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})`.
|
||||
For example, `Cal.com (support@cal.com)`.
|
||||
|
||||
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
||||
For example, `Cal.com (support@cal.com)`.
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
@@ -504,6 +543,10 @@ For example, `Cal.com (support@cal.com)`.
|
||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
||||
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
||||
|
||||
### Obtaining Zoho Calendar Client ID and Secret
|
||||
|
||||
[Follow these steps](./packages/app-store/zohocalendar/)
|
||||
|
||||
### Obtaining Zoho Bigin Client ID and Secret
|
||||
|
||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
# 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:
|
||||
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||
- 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
- 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 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))
|
||||
- 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 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))
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
|
||||
@@ -18,21 +18,21 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
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) {
|
||||
return new NextResponse("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
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
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||
to: user.email,
|
||||
from: replyTo,
|
||||
from: agentEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
|
||||
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
|
||||
import { env } from "../../../env.mjs";
|
||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||
import { extractUsers } from "../../../utils/extractUsers";
|
||||
import getHostFromHeaders from "../../../utils/host";
|
||||
import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
@@ -45,6 +46,7 @@ export const POST = async (request: NextRequest) => {
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
credentials: {
|
||||
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.
|
||||
if (!signature || !user) {
|
||||
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}`,
|
||||
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,
|
||||
from: aiEmail,
|
||||
});
|
||||
@@ -76,9 +78,9 @@ export const POST = async (request: NextRequest) => {
|
||||
const url = env.APP_URL;
|
||||
|
||||
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}`,
|
||||
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,
|
||||
from: aiEmail,
|
||||
});
|
||||
@@ -89,7 +91,7 @@ export const POST = async (request: NextRequest) => {
|
||||
const { apiKey } = credential as { apiKey: string };
|
||||
|
||||
// Pre-fetch data relevant to most bookings.
|
||||
const [eventTypes, availability] = await Promise.all([
|
||||
const [eventTypes, availability, users] = await Promise.all([
|
||||
fetchEventTypes({
|
||||
apiKey,
|
||||
}),
|
||||
@@ -99,6 +101,7 @@ export const POST = async (request: NextRequest) => {
|
||||
dateFrom: now(user.timeZone),
|
||||
dateTo: now(user.timeZone),
|
||||
}),
|
||||
extractUsers(`${parsed.text} ${parsed.subject}`),
|
||||
]);
|
||||
|
||||
if ("error" in availability) {
|
||||
@@ -138,9 +141,11 @@ export const POST = async (request: NextRequest) => {
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
username: user.username,
|
||||
timeZone: user.timeZone,
|
||||
workingHours,
|
||||
},
|
||||
users,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const env = createEnv({
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BACKEND_URL: process.env.BACKEND_URL,
|
||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
@@ -32,6 +33,7 @@ export const env = createEnv({
|
||||
*/
|
||||
server: {
|
||||
BACKEND_URL: z.string().url(),
|
||||
FRONTEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
|
||||
+25
-14
@@ -1,6 +1,8 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UserList } from "~/src/types/user";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
@@ -9,21 +11,23 @@ import { env } from "../env.mjs";
|
||||
const createBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
users,
|
||||
eventTypeId,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
language,
|
||||
responses,
|
||||
invite,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
users: UserList;
|
||||
eventTypeId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
responses: { name?: string; email?: string; location?: string };
|
||||
invite: number;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}): Promise<string | Error | { error: string }> => {
|
||||
@@ -36,6 +40,18 @@ const createBooking = async ({
|
||||
|
||||
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, {
|
||||
body: JSON.stringify({
|
||||
end,
|
||||
@@ -66,19 +82,19 @@ const createBooking = async ({
|
||||
return "Booking created";
|
||||
};
|
||||
|
||||
const createBookingTool = (apiKey: string, userId: number) => {
|
||||
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
|
||||
return new DynamicStructuredTool({
|
||||
description:
|
||||
"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, responses, title, status }) => {
|
||||
description: "Creates a booking on the primary user's calendar.",
|
||||
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
|
||||
return JSON.stringify(
|
||||
await createBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
users,
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
responses,
|
||||
invite,
|
||||
start,
|
||||
status,
|
||||
timeZone,
|
||||
@@ -86,19 +102,14 @@ const createBookingTool = (apiKey: string, userId: number) => {
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "createBookingIfAvailable",
|
||||
name: "createBooking",
|
||||
schema: z.object({
|
||||
end: z
|
||||
.string()
|
||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||
eventTypeId: z.number(),
|
||||
language: z.string(),
|
||||
responses: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.describe("External invited user. Not the user making the request."),
|
||||
invite: z.number().describe("External user id to invite."),
|
||||
start: z.string(),
|
||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||
timeZone: z.string(),
|
||||
@@ -12,13 +12,11 @@ export const fetchAvailability = async ({
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
}): Promise<Partial<Availability> | { error: string }> => {
|
||||
const params: { [k: string]: string } = {
|
||||
apiKey,
|
||||
@@ -27,8 +25,6 @@ export const fetchAvailability = async ({
|
||||
dateTo,
|
||||
};
|
||||
|
||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
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({
|
||||
description: "Get availability within range.",
|
||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
||||
description: "Get availability of users within range.",
|
||||
func: async ({ userIds, dateFrom, dateTo }) => {
|
||||
return JSON.stringify(
|
||||
await fetchAvailability({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
})
|
||||
await Promise.all(
|
||||
userIds.map(
|
||||
async (userId) =>
|
||||
await fetchAvailability({
|
||||
userId: userId,
|
||||
apiKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
name: "getAvailability",
|
||||
schema: z.object({
|
||||
userIds: z.array(z.number()).describe("The users to fetch availability for."),
|
||||
dateFrom: 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) => {
|
||||
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 }) => {
|
||||
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.
|
||||
*/
|
||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
const params = {
|
||||
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
|
||||
const params: Record<string, string> = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
params["userId"] = userId.toString();
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
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) => ({
|
||||
id: eventType.id,
|
||||
slug: eventType.slug,
|
||||
length: eventType.length,
|
||||
title: eventType.title,
|
||||
}));
|
||||
@@ -35,16 +40,19 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
|
||||
const getEventTypesTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async () => {
|
||||
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async ({ userId }) => {
|
||||
return JSON.stringify(
|
||||
await fetchEventTypes({
|
||||
apiKey,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
},
|
||||
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";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
timeZone: string;
|
||||
eventTypes: EventType[];
|
||||
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 { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
||||
import createBookingIfAvailable from "../tools/createBooking";
|
||||
import deleteBooking from "../tools/deleteBooking";
|
||||
import getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import sendBookingLink from "../tools/sendBookingLink";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
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 now from "./now";
|
||||
|
||||
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 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 = [
|
||||
createBookingIfAvailable(apiKey, userId),
|
||||
getAvailability(apiKey, userId),
|
||||
// getEventTypes(apiKey),
|
||||
getAvailability(apiKey),
|
||||
getBookings(apiKey, userId),
|
||||
createBookingIfAvailable(apiKey, userId, users),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
sendBookingLink(apiKey, user, users, agentEmail),
|
||||
];
|
||||
|
||||
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, {
|
||||
agentArgs: {
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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 users should be formatted per that user's timezone.
|
||||
|
||||
The current time in the user's timezone is: ${now(user.timeZone)}
|
||||
The user's time zone is: ${user.timeZone}
|
||||
The user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
||||
.join("\n")}
|
||||
The user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, End Time (minutes in UTC): ${w.endTime}`
|
||||
)
|
||||
.join("\n")}
|
||||
The primary user's id is: ${userId}
|
||||
The primary user's username is: ${user.username}
|
||||
The current time in the primary user's timezone is: ${now(user.timeZone)}
|
||||
The primary user's time zone is: ${user.timeZone}
|
||||
The primary user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
|
||||
.join("\n")}
|
||||
The primary user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, 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",
|
||||
|
||||
@@ -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 ({
|
||||
subject,
|
||||
to,
|
||||
cc,
|
||||
from,
|
||||
text,
|
||||
html,
|
||||
}: {
|
||||
subject: string;
|
||||
to: string;
|
||||
to: string | string[];
|
||||
cc?: string | string[];
|
||||
from: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
@@ -22,9 +24,10 @@ const send = async ({
|
||||
|
||||
const msg = {
|
||||
to,
|
||||
cc,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal AI",
|
||||
name: "Cal.ai",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
|
||||
@@ -58,7 +58,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
responses: z.record(z.any()),
|
||||
responses: z.record(z.any()).nullable(),
|
||||
}).pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
|
||||
@@ -24,6 +24,11 @@ const hostSchema = _HostModel.pick({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const childrenSchema = z.object({
|
||||
id: z.number().int(),
|
||||
userId: z.number().int(),
|
||||
});
|
||||
|
||||
export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
title: true,
|
||||
description: true,
|
||||
@@ -45,6 +50,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
disableGuests: true,
|
||||
hideCalendarNotes: true,
|
||||
minimumBookingNotice: true,
|
||||
parentId: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
teamId: true,
|
||||
@@ -56,7 +62,12 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
bookingLimits: 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()
|
||||
.strict();
|
||||
|
||||
@@ -73,6 +84,7 @@ const schemaEventTypeCreateParams = z
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
parentId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -125,6 +137,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
price: true,
|
||||
currency: true,
|
||||
slotInterval: true,
|
||||
parentId: true,
|
||||
successRedirectUrl: true,
|
||||
description: true,
|
||||
locations: true,
|
||||
@@ -137,6 +150,8 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
durationLimits: true,
|
||||
}).merge(
|
||||
z.object({
|
||||
children: z.array(childrenSchema).optional().default([]),
|
||||
hosts: z.array(hostSchema).optional().default([]),
|
||||
locations: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -10,9 +10,9 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /availability:
|
||||
* /teams/{teamId}/availability:
|
||||
* get:
|
||||
* summary: Find user or team availability
|
||||
* summary: Find team availability
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
@@ -21,25 +21,14 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
* 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
|
||||
* - in: path
|
||||
* name: teamId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 123
|
||||
* description: ID of the team 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
|
||||
@@ -59,7 +48,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
* type: integer
|
||||
* example: 123
|
||||
* description: Event Type ID of the event type to fetch the availability for
|
||||
* operationId: availability
|
||||
* operationId: team-availability
|
||||
* tags:
|
||||
* - availability
|
||||
* responses:
|
||||
@@ -96,7 +85,89 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 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 {
|
||||
[userId: number | string]: MembershipRole;
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
team: { select: { slug: true } },
|
||||
users: true,
|
||||
owner: { select: { username: true, id: true } },
|
||||
children: { select: { id: true, userId: true } },
|
||||
},
|
||||
});
|
||||
await checkPermissions(req, eventType);
|
||||
|
||||
@@ -209,6 +209,8 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
/** FIXME: Updating event-type children from API not supported for now */
|
||||
children: _,
|
||||
...parsedBody
|
||||
} = schemaEventTypeEditBodyParams.parse(body);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ async function getHandler(req: NextApiRequest) {
|
||||
team: { select: { slug: true } },
|
||||
users: true,
|
||||
owner: { select: { username: true, id: true } },
|
||||
children: { select: { id: true, userId: true } },
|
||||
},
|
||||
});
|
||||
// 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 checkParentEventOwnership from "./_utils/checkParentEventOwnership";
|
||||
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
|
||||
import checkUserMembership from "./_utils/checkUserMembership";
|
||||
import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
|
||||
/**
|
||||
@@ -118,10 +120,13 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* schedulingType:
|
||||
* type: string
|
||||
* description: The type of scheduling if a Team event. Required for team events only
|
||||
* enum: [ROUND_ROBIN, COLLECTIVE]
|
||||
* enum: [ROUND_ROBIN, COLLECTIVE, MANAGED]
|
||||
* price:
|
||||
* type: integer
|
||||
* description: Price of the event type booking
|
||||
* parentId:
|
||||
* type: integer
|
||||
* description: EventTypeId of the parent managed event
|
||||
* currency:
|
||||
* type: string
|
||||
* description: Currency acronym. Eg- usd, eur, gbp, etc.
|
||||
@@ -263,6 +268,8 @@ async function postHandler(req: NextApiRequest) {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
/** FIXME: Adding event-type children from API not supported for now */
|
||||
children: _,
|
||||
...parsedBody
|
||||
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||
|
||||
@@ -276,6 +283,11 @@ async function postHandler(req: NextApiRequest) {
|
||||
|
||||
await checkPermissions(req);
|
||||
|
||||
if (parsedBody.parentId) {
|
||||
await checkParentEventOwnership(parsedBody.parentId, userId);
|
||||
await checkUserMembership(parsedBody.parentId, parsedBody.userId);
|
||||
}
|
||||
|
||||
if (isAdmin && 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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
||||
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
@@ -11,10 +11,10 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res });
|
||||
const caller = viewerRouter.createCaller(ctx);
|
||||
const caller = slotsRouter.createCaller(ctx);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.slots.getSchedule(req.query as any /* Let tRPC handle this */);
|
||||
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
if (cause instanceof TRPCError) {
|
||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
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 prisma from "@calcom/prisma";
|
||||
|
||||
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
import handler from "../../../pages/api/bookings/_post";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
@@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
|
||||
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { lookup } from "bcp-47-match";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
function useViewerI18n(locale: string) {
|
||||
export function useViewerI18n(locale: string) {
|
||||
return trpc.viewer.public.i18n.useQuery(
|
||||
{ locale, CalComVersion: CALCOM_VERSION },
|
||||
{
|
||||
@@ -19,46 +14,3 @@ function useViewerI18n(locale: string) {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function useClientLocale(locales: string[]) {
|
||||
const session = useSession();
|
||||
// If the user is logged in, use their locale
|
||||
if (session.data?.user.locale) return session.data.user.locale;
|
||||
// If the user is not logged in, use the browser locale
|
||||
if (typeof window !== "undefined") {
|
||||
// This is the only way I found to ensure the prefetched locale is used on first render
|
||||
// FIXME: Find a better way to pick the best matching locale from the browser
|
||||
return lookup(locales, window.navigator.language) || window.navigator.language;
|
||||
}
|
||||
// If the browser is not available, use English
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function useClientViewerI18n(locales: string[]) {
|
||||
const clientLocale = useClientLocale(locales);
|
||||
return useViewerI18n(clientLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-switches locale client-side to the logged in user's preference
|
||||
*/
|
||||
const I18nLanguageHandler = (props: { locales: string[] }) => {
|
||||
const { locales } = props;
|
||||
const { i18n } = useTranslation("common");
|
||||
const locale = useClientViewerI18n(locales).data?.locale || i18n.language;
|
||||
|
||||
useEffect(() => {
|
||||
// bail early when i18n = {}
|
||||
if (Object.keys(i18n).length === 0) return;
|
||||
// if locale is ready and the i18n.language does != locale - changeLanguage
|
||||
if (locale && i18n.language !== locale) {
|
||||
i18n.changeLanguage(locale);
|
||||
}
|
||||
// set dir="rtl|ltr"
|
||||
document.dir = i18n.dir();
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
}, [locale, i18n]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default I18nLanguageHandler;
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { AppProps } from "@lib/app-providers";
|
||||
import AppProviders from "@lib/app-providers";
|
||||
import { seoConfig } from "@lib/config/next-seo.config";
|
||||
|
||||
import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
|
||||
export interface CalPageWrapper {
|
||||
(props?: AppProps): JSX.Element;
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
@@ -72,7 +70,6 @@ function PageWrapper(props: AppProps) {
|
||||
}
|
||||
{...seoConfig.defaultNextSeo}
|
||||
/>
|
||||
<I18nLanguageHandler locales={props.router.locales || []} />
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
|
||||
@@ -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 [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
@@ -264,8 +265,8 @@ export const AppPage = ({
|
||||
|
||||
{price !== 0 && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
|
||||
{feeType === "monthly" && `/${t("month")}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -285,7 +286,7 @@ export const AppPage = ({
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
{feeType === "monthly" && `/${t("month")}`}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -322,7 +323,7 @@ export const AppPage = ({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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" />
|
||||
|
||||
{email}
|
||||
|
||||
@@ -130,7 +130,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
title={t("something_went_wrong")}
|
||||
message={
|
||||
<span>
|
||||
<Link href={"/apps/" + item.integration.slug}>{item.integration.name}</Link>:{" "}
|
||||
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
|
||||
{t("calendar_error")}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export const InstallAppButtonChild = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
className="w-auto"
|
||||
onInteractOutside={(event) => {
|
||||
if (mutation.isLoading) event.preventDefault();
|
||||
}}>
|
||||
@@ -94,6 +95,7 @@ export const InstallAppButtonChild = ({
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
className="flex"
|
||||
type="button"
|
||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||
key={team.id}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
@@ -26,11 +26,11 @@ import {
|
||||
DialogFooter,
|
||||
MeetingTimeInTimezones,
|
||||
showToast,
|
||||
Tooltip,
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
Tooltip,
|
||||
} 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";
|
||||
|
||||
@@ -58,7 +58,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
@@ -259,14 +258,16 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||
|
||||
const onClickTableData = () => {
|
||||
const buildBookingLink = () => {
|
||||
const urlSearchParams = new URLSearchParams({
|
||||
allRemainingBookings: isTabRecurring.toString(),
|
||||
});
|
||||
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;
|
||||
// To be used after we run query on legacy bookings
|
||||
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
|
||||
@@ -337,54 +338,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</Dialog>
|
||||
|
||||
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||
<td
|
||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||
onClick={onClickTableData}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-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">
|
||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
|
||||
<Link href={bookingLink}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<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.endTime, user?.timeFormat, user?.timeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
@@ -395,66 +353,111 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
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>
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<Link href={bookingLink}>
|
||||
{/* 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-subtle pr-2 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"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">
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
title={title}
|
||||
className={classNames(
|
||||
"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>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={booking.attendees}
|
||||
user={booking.user}
|
||||
currentEmail={user?.email}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={booking.attendees}
|
||||
user={booking.user}
|
||||
currentEmail={user?.email}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</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">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
@@ -573,7 +576,7 @@ const FirstAttendee = ({
|
||||
<a
|
||||
key={user.email}
|
||||
className=" hover:text-blue-500"
|
||||
href={"mailto:" + user.email}
|
||||
href={`mailto:${user.email}`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{user.name}
|
||||
</a>
|
||||
@@ -587,7 +590,7 @@ type AttendeeProps = {
|
||||
|
||||
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||
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}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -121,7 +121,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid URL for ${eventLocationType.label}. ${
|
||||
sampleUrl ? "Sample URL: " + sampleUrl : ""
|
||||
sampleUrl ? `Sample URL: ${sampleUrl}` : ""
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
TextField,
|
||||
Tooltip,
|
||||
} 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 RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
@@ -67,7 +67,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
|
||||
|
||||
eventType.bookingFields.forEach(({ name }) => {
|
||||
bookingFields[name] = name + " input";
|
||||
bookingFields[name] = `${name} input`;
|
||||
});
|
||||
|
||||
const eventNameObject: EventNameObjectType = {
|
||||
@@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
|
||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||
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
|
||||
* 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.
|
||||
*/}
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Label>{t("add_to_calendar")}</Label>
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
className="hover:text-emphasis text-default text-sm">
|
||||
{t("add_another_calendar")}
|
||||
</Link>
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Label className="font-medium">{t("add_to_calendar")}</Label>
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
className="hover:text-emphasis text-default text-sm">
|
||||
{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 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-default text-sm">{t("select_which_cal")}</p>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
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 className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
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>
|
||||
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<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),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
eventType={eventType}
|
||||
seatsEnabled={seatsEnabled}
|
||||
@@ -204,13 +206,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="requiresBookerEmailVerification"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.requiresBookerEmailVerification}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("requires_booker_email_verification")}
|
||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||
description={t("description_requires_booker_email_verification")}
|
||||
@@ -219,13 +223,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.hideCalendarNotes}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_notes")}
|
||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||
description={t("disable_notes_description")}
|
||||
@@ -234,13 +240,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<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")}
|
||||
{...successRedirectUrlLocked}
|
||||
description={t("redirect_url_description")}
|
||||
@@ -249,8 +261,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
setRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
||||
<div className="lg:-mb-2 lg:-ml-2">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label={t("redirect_success_booking")}
|
||||
@@ -274,10 +285,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<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"
|
||||
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")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
@@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||
setHashedLinkVisible(e);
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||
<div className="lg:-ml-2">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||
<TextField
|
||||
disabled
|
||||
@@ -321,7 +345,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
control={formMethods.control}
|
||||
@@ -329,6 +353,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<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"
|
||||
title={t("offer_seats")}
|
||||
{...seatsLocked}
|
||||
@@ -349,45 +379,49 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
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}
|
||||
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 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>
|
||||
</SettingsToggle>
|
||||
{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) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_attendees_confirmation_emails")}
|
||||
description={t("disable_attendees_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
@@ -417,7 +452,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
)}
|
||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.host"
|
||||
control={formMethods.control}
|
||||
@@ -425,6 +459,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_host_confirmation_emails")}
|
||||
description={t("disable_host_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
|
||||
@@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
</div>
|
||||
</div>
|
||||
{!shouldLockDisableProps("apps").disabled && (
|
||||
<div className="bg-muted rounded-md p-8">
|
||||
<div className="bg-muted mt-6 rounded-md p-8">
|
||||
{!isLoading && notInstalledApps?.length ? (
|
||||
<>
|
||||
<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>
|
||||
<p className="text-default mb-6 text-sm font-normal">
|
||||
<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">
|
||||
App Store
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { OptionProps, SingleValueProps } 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)) || [];
|
||||
|
||||
return (
|
||||
<div className="border-default space-y-4 rounded border px-6 pb-4">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<hr className="border-subtle" />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<div className="border-subtle space-y-4 border-x p-6">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</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">
|
||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{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_member_description")
|
||||
);
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
const { watch, setValue, getValues } = useFormContext<FormValues>();
|
||||
const watchSchedule = watch("schedule");
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
||||
|
||||
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
|
||||
@@ -214,7 +214,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
|
||||
setOptions(options);
|
||||
|
||||
const scheduleId = formMethods.getValues("schedule");
|
||||
const scheduleId = getValues("schedule");
|
||||
const value = options.find((option) =>
|
||||
scheduleId
|
||||
? option.value === scheduleId
|
||||
@@ -223,15 +223,20 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
: 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 (
|
||||
<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">
|
||||
{t("availability")}
|
||||
{shouldLockIndicator("availability")}
|
||||
@@ -248,7 +253,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
isSearchable={false}
|
||||
onChange={(selected) => {
|
||||
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"
|
||||
value={availabilityValue}
|
||||
@@ -276,7 +281,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
|
||||
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const { resetField, setValue } = useFormContext<FormValues>();
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<Controller
|
||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||
@@ -285,9 +290,7 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
|
||||
checked={!value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(!checked);
|
||||
if (checked) {
|
||||
resetField("schedule");
|
||||
} else {
|
||||
if (!checked) {
|
||||
setValue("schedule", null);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
|
||||
import type { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
import { Plus, Trash } from "@calcom/ui/components/icon";
|
||||
import { Plus, Trash2 } from "@calcom/ui/components/icon";
|
||||
|
||||
const MinimumBookingNoticeInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
@@ -83,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||
type="number"
|
||||
placeholder="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} />
|
||||
</div>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={passThroughProps.disabled}
|
||||
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
defaultValue={durationTypeOptions.find(
|
||||
(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);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4 lg:space-y-8">
|
||||
<div>
|
||||
<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="w-full">
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
@@ -189,7 +189,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
label: `${minutes} ${t("minutes")}`,
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
@@ -225,7 +225,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
label: `${minutes} ${t("minutes")}`,
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
@@ -272,7 +272,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
value: -1,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
label: `${minutes} ${t("minutes")}`,
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
@@ -295,159 +295,195 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("bookingLimits", {
|
||||
PER_DAY: 1,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("bookingLimits", {
|
||||
PER_DAY: 1,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
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
|
||||
name="durationLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<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
|
||||
name="periodType"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={value && value !== "UNLIMITED"}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
<Select
|
||||
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}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = value && value !== "UNLIMITED";
|
||||
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup.Root>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<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")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
@@ -458,18 +494,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
formMethods.setValue("offsetStart", 0);
|
||||
}
|
||||
}}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
);
|
||||
@@ -509,19 +547,19 @@ const IntervalLimitItem = ({
|
||||
onIntervalSelect,
|
||||
}: IntervalLimitItemProps) => {
|
||||
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
|
||||
required
|
||||
type="number"
|
||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||
className="mb-0 !h-auto"
|
||||
className="mb-0"
|
||||
placeholder={`${value}`}
|
||||
disabled={disabled}
|
||||
min={step}
|
||||
step={step}
|
||||
defaultValue={value}
|
||||
addOnSuffix={textFieldSuffix}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
|
||||
/>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
@@ -529,9 +567,16 @@ const IntervalLimitItem = ({
|
||||
isDisabled={disabled}
|
||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||
onChange={onIntervalSelect}
|
||||
className="w-36"
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,10 @@ import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
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(
|
||||
(mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
})
|
||||
);
|
||||
const multipleDurationOptions = [
|
||||
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 480,
|
||||
].map((mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
}));
|
||||
|
||||
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||
MultiValue<{
|
||||
@@ -292,7 +294,6 @@ export const EventSetupTab = (
|
||||
|
||||
const eventLabel =
|
||||
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${location.type}${index}`}
|
||||
@@ -301,7 +302,10 @@ export const EventSetupTab = (
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
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`}
|
||||
/>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||
@@ -387,178 +391,185 @@ export const EventSetupTab = (
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-8">
|
||||
<TextField
|
||||
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>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...lengthLockedProps}
|
||||
label={t("duration")}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
label={t("title")}
|
||||
{...shouldLockDisableProps("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
)}
|
||||
{!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>
|
||||
<Label>
|
||||
{t("description")}
|
||||
{shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor
|
||||
description={eventType?.description}
|
||||
editable={!descriptionLockedProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
<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),
|
||||
})}
|
||||
/>
|
||||
</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 */}
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? 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 className="border-subtle rounded-md border p-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
|
||||
return (
|
||||
<Shell
|
||||
backPath="/event-types"
|
||||
title={eventType.title + " | " + t("event_type")}
|
||||
title={`${eventType.title} | ${t("event_type")}`}
|
||||
heading={eventType.title}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
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 { useState } from "react";
|
||||
|
||||
@@ -8,6 +10,7 @@ import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||
@@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
||||
)}
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="mb-2 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="border-subtle mb-2 rounded-md border p-8">
|
||||
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("add_webhook_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
|
||||
<div className="border-subtle mt-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
<NewWebhookButton />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
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
|
||||
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
|
||||
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")}
|
||||
{...recurringLocked}
|
||||
description={t("recurring_event_description")}
|
||||
@@ -66,68 +78,70 @@ export default function RecurringEventController({
|
||||
setRecurringEventState(newVal);
|
||||
}
|
||||
}}>
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="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 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>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -67,6 +67,12 @@ export default function RequiresConfirmationController({
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<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")}
|
||||
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||
@@ -77,107 +83,111 @@ export default function RequiresConfirmationController({
|
||||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "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
|
||||
);
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
id="always"
|
||||
value="always"
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
val
|
||||
);
|
||||
}}
|
||||
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
className="ml-2"
|
||||
onChange={(opt) => {
|
||||
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
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
id="always"
|
||||
value="always"
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
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>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
id="notice"
|
||||
value="notice"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
|
||||
onChange={(opt) => {
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural
|
||||
);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
id="notice"
|
||||
value="notice"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
@@ -22,7 +23,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { nextStep } = props;
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
const telemetry = useTelemetry();
|
||||
const userSettingsSchema = z.object({
|
||||
name: z
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Avatar } from "@calcom/ui";
|
||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||
type MembersType = TeamType["members"];
|
||||
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 routerQuery = useRouterQuery();
|
||||
@@ -23,7 +22,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
||||
return (
|
||||
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
|
||||
<Avatar size="md" alt={member.name || ""} imageSrc={"/" + 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">
|
||||
<p className="text-default font-medium">{member.name}</p>
|
||||
<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 (
|
||||
<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) => {
|
||||
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 (
|
||||
<div>
|
||||
<Members members={team.members} teamName={team.name} />
|
||||
<Members members={members} teamName={teamName} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
|
||||
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-center bg-[#f3f4f6] py-12 sm:px-6 lg:px-8">
|
||||
<div className="bg-subtle dark:bg-darkgray-50 flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<HeadSeo title={props.title} description={props.description} />
|
||||
{props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-default border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
|
||||
<div className="bg-default dark:bg-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="text-default mt-8 text-center text-sm">{props.footerText}</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { components } from "react-select";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
export type LocationOption = {
|
||||
@@ -22,7 +23,7 @@ export type GroupOptionType = GroupBase<LocationOption>;
|
||||
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { appWithTranslation } from "next-i18next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
@@ -17,9 +17,10 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useClientViewerI18n } from "@components/I18nLanguageHandler";
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
|
||||
const I18nextAdapter = appWithTranslation<
|
||||
NextJsAppProps<SSRConfig> & {
|
||||
@@ -30,10 +31,12 @@ const I18nextAdapter = appWithTranslation<
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<
|
||||
WithNonceProps & {
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
} & Record<string, unknown>
|
||||
WithLocaleProps<
|
||||
WithNonceProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
}>
|
||||
>
|
||||
>,
|
||||
"Component"
|
||||
> & {
|
||||
@@ -68,8 +71,8 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
**/
|
||||
const clientViewerI18n = useClientViewerI18n(props.router.locales || []);
|
||||
const { i18n, locale } = clientViewerI18n.data || {};
|
||||
const clientViewerI18n = useViewerI18n(props.pageProps.newLocale);
|
||||
const i18n = clientViewerI18n.data?.i18n;
|
||||
|
||||
const passedProps = {
|
||||
...props,
|
||||
@@ -77,8 +80,7 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
||||
...props.pageProps,
|
||||
...i18n,
|
||||
},
|
||||
router: locale ? { locale } : props.router,
|
||||
} as unknown as ComponentProps<typeof I18nextAdapter>;
|
||||
};
|
||||
|
||||
return <I18nextAdapter {...passedProps} />;
|
||||
};
|
||||
@@ -179,14 +181,14 @@ function getThemeProviderProps({
|
||||
);
|
||||
}
|
||||
|
||||
const appearanceIdSuffix = themeBasis ? ":" + themeBasis : "";
|
||||
const appearanceIdSuffix = themeBasis ? `:${themeBasis}` : "";
|
||||
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
|
||||
let embedExplicitlySetThemeSuffix = "";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const embedTheme = window.getEmbedTheme();
|
||||
if (embedTheme) {
|
||||
embedExplicitlySetThemeSuffix = ":" + embedTheme;
|
||||
embedExplicitlySetThemeSuffix = `:${embedTheme}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +235,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const { pageProps, ...rest } = props;
|
||||
const { _nonce, ...restPageProps } = pageProps;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { nonce, ...restPageProps } = pageProps;
|
||||
const propsWithoutNonce = {
|
||||
pageProps: {
|
||||
...restPageProps,
|
||||
@@ -243,8 +247,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
@@ -260,8 +264,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
</SessionProvider>
|
||||
</CustomI18nextProvider>
|
||||
</EventCollectionProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ const initClock = () => {
|
||||
// This only sets browser locale if there's no preference on localStorage.
|
||||
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
||||
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) => {
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ function getCspPolicy(nonce: string) {
|
||||
script-src ${
|
||||
IS_PRODUCTION
|
||||
? // '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
|
||||
"'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");
|
||||
|
||||
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
|
||||
const newDestinationUrl =
|
||||
destinationUrlObj.pathname +
|
||||
"/embed?" +
|
||||
destinationUrlObj.searchParams.toString() +
|
||||
"&layout=" +
|
||||
layout +
|
||||
"&embed=" +
|
||||
embed;
|
||||
const newDestinationUrl = `${
|
||||
destinationUrlObj.pathname
|
||||
}/embed?${destinationUrlObj.searchParams.toString()}&layout=${layout}&embed=${embed}`;
|
||||
|
||||
return {
|
||||
...ssrResponse,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type WithLocaleProps<T extends Record<string, unknown>> = T & {
|
||||
newLocale: string;
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
|
||||
import type { GetServerSideProps } from "next";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
export type WithNonceProps = {
|
||||
export type WithNonceProps<T extends Record<string, any>> = T & {
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
@@ -11,9 +11,16 @@ export type WithNonceProps = {
|
||||
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
|
||||
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
|
||||
*/
|
||||
export default function withNonce(getServerSideProps: GetServerSideProps) {
|
||||
return async (context: GetServerSidePropsContext) => {
|
||||
export default function withNonce<T extends Record<string, any>>(
|
||||
getServerSideProps: GetServerSideProps<T>
|
||||
): GetServerSideProps<WithNonceProps<T>> {
|
||||
return async (context) => {
|
||||
const ssrResponse = await getServerSideProps(context);
|
||||
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
const { nonce } = csp(context.req, context.res);
|
||||
|
||||
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
|
||||
@@ -23,10 +30,6 @@ export default function withNonce(getServerSideProps: GetServerSideProps) {
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
|
||||
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const middleware: NextMiddleware = async (req) => {
|
||||
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.
|
||||
requestHeaders.set("x-csp-enforce", "true");
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export const config = {
|
||||
matcher: [
|
||||
"/:path*/embed",
|
||||
"/api/trpc/:path*",
|
||||
"/login",
|
||||
"/auth/login",
|
||||
/**
|
||||
* Paths required by routingForms.handle
|
||||
|
||||
@@ -21,11 +21,11 @@ process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
||||
|
||||
// So we can test deploy previews preview
|
||||
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
|
||||
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) {
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
@@ -226,6 +226,10 @@ const nextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
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
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.3.2",
|
||||
"version": "3.3.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -49,6 +49,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@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-popover": "^1.0.2",
|
||||
"@radix-ui/react-radio-group": "^1.0.0",
|
||||
|
||||
@@ -250,11 +250,8 @@ export default function Custom404() {
|
||||
) : IS_CALCOM ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
<strong className="text-blue-500">
|
||||
{new URL(WEBSITE_URL).hostname}
|
||||
{username}
|
||||
</strong>{" "}
|
||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
||||
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
|
||||
<span className="text-blue-500">{t("register_now")}</span>.
|
||||
</a>
|
||||
) : (
|
||||
<span className="mt-2 inline-block text-lg">
|
||||
|
||||
@@ -125,7 +125,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
{user.away ? (
|
||||
<div className="overflow-hidden rounded-sm border ">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import {
|
||||
@@ -10,8 +11,7 @@ import {
|
||||
getMultipleDurationValue,
|
||||
} 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 } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -44,6 +44,7 @@ export default function Type({
|
||||
hideBranding={isBrandingHidden}
|
||||
isSEOIndexable={isSEOIndexable ?? true}
|
||||
entity={entity}
|
||||
bookingData={booking}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
@@ -62,6 +63,7 @@ Type.isBookingPage = true;
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
|
||||
@@ -97,7 +99,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
@@ -139,6 +141,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
}
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
@@ -152,7 +155,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug),
|
||||
},
|
||||
select: {
|
||||
away: true,
|
||||
@@ -169,7 +172,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
+31
-1
@@ -1,5 +1,8 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { AppContextType } from "next/dist/shared/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
@@ -8,8 +11,35 @@ import "../styles/globals.css";
|
||||
|
||||
function MyApp(props: AppProps) {
|
||||
const { Component, pageProps } = props;
|
||||
|
||||
if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
export default trpc.withTRPC(MyApp);
|
||||
declare global {
|
||||
interface Window {
|
||||
calNewLocale: string;
|
||||
}
|
||||
}
|
||||
|
||||
MyApp.getInitialProps = async (ctx: AppContextType) => {
|
||||
const { req } = ctx.ctx;
|
||||
|
||||
let newLocale = "en";
|
||||
|
||||
if (req) {
|
||||
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
|
||||
} else if (typeof window !== "undefined" && window.calNewLocale) {
|
||||
newLocale = window.calNewLocale;
|
||||
}
|
||||
|
||||
return {
|
||||
pageProps: {
|
||||
newLocale,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const WrappedMyApp = trpc.withTRPC(MyApp);
|
||||
|
||||
export default WrappedMyApp;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { NextPageContext } from "next";
|
||||
import type { DocumentContext, DocumentProps } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
|
||||
function setHeader(ctx: NextPageContext, name: string, value: string) {
|
||||
try {
|
||||
ctx.res?.setHeader(name, value);
|
||||
@@ -26,23 +28,50 @@ class MyDocument extends Document<Props> {
|
||||
setHeader(ctx, "x-csp", "initialPropsOnly");
|
||||
}
|
||||
|
||||
const newLocale = ctx.req
|
||||
? await getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||
: "en";
|
||||
|
||||
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
|
||||
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 initialProps = await Document.getInitialProps(ctx);
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps, newLocale };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { locale } = this.props.__NEXT_DATA__;
|
||||
const { isEmbed, embedColorScheme } = this.props;
|
||||
const newLocale = this.props.newLocale || "en";
|
||||
|
||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||
|
||||
const intlLocale = new Intl.Locale(newLocale);
|
||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||
const direction = intlLocale.textInfo?.direction;
|
||||
if (!direction) {
|
||||
throw new Error("NodeJS major breaking change detected, use getTextInfo() instead.");
|
||||
}
|
||||
|
||||
return (
|
||||
<Html lang={locale} style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<Html
|
||||
lang={newLocale}
|
||||
dir={direction}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<Head nonce={nonce}>
|
||||
<script
|
||||
nonce={nonce}
|
||||
id="newLocale"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.calNewLocale = "${newLocale}";`,
|
||||
}}
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/api/logo?type=apple-touch-icon" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/api/logo?type=favicon-32" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/api/logo?type=favicon-16" />
|
||||
|
||||
@@ -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
|
||||
if (team.parentId) {
|
||||
// Create a membership for the organization itself
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
|
||||
await prisma.membership.upsert({
|
||||
where: {
|
||||
userId_teamId: { userId: user.id, teamId: team.parentId },
|
||||
},
|
||||
update: {
|
||||
accepted: true,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
teamId: team.parentId,
|
||||
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";
|
||||
|
||||
@@ -7,7 +7,7 @@ import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
||||
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
|
||||
|
||||
enum DirectAction {
|
||||
ACCEPT = "accept",
|
||||
@@ -55,13 +55,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
|
||||
try {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
const caller = viewerRouter.createCaller({
|
||||
const caller = bookingsRouter.createCaller({
|
||||
...ctx,
|
||||
req,
|
||||
res,
|
||||
user: { ...user, locale: user?.locale ?? "en" },
|
||||
});
|
||||
await caller.bookings.confirm({
|
||||
await caller.confirm({
|
||||
bookingId: booking.id,
|
||||
recurringEventId: booking.recurringEventId || undefined,
|
||||
confirmed: action === DirectAction.ACCEPT,
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ message: "Google client_secret missing." });
|
||||
|
||||
// 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 authUrl = oAuth2Client.generateAuthUrl({
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
if (!client_secret || typeof client_secret !== "string")
|
||||
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);
|
||||
|
||||
if (!code) {
|
||||
@@ -54,11 +54,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
if (!teamId) {
|
||||
res.redirect(getSafeRedirectUrl(WEBAPP_URL + "/settings") ?? `${WEBAPP_URL}/teams`);
|
||||
res.redirect(getSafeRedirectUrl(`${WEBAPP_URL}/settings`) ?? `${WEBAPP_URL}/teams`);
|
||||
}
|
||||
|
||||
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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
// Check that credential sharing is enabled
|
||||
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
||||
return res.status(403).json({ message: "Credential sharing is not enabled" });
|
||||
}
|
||||
|
||||
// Check that the webhook secret matches
|
||||
if (
|
||||
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "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);
|
||||
|
||||
// Check that the user exists
|
||||
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
|
||||
|
||||
if (!user) {
|
||||
@@ -39,26 +36,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const app = await prisma.app.findUnique({
|
||||
where: { slug: reqBody.appSlug },
|
||||
select: { slug: true },
|
||||
select: { dirName: true },
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
return res.status(404).json({ message: "App not found" });
|
||||
}
|
||||
|
||||
// Search for the app's slug and type
|
||||
const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata];
|
||||
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
||||
|
||||
if (!appMetadata) {
|
||||
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
|
||||
}
|
||||
|
||||
// Decrypt the keys
|
||||
const keys = JSON.parse(
|
||||
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({
|
||||
where: {
|
||||
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 { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
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 PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const slug = searchParams?.get("slug") as string;
|
||||
@@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
||||
|
||||
SetupInformation.PageWrapper = PageWrapper;
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
return {
|
||||
paths: [],
|
||||
fallback: "blocking",
|
||||
};
|
||||
};
|
||||
|
||||
export { getStaticProps };
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function Apps({ categories }: inferSSRProps<typeof getServerSideP
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category.name}
|
||||
href={"/apps/categories/" + category.name}
|
||||
href={`/apps/categories/${category.name}`}
|
||||
data-testid={`app-store-category-${category.name}`}
|
||||
className="bg-subtle relative flex rounded-sm px-6 py-4 sm:block">
|
||||
<div className="self-center">
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useReducer, useState } from "react";
|
||||
import z from "zod";
|
||||
import { useReducer } from "react";
|
||||
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 { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } 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 {
|
||||
Alert,
|
||||
Button,
|
||||
EmptyScreen,
|
||||
List,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
ShellSubHeading,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
Dropdown,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Button, EmptyScreen, AppSkeletonLoader as SkeletonLoader, ShellSubHeading } from "@calcom/ui";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
BarChart,
|
||||
@@ -38,231 +16,24 @@ import {
|
||||
CreditCard,
|
||||
Grid,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Share2,
|
||||
Trash,
|
||||
Video,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { AppList } from "@components/apps/AppList";
|
||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||
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 {
|
||||
variant?: AppCategories;
|
||||
exclude?: AppCategories[];
|
||||
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 = ({
|
||||
variant,
|
||||
exclude,
|
||||
@@ -329,7 +100,8 @@ const IntegrationsContainer = ({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||
|
||||
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Button, PasswordField, Form } from "@calcom/ui";
|
||||
@@ -141,7 +142,6 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: Props)
|
||||
);
|
||||
}
|
||||
|
||||
Page.isThemeSupported = false;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const id = context.params?.id as string;
|
||||
@@ -163,12 +163,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
} catch (e) {
|
||||
resetPasswordRequest = null;
|
||||
}
|
||||
const locale = await getLocale(context.req);
|
||||
return {
|
||||
props: {
|
||||
isRequestExpired: !resetPasswordRequest,
|
||||
requestId: id,
|
||||
csrfToken: await getCsrfToken({ req: context.req }),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
||||
import type { CSSProperties, SyntheticEvent } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, EmailField } from "@calcom/ui";
|
||||
@@ -126,8 +127,9 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
className="w-full justify-center dark:bg-white dark:text-black"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
aria-label={t("request_password_reset")}
|
||||
loading={loading}>
|
||||
@@ -141,7 +143,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
ForgotPassword.isThemeSupported = false;
|
||||
ForgotPassword.PageWrapper = PageWrapper;
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
@@ -154,11 +155,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
res.end();
|
||||
return { props: {} };
|
||||
}
|
||||
const locale = await getLocale(context.req);
|
||||
|
||||
return {
|
||||
props: {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,11 +15,12 @@ import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -50,7 +51,8 @@ export default function Login({
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
totpEmail,
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -160,6 +162,16 @@ export default function Login({
|
||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||
};
|
||||
|
||||
const { data, isLoading } = trpc.viewer.public.ssoConnections.useQuery(undefined, {
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const displaySSOLogin = HOSTED_CAL_FEATURES
|
||||
? true
|
||||
: isSAMLLoginEnabled && !isLoading && data?.connectionExists;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
@@ -225,14 +237,14 @@ export default function Login({
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={formState.isSubmitting}
|
||||
className="w-full justify-center">
|
||||
className="w-full justify-center dark:bg-white dark:text-black">
|
||||
{twoFactorRequired ? t("submit") : t("sign_in")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{!twoFactorRequired && (
|
||||
<>
|
||||
{(isGoogleLoginEnabled || isSAMLLoginEnabled) && <hr className="border-subtle my-8" />}
|
||||
{(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />}
|
||||
<div className="space-y-3">
|
||||
{isGoogleLoginEnabled && (
|
||||
<Button
|
||||
@@ -247,7 +259,7 @@ export default function Login({
|
||||
{t("signin_with_google")}
|
||||
</Button>
|
||||
)}
|
||||
{isSAMLLoginEnabled && (
|
||||
{displaySSOLogin && (
|
||||
<SAMLLogin
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
@@ -337,7 +349,6 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
|
||||
};
|
||||
};
|
||||
|
||||
Login.isThemeSupported = false;
|
||||
Login.PageWrapper = PageWrapper;
|
||||
|
||||
export const getServerSideProps = withNonce(_getServerSideProps);
|
||||
|
||||
@@ -57,7 +57,6 @@ export function Logout(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
Logout.isThemeSupported = false;
|
||||
Logout.PageWrapper = PageWrapper;
|
||||
export default Logout;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
if (!email) {
|
||||
router.push("/auth/error?error=" + "Email not provided");
|
||||
router.push(`/auth/error?error=Email not provided`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.isSAMLLoginEnabled) {
|
||||
router.push("/auth/error?error=" + "SAML login not enabled");
|
||||
router.push(`/auth/error?error=SAML login not enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const providerParam = asStringOrNull(context.query.provider);
|
||||
const emailParam = asStringOrNull(context.query.email);
|
||||
const usernameParam = asStringOrNull(context.query.username);
|
||||
const successDestination = "/getting-started" + (usernameParam ? `?username=${usernameParam}` : "");
|
||||
const successDestination = `/getting-started${usernameParam ? `?username=${usernameParam}` : ""}`;
|
||||
if (!providerParam) {
|
||||
throw new Error(`File is not named sso/[provider]`);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
if (error) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/auth/error?error=" + error,
|
||||
destination: `/auth/error?error=${error}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,10 +60,16 @@ export default function Verify() {
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||
const [secondsLeft, setSecondsLeft] = useState(30);
|
||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({
|
||||
stripeCustomerId,
|
||||
checkoutSessionId: sessionId,
|
||||
});
|
||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
|
||||
{
|
||||
stripeCustomerId,
|
||||
checkoutSessionId: sessionId,
|
||||
},
|
||||
{
|
||||
enabled: !!stripeCustomerId || !!sessionId,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
|
||||
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
|
||||
// to avoid refresh to skip waiting 30 seconds to re-send email
|
||||
@@ -114,7 +120,7 @@ export default function Verify() {
|
||||
? "Your payment failed"
|
||||
: sessionId
|
||||
? "Payment successful!"
|
||||
: "Verify your email" + " | " + APP_NAME}
|
||||
: `Verify your email | ${APP_NAME}`}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6">
|
||||
@@ -123,12 +129,12 @@ export default function Verify() {
|
||||
{hasPaymentFailed ? (
|
||||
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||
) : 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" />
|
||||
)}
|
||||
</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
|
||||
? "Your payment failed"
|
||||
: sessionId
|
||||
@@ -138,7 +144,7 @@ export default function Verify() {
|
||||
{hasPaymentFailed && (
|
||||
<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.{" "}
|
||||
{hasPaymentFailed &&
|
||||
"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();
|
||||
setSecondsLeft(30);
|
||||
// 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()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username);
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function Availability() {
|
||||
return (
|
||||
<Shell
|
||||
backPath={fromEventType ? true : "/availability"}
|
||||
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
|
||||
title={schedule?.name ? `${schedule.name} | ${t("availability")}` : t("availability")}
|
||||
heading={
|
||||
<Controller
|
||||
control={form.control}
|
||||
|
||||
@@ -133,7 +133,7 @@ Troubleshoot.PageWrapper = PageWrapper;
|
||||
function convertMinsToHrsMins(mins: number) {
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
const hs = h < 10 ? "0" + h : h;
|
||||
const ms = m < 10 ? "0" + m : m;
|
||||
const hs = h < 10 ? `0${h}` : h;
|
||||
const ms = m < 10 ? `0${m}` : m;
|
||||
return `${hs}:${ms}`;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
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 { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
@@ -93,7 +94,7 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t, i18n } = useLocale();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
@@ -132,7 +133,9 @@ export default function Success(props: SuccessProps) {
|
||||
|
||||
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 [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
|
||||
@@ -143,7 +146,7 @@ export default function Success(props: SuccessProps) {
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||
|
||||
const { requiresLoginToUpdate } = props;
|
||||
function setIsCancellationMode(value: boolean) {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
|
||||
@@ -214,8 +217,10 @@ export default function Success(props: SuccessProps) {
|
||||
confirmed: !needsConfirmation,
|
||||
// TODO: Add payment details
|
||||
});
|
||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||
setIs24h(!!getIs24hClockFromLocalStorage());
|
||||
setDate(
|
||||
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
|
||||
}, [eventType, needsConfirmation]);
|
||||
|
||||
@@ -264,13 +269,13 @@ export default function Success(props: SuccessProps) {
|
||||
}
|
||||
if (needsConfirmation) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
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
|
||||
@@ -353,10 +358,10 @@ export default function Success(props: SuccessProps) {
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
{!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" />}
|
||||
{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 className="mb-8 mt-6 text-center last:mb-0">
|
||||
<h3
|
||||
@@ -486,10 +491,7 @@ export default function Success(props: SuccessProps) {
|
||||
: t("payment")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-2 mt-3">
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: props.paymentStatus.currency,
|
||||
}).format(props.paymentStatus.amount / 100.0)}
|
||||
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -530,7 +532,28 @@ export default function Success(props: SuccessProps) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(!needsConfirmation || !userIsOwner) &&
|
||||
{requiresLoginToUpdate && (
|
||||
<>
|
||||
<hr className="border-subtle mb-8" />
|
||||
<div className="text-center">
|
||||
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
|
||||
{/* Login button but redirect to here */}
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/auth/login?callbackUrl=${encodeURIComponent(
|
||||
`/booking/${bookingInfo?.uid}`
|
||||
)}`}
|
||||
legacyBehavior>
|
||||
{t("login")}
|
||||
</Link>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!requiresLoginToUpdate &&
|
||||
(!needsConfirmation || !userIsOwner) &&
|
||||
!isCancelled &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
@@ -538,28 +561,30 @@ export default function Success(props: SuccessProps) {
|
||||
<div className="text-center last:pb-0">
|
||||
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
|
||||
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
<>
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="mx-2">{t("or_lowercase")}</span>
|
||||
</span>
|
||||
<span className="mx-2">{t("or_lowercase")}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-testid="cancel"
|
||||
className={classNames(
|
||||
"text-default underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
)}
|
||||
onClick={() => setIsCancellationMode(true)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="cancel"
|
||||
className={classNames(
|
||||
"text-default underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
)}
|
||||
onClick={() => setIsCancellationMode(true)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -590,23 +615,24 @@ export default function Success(props: SuccessProps) {
|
||||
</span>
|
||||
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
||||
<Link
|
||||
href={
|
||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
props.eventType.description
|
||||
}` +
|
||||
(typeof locationVideoCallUrl === "string"
|
||||
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
||||
: "") +
|
||||
(props.eventType.recurringEvent
|
||||
? "&recur=" +
|
||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||
: "")
|
||||
}
|
||||
href={`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
props.eventType.description
|
||||
}${
|
||||
typeof locationVideoCallUrl === "string"
|
||||
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
|
||||
: ""
|
||||
}${
|
||||
props.eventType.recurringEvent
|
||||
? `&recur=${encodeURIComponent(
|
||||
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">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
@@ -620,17 +646,17 @@ export default function Success(props: SuccessProps) {
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(calculatedDuration, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
`https://outlook.live.com/calendar/0/deeplink/compose?body=${
|
||||
props.eventType.description
|
||||
}&enddt=${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
|
||||
.utc()
|
||||
.format()}&subject=${eventName}`
|
||||
) +
|
||||
(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"
|
||||
@@ -647,17 +673,17 @@ export default function Success(props: SuccessProps) {
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(calculatedDuration, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
`https://outlook.office.com/calendar/0/deeplink/compose?body=${
|
||||
props.eventType.description
|
||||
}&enddt=${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
|
||||
.utc()
|
||||
.format()}&subject=${eventName}`
|
||||
) +
|
||||
(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"
|
||||
@@ -672,9 +698,9 @@ export default function Success(props: SuccessProps) {
|
||||
</svg>
|
||||
</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"
|
||||
download={props.eventType.title + ".ics"}>
|
||||
download={`${props.eventType.title}.ics`}>
|
||||
<svg
|
||||
version="1.1"
|
||||
fill="currentColor"
|
||||
@@ -1006,10 +1032,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const session = await getServerSession(context);
|
||||
let tz: string | null = null;
|
||||
|
||||
let userTimeFormat: number | null = null;
|
||||
let requiresLoginToUpdate = false;
|
||||
if (session) {
|
||||
const user = await ssr.viewer.me.fetch();
|
||||
tz = user.timeZone;
|
||||
userTimeFormat = user.timeFormat;
|
||||
}
|
||||
|
||||
const parsedQuery = querySchema.safeParse(context.query);
|
||||
@@ -1017,9 +1045,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!parsedQuery.success) return { notFound: true };
|
||||
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
|
||||
|
||||
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
|
||||
const bookingInfoRaw = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: await maybeGetBookingUidFromSeat(prisma, uid),
|
||||
uid: maybeUid,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
@@ -1083,6 +1112,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
|
||||
requiresLoginToUpdate = true;
|
||||
}
|
||||
|
||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
@@ -1150,6 +1183,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
bookingInfo,
|
||||
paymentStatus: payment,
|
||||
...(tz && { tz }),
|
||||
userTimeFormat,
|
||||
requiresLoginToUpdate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
@@ -27,6 +28,7 @@ export default function Type({
|
||||
isTeamEvent,
|
||||
entity,
|
||||
duration,
|
||||
hashedLink,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -46,6 +48,7 @@ export default function Type({
|
||||
isTeamEvent={isTeamEvent}
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
hashedLink={hashedLink}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -55,6 +58,7 @@ Type.PageWrapper = PageWrapper;
|
||||
Type.isBookingPage = true;
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
@@ -117,7 +121,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
}
|
||||
|
||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||
@@ -149,6 +153,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
// Sending the team event from the server, because this template file
|
||||
// is reused for both team and user events.
|
||||
isTeamEvent,
|
||||
hashedLink: link,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -449,7 +449,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
availability={availability}
|
||||
isUpdateMutationLoading={updateMutation.isLoading}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
disableBorder={true}
|
||||
currentUserMembership={currentUserMembership}
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||
<Form
|
||||
|
||||
@@ -127,7 +127,7 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
readOnly={events[0].metadata.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -140,13 +140,13 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
<div>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
{group.profile.slug ? (
|
||||
<small
|
||||
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.slug}`}
|
||||
@@ -177,13 +177,13 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
<div>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
{group.profile.slug ? (
|
||||
<small
|
||||
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}`}
|
||||
</small>
|
||||
) : null}
|
||||
@@ -363,7 +363,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
}, []);
|
||||
|
||||
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];
|
||||
@@ -475,7 +479,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</>
|
||||
)}
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
@@ -489,9 +493,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={"event-type-edit-" + type.id}
|
||||
data-testid={`event-type-edit-${type.id}`}
|
||||
StartIcon={Edit2}
|
||||
onClick={() => router.push("/event-types/" + type.id)}>
|
||||
onClick={() => router.push(`/event-types/${type.id}`)}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
@@ -501,7 +505,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
StartIcon={Copy}
|
||||
onClick={() => openDuplicateModal(type, group)}>
|
||||
{t("duplicate")}
|
||||
@@ -551,7 +555,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</div>
|
||||
<div className="min-w-9 mx-5 flex sm:hidden">
|
||||
<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} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
@@ -569,7 +573,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
@@ -584,7 +588,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
{isNativeShare ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
onClick={() => {
|
||||
navigator
|
||||
.share({
|
||||
@@ -604,7 +608,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
{!readOnly && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => router.push("/event-types/" + type.id)}
|
||||
onClick={() => router.push(`/event-types/${type.id}`)}
|
||||
StartIcon={Edit}
|
||||
className="w-full rounded-none">
|
||||
{t("edit")}
|
||||
@@ -616,7 +620,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
StartIcon={Copy}
|
||||
data-testid={"event-type-duplicate-" + type.id}>
|
||||
data-testid={`event-type-duplicate-${type.id}`}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
@@ -764,7 +768,7 @@ const EventTypeListHeading = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CreateFirstEventTypeView = () => {
|
||||
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -774,7 +778,7 @@ const CreateFirstEventTypeView = () => {
|
||||
description={t("new_event_type_description")}
|
||||
className="mb-16"
|
||||
buttonRaw={
|
||||
<Button href="?dialog=new" variant="button">
|
||||
<Button href={`?dialog=new&eventPage=${slug}`} variant="button">
|
||||
{t("create")}
|
||||
</Button>
|
||||
}
|
||||
@@ -915,7 +919,7 @@ const Main = ({
|
||||
) : group.teamId ? (
|
||||
<EmptyEventTypeList group={group} />
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
@@ -931,7 +935,7 @@ const Main = ({
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
|
||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
|
||||
<EventTypeEmbedDialog />
|
||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user