Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9831845d27 | ||
|
|
04cd821a57 | ||
|
|
7f270649b4 | ||
|
|
fae714bceb | ||
|
|
47c2fc3d89 | ||
|
|
53b202790e | ||
|
|
9b1031d009 | ||
|
|
b25e6c25aa | ||
|
|
4083ebd591 | ||
|
|
f2e0f00f93 | ||
|
|
fd6b2c57cb | ||
|
|
3d685eb4ae | ||
|
|
c9fb82a7e6 | ||
|
|
eaf19d1d23 | ||
|
|
7e6e6b6d6b | ||
|
|
3a5522cf0e | ||
|
|
6377377c4d | ||
|
|
62be5b561e | ||
|
|
424482646f | ||
|
|
f0b1767b3c | ||
|
|
3e3e802b28 | ||
|
|
da9f49341f | ||
|
|
1ab9728fa0 | ||
|
|
5b4cebac16 | ||
|
|
788e2acaff | ||
|
|
ada3317ba5 | ||
|
|
bb73e30b17 | ||
|
|
006645b279 | ||
|
|
ec06f645bd | ||
|
|
21bc4f9386 | ||
|
|
45f8d2d230 | ||
|
|
d98731d50a | ||
|
|
ec97971e7d | ||
|
|
0b83133155 | ||
|
|
5625cf226b | ||
|
|
adbae64619 | ||
|
|
ecf352ce00 | ||
|
|
e53648d218 | ||
|
|
9da761b21c | ||
|
|
202db9315f | ||
|
|
89f86e2c84 | ||
|
|
0f27385c17 | ||
|
|
622d0fd0bc | ||
|
|
5908e5b14b | ||
|
|
4908b6fd01 | ||
|
|
b93f87af14 | ||
|
|
71c9a7b931 | ||
|
|
b143498393 | ||
|
|
322a845a17 | ||
|
|
3bcc4b86e5 | ||
|
|
3a67ae6d1f | ||
|
|
8c4eed2bbc | ||
|
|
ce0c8347fb | ||
|
|
91b732ff1c | ||
|
|
a311f6bf4b | ||
|
|
04f9b93ceb | ||
|
|
190cc8caf6 | ||
|
|
b6a20cc4d7 | ||
|
|
eeb0cd7e4d | ||
|
|
b77923fc65 | ||
|
|
6687544e66 | ||
|
|
7384675b6b | ||
|
|
87e3c8d4a5 | ||
|
|
e23f9330d3 | ||
|
|
759bb67077 | ||
|
|
0a8509d721 | ||
|
|
f4b6a16a9e | ||
|
|
6e8fbc280f | ||
|
|
52e6711d51 | ||
|
|
0fb44887e3 | ||
|
|
b376ebae25 | ||
|
|
49ddd6cb59 | ||
|
|
c437f15868 | ||
|
|
15052c8b48 | ||
|
|
800002222b | ||
|
|
e3283baa0e | ||
|
|
382d56ab54 | ||
|
|
c93e8774c9 | ||
|
|
71e74b8320 | ||
|
|
d338504856 | ||
|
|
af793e9e81 | ||
|
|
6caf09e3e7 | ||
|
|
b6742c4c4a | ||
|
|
fcdd2ab81b | ||
|
|
8fd976f5c7 | ||
|
|
521b63e732 | ||
|
|
4890e6b5b9 | ||
|
|
9851c8f526 | ||
|
|
7826a34b00 | ||
|
|
2559873b2c | ||
|
|
cc90cf0b72 | ||
|
|
8717d96d0a | ||
|
|
c8ba5e1aa1 | ||
|
|
eb59908c84 | ||
|
|
981fb9c5be | ||
|
|
ca29940ea5 | ||
|
|
cf186e58bd | ||
|
|
3bae13eea8 | ||
|
|
5d4cbe37eb | ||
|
|
48f969eae5 | ||
|
|
546f627177 | ||
|
|
c6169607ae | ||
|
|
795423ae55 | ||
|
|
00e3b970d6 | ||
|
|
9e786b9133 | ||
|
|
2941ad334c | ||
|
|
cefd0cfb16 | ||
|
|
f8aa274b07 | ||
|
|
3d2fd28214 | ||
|
|
fa66448f89 | ||
|
|
73cdf5dda5 | ||
|
|
eac2e4e53e | ||
|
|
d9d95ba17c | ||
|
|
3bca9687d0 | ||
|
|
b91dfe7595 | ||
|
|
9e89f954e8 | ||
|
|
b860a79d59 | ||
|
|
5eca42bb45 | ||
|
|
5cf67fdbaa | ||
|
|
ac0c3bdfb9 | ||
|
|
652c2e342f | ||
|
|
ecbdfea818 | ||
|
|
0846d0666b | ||
|
|
a704f1ed0a | ||
|
|
97550a39f3 | ||
|
|
e36428de5d | ||
|
|
373bc1660c | ||
|
|
9863178025 | ||
|
|
50c75da5e0 | ||
|
|
7585e9b32e | ||
|
|
95b3397e42 | ||
|
|
b3ada7c25c | ||
|
|
0142a1502f | ||
|
|
8996c168ca | ||
|
|
4d14809ecf | ||
|
|
6749b887dd | ||
|
|
08e6059c8d | ||
|
|
b9dd90b998 | ||
|
|
bced10eab1 | ||
|
|
deeafb21a5 | ||
|
|
81b4443fc2 | ||
|
|
4f89205dd4 | ||
|
|
75d19e0e7d | ||
|
|
c8ae414ecf | ||
|
|
e7dc2d3d7a | ||
|
|
5b66c1f986 | ||
|
|
0b4f771462 | ||
|
|
944a3c02ce | ||
|
|
5185704a38 | ||
|
|
152bb57bc1 | ||
|
|
15bfeb30d7 | ||
|
|
0bf3818b30 | ||
|
|
ede0e98e1f | ||
|
|
d702bcf0a3 | ||
|
|
2194b92fdf | ||
|
|
2c51fd77a0 | ||
|
|
f8b908500f | ||
|
|
ac0840c802 | ||
|
|
392aac2c6e | ||
|
|
4236288d32 | ||
|
|
4693cbba4f | ||
|
|
34c5360a4d | ||
|
|
228dea1308 | ||
|
|
69dd6fe7d4 | ||
|
|
7a1e250016 | ||
|
|
00d6495752 | ||
|
|
bebc119c13 | ||
|
|
717d9a512d | ||
|
|
9814914b83 | ||
|
|
693dc6d9ce |
1
.eslintrc.js
Normal file
1
.eslintrc.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require("./packages/config/eslint-preset");
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -22,6 +22,6 @@ Any other relevant information. For example, why do you consider this a bug and
|
||||
|
||||
### Technical details
|
||||
|
||||
- Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
|
||||
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://cal.com/slack
|
||||
about: Ask a general question about the project on our Slack workspace
|
||||
about: Ask a general question about the project on our Slack workspace
|
||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -20,12 +20,15 @@ Fixes # (issue)
|
||||
- [ ] Test A
|
||||
- [ ] Test B
|
||||
|
||||
## Checklist:
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code and corrected any misspellings
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
<!-- Please remove all the irrelevant bullets to your PR -->
|
||||
|
||||
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
- My code doesn't follow the style guidelines of this project
|
||||
- I haven't performed a self-review of my own code and corrected any misspellings
|
||||
- I haven't commented my code, particularly in hard-to-understand areas
|
||||
- I haven't checked if my PR needs changes to the documentation
|
||||
- I haven't checked if my changes generate no new warnings
|
||||
- I haven't added tests that prove my fix is effective or that my feature works
|
||||
- I haven't checked if new and existing unit tests pass locally with my changes
|
||||
|
||||
9
.github/workflows/e2e.yml
vendored
9
.github/workflows/e2e.yml
vendored
@@ -27,10 +27,11 @@ jobs:
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
# NEXTAUTH_URL: xxx
|
||||
# EMAIL_FROM: xxx
|
||||
# EMAIL_SERVER_HOST: xxx
|
||||
# EMAIL_SERVER_PORT: xxx
|
||||
# EMAIL_SERVER_USER: xxx
|
||||
EMAIL_FROM: e2e@cal.com
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
# MS_GRAPH_CLIENT_ID: xxx
|
||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# ZOOM_CLIENT_ID: xxx
|
||||
|
||||
24
.github/workflows/lint.yml
vendored
24
.github/workflows/lint.yml
vendored
@@ -15,10 +15,30 @@ jobs:
|
||||
- name: Use Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
version: 14.x
|
||||
node-version: 14.x
|
||||
cache: "yarn"
|
||||
cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Install deps
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
run: yarn lint:report
|
||||
continue-on-error: true
|
||||
|
||||
- name: Merge lint reports
|
||||
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
|
||||
|
||||
- name: Annotate Code Linting Results
|
||||
uses: ataylorme/eslint-annotate-action@1.2.0
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
report-json: "lint-results/eslint_report.json"
|
||||
|
||||
- name: Upload ESLint report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: lint-results
|
||||
path: lint-results
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -59,5 +59,15 @@ yarn-error.log*
|
||||
|
||||
# Typescript
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
.turbo
|
||||
|
||||
# Prisma-Zod
|
||||
packages/prisma/zod/*.ts
|
||||
|
||||
# Builds
|
||||
dist
|
||||
|
||||
# Linting
|
||||
lint-results
|
||||
|
||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[submodule "apps/api"]
|
||||
path = apps/api
|
||||
url = git@github.com:calcom/api.git
|
||||
[submodule "apps/website"]
|
||||
path = apps/website
|
||||
url = git@github.com:calcom/website.git
|
||||
6
.husky/post-receive
Executable file
6
.husky/post-receive
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
echo "Updating submodules recursively"
|
||||
pwd
|
||||
git submodule update --init --recursive
|
||||
@@ -4,5 +4,5 @@ version = 1
|
||||
autoupdate_label = "♻️ autoupdate"
|
||||
|
||||
[approve]
|
||||
auto_approve_usernames = ["dependabot", "PeerRich", "baileypumfleet"]
|
||||
auto_approve_usernames = ["dependabot"]
|
||||
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
module.exports = {
|
||||
bracketSpacing: true,
|
||||
bracketSameLine: true,
|
||||
singleQuote: false,
|
||||
jsxSingleQuote: false,
|
||||
trailingComma: "es5",
|
||||
semi: true,
|
||||
printWidth: 110,
|
||||
arrowParens: "always",
|
||||
importOrder: ["^@(calcom|ee)/(.*)$", "^@lib/(.*)$", "^@components/(.*)$", "^@(server|trpc)/(.*)$", "^[./]"],
|
||||
importOrderSeparation: true,
|
||||
plugins: [require("./merged-prettier-plugin")],
|
||||
};
|
||||
module.exports = require("./packages/config/prettier-preset");
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.github
|
||||
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -7,6 +7,6 @@
|
||||
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
|
||||
"ban.spellright", // Spell check for docs
|
||||
"stripe.vscode-stripe", // stripe VSCode extension
|
||||
"Prisma.prisma" // syntax|format|completion for prisma
|
||||
"Prisma.prisma" // syntax|format|completion for prisma
|
||||
]
|
||||
}
|
||||
|
||||
79
CONTRIBUTING.md
Normal file
79
CONTRIBUTING.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
requests should be made against. The changes on the `main`
|
||||
branch are tagged into a release monthly.
|
||||
|
||||
To develop locally:
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
|
||||
own GitHub account and then
|
||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch:
|
||||
|
||||
```sh
|
||||
git checkout -b MY_BRANCH_NAME
|
||||
```
|
||||
|
||||
3. Install yarn:
|
||||
|
||||
```sh
|
||||
npm install -g yarn
|
||||
```
|
||||
|
||||
4. Install the dependencies with:
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
5. Start developing and watch for code changes:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
You can build the project with:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
Please be sure that you can make a full production build before pushing code.
|
||||
|
||||
## Testing
|
||||
|
||||
More info on how to add new tests coming soon.
|
||||
|
||||
### Running tests
|
||||
|
||||
This will run and test all flows in multiple Chromium windows to verify that no critical flow breaks:
|
||||
|
||||
```sh
|
||||
yarn test-e2e
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
To check the formatting of your code:
|
||||
|
||||
```sh
|
||||
yarn lint
|
||||
```
|
||||
|
||||
If you get errors, be sure to fix them before committing.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- 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 you 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. Se 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.
|
||||
42
README.md
42
README.md
@@ -68,7 +68,7 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
||||
|
||||
Cal officially launched as v.1.0 on 15th of September, however a lot of new features are coming. Watch **releases** of this repository to be notified for future updates:
|
||||
|
||||

|
||||

|
||||
|
||||
<!-- GETTING STARTED -->
|
||||
|
||||
@@ -124,6 +124,14 @@ Here is what you need to be able to run Cal.
|
||||
yarn dx
|
||||
```
|
||||
|
||||
#### Development tip
|
||||
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `apps/web/.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
|
||||
```sh
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
||||
```
|
||||
|
||||
#### Manual setup
|
||||
|
||||
1. Configure environment variables in the .env file. Replace `<user>`, `<pass>`, `<db-host>`, `<db-port>` with their applicable values
|
||||
@@ -163,13 +171,13 @@ yarn dx
|
||||
1. Set up the database using the Prisma schema (found in `apps/web/prisma/schema.prisma`)
|
||||
|
||||
```sh
|
||||
npx prisma migrate deploy
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
|
||||
1. Run (in development mode)
|
||||
|
||||
```sh
|
||||
yarn dev --scope=@calcom/web
|
||||
yarn dev
|
||||
```
|
||||
|
||||
#### Setting up your first user
|
||||
@@ -177,12 +185,12 @@ yarn dx
|
||||
1. Open [Prisma Studio](https://www.prisma.io/studio) to look at or modify the database content:
|
||||
|
||||
```sh
|
||||
npx prisma studio
|
||||
yarn db-studio
|
||||
```
|
||||
|
||||
1. Click on the `User` model to add a new user record.
|
||||
1. Fill out the fields `email`, `username`, `password`, and set `metadata` to empty `{}` (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
|
||||
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `prisma/schema.prisma` file.
|
||||
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `apps/web/prisma/schema.prisma` file.
|
||||
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
||||
|
||||
### E2E-Testing
|
||||
@@ -210,7 +218,7 @@ yarn workspace @calcom/web playwright-report
|
||||
In a development environment, run:
|
||||
|
||||
```sh
|
||||
npx prisma migrate dev
|
||||
yarn workspace @calcom/prisma db-migrate
|
||||
```
|
||||
|
||||
(this can clear your development database in some cases)
|
||||
@@ -218,7 +226,7 @@ yarn workspace @calcom/web playwright-report
|
||||
In a production environment, run:
|
||||
|
||||
```sh
|
||||
npx prisma migrate deploy
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
|
||||
3. Check the `.env.example` and compare it to your current `.env` file. In case there are any fields not present
|
||||
@@ -233,14 +241,14 @@ yarn workspace @calcom/web playwright-report
|
||||
4. Start the server. In a development environment, just do:
|
||||
|
||||
```sh
|
||||
yarn dev --scope=@calcom/web
|
||||
yarn dev
|
||||
```
|
||||
|
||||
For a production build, run for example:
|
||||
|
||||
```sh
|
||||
yarn build --scope=@calcom/web
|
||||
yarn start --scope=@calcom/web
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
5. Enjoy the new version.
|
||||
@@ -272,20 +280,17 @@ You can deploy Cal on [Railway](https://railway.app/) using the button above. Th
|
||||
|
||||
## Roadmap
|
||||
|
||||
See the [open issues](https://github.com/calcom/cal.com/issues) for a list of proposed features (and known issues).
|
||||
See the [roadmap project](https://github.com/orgs/calcom/projects/1) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Please see our [contributing guide](/CONTRIBUTING.md).
|
||||
|
||||
1. Fork the project
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Make your changes
|
||||
4. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
5. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
6. Open a pull request
|
||||
### Good First Issues
|
||||
|
||||
We have a list of [good first issues](https://github.com/calcom/cal.com/labels/✅%20good%20first%20issue) that contain bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||
|
||||
## Integrations
|
||||
|
||||
@@ -334,6 +339,7 @@ Contributions are what make the open source community such an amazing place to b
|
||||
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
3. Copy your API key.
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
5. If you have the [Daily Scale Plan](https://www.daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
|
||||
2
app.json
2
app.json
@@ -21,6 +21,6 @@
|
||||
"JWT_SECRET": "secret"
|
||||
},
|
||||
"scripts": {
|
||||
"postdeploy": "cd apps/web && npx prisma migrate deploy"
|
||||
"postdeploy": "cd packages/prisma && npx prisma migrate deploy"
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/api
Submodule
1
apps/api
Submodule
Submodule apps/api added at 378cbf8f3a
@@ -1 +0,0 @@
|
||||
module.exports = require("@calcom/config/eslint-preset");
|
||||
5
apps/docs/next-env.d.ts
vendored
Normal file
5
apps/docs/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const withNextra = require("nextra")({
|
||||
theme: "nextra-theme-docs",
|
||||
themeConfig: "./theme.config.js",
|
||||
|
||||
@@ -4,17 +4,24 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "next",
|
||||
"start": "next start",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "PORT=4000 next",
|
||||
"lint": "next lint",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
|
||||
"start": "PORT=4000 next start",
|
||||
"build": "next build"
|
||||
},
|
||||
"author": "Cal.com, Inc.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"next": "^12.0.9",
|
||||
"next": "^12.1.0",
|
||||
"nextra": "^1.1.0",
|
||||
"nextra-theme-docs": "^1.2.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/config": "*",
|
||||
"eslint": "^8.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "nextra-theme-docs/style.css";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
export default function Nextra({ Component, pageProps }) {
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Availability
|
||||
---
|
||||
|
||||
# Availability
|
||||
|
||||
## Setting your availability
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Billing
|
||||
---
|
||||
|
||||
# Billing
|
||||
## How the trial works
|
||||
You are given FREE access for 14 days of our PRO subscription, you can use this to test and try out our product and see if it works for you. No credit card is required to sign up and you decide if you want to upgrade to a PRO subscription afterwards.
|
||||
@@ -26,7 +30,7 @@ We've reserved a ton of premium usernames, such as short handles or first names
|
||||
Some users may not be able to access Billing as their billing email is different to their account email. If this is the case, you can change the email associated with your account in [Profile Settings](https://app.cal.com/settings/profile).
|
||||
|
||||
## Subscription for each team member
|
||||
If your team requires multiple event types then each team member has to be subscribed to our paid plan. If that is something that isn’t necessary for your team, you can proceed with your FREE plan.
|
||||
If your team requires multiple event types then each team member has to be subscribed to our paid plan. If that is something that isn’t necessary for your team, you can proceed with your FREE plan.
|
||||
|
||||
## Discount for non-profits and students
|
||||
We offer 50% for non-profit organizations and students. Just raise a ticket with our support team and submit the necessary proof of status.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Bookings
|
||||
---
|
||||
|
||||
# Bookings
|
||||
|
||||
## What can you do on the bookings page?
|
||||
|
||||
83
apps/docs/pages/contributing.mdx
Normal file
83
apps/docs/pages/contributing.mdx
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Contributing
|
||||
---
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
requests should be made against. The changes on the `main`
|
||||
branch are tagged into a release monthly.
|
||||
|
||||
To develop locally:
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
|
||||
own GitHub account and then
|
||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch:
|
||||
|
||||
```sh
|
||||
git checkout -b MY_BRANCH_NAME
|
||||
```
|
||||
|
||||
3. Install yarn:
|
||||
|
||||
```sh
|
||||
npm install -g yarn
|
||||
```
|
||||
|
||||
4. Install the dependencies with:
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
5. Start developing and watch for code changes:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
You can build the project with:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
Please be sure that you can make a full production build before pushing code.
|
||||
|
||||
## Testing
|
||||
|
||||
More info on how to add new tests coming soon.
|
||||
|
||||
### Running tests
|
||||
|
||||
This will run and test all flows in multiple Chromium windows to verify that no critical flow breaks:
|
||||
|
||||
```sh
|
||||
yarn test-e2e
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
To check the formatting of your code:
|
||||
|
||||
```sh
|
||||
yarn lint
|
||||
```
|
||||
|
||||
If you get errors, be sure to fix them before comitting.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- 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 you 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. Se 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.
|
||||
@@ -1,4 +1,8 @@
|
||||
import Callout from 'nextra-theme-docs/callout';
|
||||
---
|
||||
title: Adding CSS
|
||||
---
|
||||
|
||||
import Callout from "nextra-theme-docs/callout";
|
||||
|
||||
# Adding CSS
|
||||
|
||||
@@ -30,4 +34,4 @@ import "../styles/your-new-stylesheet.css";
|
||||
These styles will apply to all pages and components in your application.
|
||||
</Callout>
|
||||
|
||||
Due to the global nature of stylesheets, and to avoid conflicts, you may **only import them inside `pages/_app.tsx`**.
|
||||
Due to the global nature of stylesheets, and to avoid conflicts, you may **only import them inside `pages/_app.tsx`**.
|
||||
|
||||
45
apps/docs/pages/developer/app-store.mdx
Normal file
45
apps/docs/pages/developer/app-store.mdx
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Contributing to App Store
|
||||
---
|
||||
|
||||
# Contributing to the App Store
|
||||
|
||||
Since Cal.com is open source we encourage developers to create new apps for others to use. This guide is to help you get started.
|
||||
|
||||
## Structure
|
||||
|
||||
All apps can be found under `packages/app-store`. In this folder is `_example` which shows the general structure of an app.
|
||||
|
||||
```sh
|
||||
├──_example
|
||||
| ├──index.ts
|
||||
| ├──package.json
|
||||
| ├──.env.example
|
||||
|
|
||||
| ├──api
|
||||
| | ├──example.ts
|
||||
| | ├──index.ts
|
||||
|
|
||||
| ├──lib
|
||||
| | ├──adaptor.ts
|
||||
| | ├──index.ts
|
||||
|
|
||||
| ├──static
|
||||
| | ├──icon.svg
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
In the `package.json` name your package appropriately and list the dependencies needed for the package.
|
||||
|
||||
Next in the `.env.example` specify the environmental variables (ex. auth token, API secrets) that your app will need. In a comment add a link to instructions on how to obtain the credentials. Create a `.env` with your the filled in environmental variables.
|
||||
|
||||
In `index.js` fill out the meta data that will be rendered on the app page. Under `packages/app-store/index.ts`, import your app and add it under `appStore`. Your app should now appear in the app store.
|
||||
|
||||
Under the `/api` folder, this is where any API calls that are associated with your app will be handled. Since cal.com uses Next.js we use dynamic API routes. In this example if we want to hit `/api/example.ts` the route would be `{BASE_URL}/api/integrations/_example/example`. Export your endpoints in an `index.ts` file under `/api` folder and import them in your main `index.ts` file.
|
||||
|
||||
The `/lib` folder is where the functions of your app live. For example, when creating a booking with a MS Teams link the function to make the call to grab the link lives in the `/lib` folder. Export your endpoints in an `index.ts` file under `/lib` folder and import them in your main `index.ts` file.
|
||||
|
||||
The `/static` folder is where your assets live.
|
||||
|
||||
If you need any help feel free to join us on [Slack](https://cal.com/slack)
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Code styling
|
||||
---
|
||||
|
||||
# Code Styling
|
||||
Keeping our code styles consistent is key to making the repository easy to read and work with.
|
||||
|
||||
@@ -12,4 +16,4 @@ Calendso uses the ESLint and Prettier formatting tools, and the repository comes
|
||||
We use the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript) for all JavaScript and Typescript code.
|
||||
|
||||
## HTML & CSS
|
||||
We use the [Google HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html) for any HTML and CSS markup. However, exceptions to the HTML guide apply where JSX differentiates from standard HTML.
|
||||
We use the [Google HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html) for any HTML and CSS markup. However, exceptions to the HTML guide apply where JSX differentiates from standard HTML.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"migrations": "Migrations",
|
||||
"pre-fill": "Pre-fill fields",
|
||||
"code-styling": "Code styling",
|
||||
"project-structure": "Project structure",
|
||||
"pull-requests": "Pull requests",
|
||||
"adding-css": "Adding CSS"
|
||||
}
|
||||
|
||||
"migrations": "Migrations",
|
||||
"pre-fill": "Pre-fill fields",
|
||||
"code-styling": "Code styling",
|
||||
"project-structure": "Project structure",
|
||||
"pull-requests": "Pull requests",
|
||||
"adding-css": "Adding CSS",
|
||||
"app-store": "Contributing to App Store"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: Migrations
|
||||
---
|
||||
|
||||
# Database Migrations
|
||||
As described in the [upgrade guide](https://docs.cal.com/self-hosting/upgrading.md), you should use the `npx prisma migrate dev` or `npx prisma migrate deploy` command to update the database.
|
||||
As described in the [upgrade guide](https://docs.cal.com/self-hosting/upgrade), you should use the `yarn workspace @calcom/prisma db-migrate` or `yarn workspace @calcom/prisma db-deploy` command to update the database.
|
||||
|
||||
We use database migrations in order to handle changes to the database schema in a more secure and stable way. This is actually very common. The thing is that when just changing the schema in `schema.prisma` without creating migrations, the update to the newer database schema can damage or delete all data in production mode, since the system sometimes doesn't know how to transform the data from A to B. Using migrations, each step is reproducable, transparent and can be undone in a simple way.
|
||||
|
||||
@@ -7,8 +11,8 @@ We use database migrations in order to handle changes to the database schema in
|
||||
If you are modifying the codebase and make a change to the `schema.prisma` file, you must create a migration.
|
||||
|
||||
To create a migration for your previously changed `schema.prisma`, simply run the following:
|
||||
```
|
||||
npx prisma migrate dev
|
||||
```sh
|
||||
yarn workspace @calcom/prisma db-migrate
|
||||
```
|
||||
|
||||
Now, you must create a short name for your migration to describe what changed (for example, "user_add_email_verified"). Then just add and commit it with the corresponding code that uses your new database schema.
|
||||
@@ -21,7 +25,7 @@ Always keep an eye on what migrations Prisma is generating. Prisma often happily
|
||||
|
||||
## Error: The database schema is not empty
|
||||
Prisma uses a database called `_prisma_migrations` to keep track of which migrations have been applied and which haven't. If your local migrations database doesn't match up with what's in the actual database, then Prisma will throw the following error:
|
||||
```
|
||||
```text
|
||||
Error: P3005
|
||||
|
||||
The database schema for `localhost:5432` is not empty. Read more about how to baseline an existing production database: https://pris.ly/d/migrate-baseline
|
||||
@@ -30,8 +34,8 @@ The database schema for `localhost:5432` is not empty. Read more about how to ba
|
||||
In order to fix this, we need to tell Prisma which migrations have already been applied.
|
||||
|
||||
This can be done by running the following command, replacing `migration_name` with each migration that you have already applied:
|
||||
```
|
||||
npx prisma migrate resolve --applied migration_name
|
||||
```sh
|
||||
yarn prisma migrate resolve --applied migration_name
|
||||
```
|
||||
|
||||
You will need to run the command for each migration that you want to mark as applied.
|
||||
You will need to run the command for each migration that you want to mark as applied.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Pre-fill fields
|
||||
---
|
||||
|
||||
# Pre-fill fields
|
||||
|
||||
You can pre-fill a number of fields on the booking form by using their corresponding URL parameters. This can include the user’s name, email, or guests to be added to the booking.
|
||||
@@ -17,4 +21,4 @@ Guests can also be added to the link, there is also no limit to the amount of gu
|
||||
These should be added to your link like this:
|
||||
```text
|
||||
guest=guest1@example.com&guest=guest2@example.com
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Project structure
|
||||
---
|
||||
|
||||
# Project Structure
|
||||
|
||||
This page gives an overview of how the codebase is structured so you can easily dive into the Cal.com code.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Pull requests
|
||||
---
|
||||
|
||||
# Pull Requests
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Event Types
|
||||
---
|
||||
|
||||
# Event Types
|
||||
Event types allow you to create different events for different occasions when booking a time with you in your calendar. These can be named differently, have different time durations and the choice of platform can change.
|
||||
|
||||
@@ -13,7 +17,7 @@ Event types allow you to create different events for different occasions when bo
|
||||
|
||||
## Editing event types
|
||||
1. Go to [Your Event Types](https://app.cal.com/event-types).
|
||||
2. Click anywhere within the box of the event you would like to edit.
|
||||
2. Click anywhere within the box of the event you would like to edit.
|
||||
(From here you can edit the basic settings of your event)
|
||||
3. To get the advanced options, at the bottom of your event setting click 'Show Advanced Settings'
|
||||
4. After you have finished editing the event type, scroll to the bottom of your page and select 'Update'
|
||||
@@ -27,7 +31,7 @@ Event types allow you to create different events for different occasions when bo
|
||||
## How to block a time slot before/after a meeting
|
||||
You can block out a time frame in your calendar only after the meeting. You can do this by selecting `Show advanced settings` of your Event Type. The setting is labeled `Time-slot intervals`.
|
||||
|
||||
## Setting up specific availability for each type of Event
|
||||
## Setting up specific availability for each type of Event
|
||||
Head to `Show advanced settings` of your event. At the bottom you can set up specific availability for different Event Types.
|
||||
|
||||
## Availability not showing on a certain day in your calendar
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: FAQs
|
||||
---
|
||||
|
||||
# Frequently asked questions
|
||||
|
||||
## Does Cal.com support a custom domain?
|
||||
@@ -7,7 +11,7 @@ This is possible with our self-hosted option.
|
||||
As it stands this is currently not possible. We always keep an eye on the limitations like these that our users point to us. We’ve had requests in the past for the multi-booking feature and this is on our priority list.
|
||||
|
||||
## How to quickly block further bookings?
|
||||
1. Click on the lower left corner of your dashboard where your username is displayed.
|
||||
1. Click on the lower left corner of your dashboard where your username is displayed.
|
||||
2. That initates a dropdown menu. Click on `Set youself as away`.
|
||||
|
||||
This is a method to disable your Cal.com account which won't allow any bookings once initiated. However, bookings made before turning on *away mode* will still be booked.
|
||||
@@ -1,4 +1,8 @@
|
||||
import Callout from 'nextra-theme-docs/callout';
|
||||
---
|
||||
title: Import
|
||||
---
|
||||
|
||||
import Callout from "nextra-theme-docs/callout";
|
||||
|
||||
# Import data from other scheduling tools
|
||||
|
||||
@@ -27,4 +31,4 @@ The following steps will help you retrieve your SavvyCal access token, which you
|
||||
4. Click to copy the token, and then paste the token into the Cal.com importer
|
||||
<Callout>
|
||||
Even though we don't store your access token, you can press the trash icon to revoke the access token from the **Developers** tab once the import is complete.
|
||||
</Callout>
|
||||
</Callout>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import Bleed from 'nextra-theme-docs/bleed'
|
||||
---
|
||||
title: Home
|
||||
---
|
||||
|
||||
import Bleed from "nextra-theme-docs/bleed";
|
||||
|
||||
# Cal.com Documentation
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Google
|
||||
---
|
||||
|
||||
# Google Calendar
|
||||
The Google Calendar integration checks for availability in your Google Calendars and creates bookings for you.
|
||||
|
||||
@@ -27,10 +31,10 @@ To remove a product from your account that isn't listed in your Google Account,
|
||||
|
||||
## Where to find the Google Meet integration?
|
||||
|
||||
Google Meet is a part of the Google Calendar integration and it should be available once you've added your Google Calendar. Just select Google Meet as location for your Event Type:
|
||||
Google Meet is a part of the Google Calendar integration and it should be available once you've added your Google Calendar. Just select Google Meet as location for your Event Type:
|
||||
|
||||
1. Go to your `Event Types`.
|
||||
2. Click on the `Location` drop-down menu.
|
||||
3. Select Google Meet as the location of your meeting.
|
||||
3. Select Google Meet as the location of your meeting.
|
||||
|
||||
Once your Event Type slot is booked, it will automatically generate the Google Meet link for the meeting.
|
||||
Once your Event Type slot is booked, it will automatically generate the Google Meet link for the meeting.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Introduction
|
||||
---
|
||||
|
||||
# Integrations
|
||||
|
||||
## Connecting new calendars
|
||||
@@ -13,5 +17,5 @@
|
||||
If you have two or more integrated calendars and you want your events to show in only one, you can define a primary calendar like this:
|
||||
|
||||
1. Go to your [Integrations](https://app.cal.com/integrations) page.
|
||||
2. Next to your `Calendars` you will see a dropdown that says `Create events on:`.
|
||||
3. Select your primary calendar.
|
||||
2. Next to your `Calendars` you will see a dropdown that says `Create events on:`.
|
||||
3. Select your primary calendar.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Microsoft
|
||||
---
|
||||
|
||||
# Outlook/Microsoft 365
|
||||
The Outlook integration enables you to use your outlook.com or Microsoft 365 account to use for conflict checking and event bookings.
|
||||
|
||||
@@ -17,4 +21,4 @@ The top part of permissions window shows what you personally consented to. Examp
|
||||
|
||||
You can revoke any of the permissions you consented to by selecting `Revoke Permissions`, however removing a permission may break some of the apps functionality. If you have problems after you remove permissions or accounts, contact your organization's Helpdesk for additional assistance.
|
||||
|
||||
If you require more help, head over the Microsoft Documentation Page about [Managing Applications](https://docs.microsoft.com/en-us/azure/active-directory/user-help/my-applications-portal-permissions-saved-accounts)
|
||||
If you require more help, head over the Microsoft Documentation Page about [Managing Applications](https://docs.microsoft.com/en-us/azure/active-directory/user-help/my-applications-portal-permissions-saved-accounts)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Stripe
|
||||
---
|
||||
|
||||
# Stripe Payments
|
||||
|
||||
The Stripe integration allows users to add payments to their bookings.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## Do you have a Zapier integration?
|
||||
---
|
||||
title: Zapier
|
||||
---
|
||||
|
||||
We are currently working on it, but it isn’t live just yet. Until then, you can use our Webhooks integration and use Zapier's “Webhooks by Zapier”.
|
||||
## Do you have a Zapier integration?
|
||||
|
||||
We are currently working on it, but it isn’t live just yet. Until then, you can use our Webhooks integration and use Zapier's “Webhooks by Zapier”.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Zoom
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
"import": "Import",
|
||||
"billing": "Billing",
|
||||
"developer": "Developer",
|
||||
"contributing": "Contributing",
|
||||
"faq": "FAQs"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Docker
|
||||
---
|
||||
|
||||
# Docker
|
||||
|
||||
The Docker configuration for Cal is an effort powered by people within the community. Cal does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
||||
@@ -15,7 +19,7 @@ Make sure you have `docker` & `docker-compose` installed on the server / system.
|
||||
```bash
|
||||
docker pull calendso/calendso
|
||||
```
|
||||
|
||||
|
||||
or
|
||||
|
||||
### Option #2: Cloning
|
||||
@@ -30,12 +34,12 @@ or
|
||||
|
||||
3. Build and start calendso
|
||||
|
||||
```
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
4. Start prisma studio
|
||||
```
|
||||
```bash
|
||||
docker-compose exec calendso -- npx prisma studio
|
||||
```
|
||||
5. Open a browser to [port 5555](http://localhost:5555) on your localhost to look at or modify the database content.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Installation
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
To get a local copy up and running, please follow these simple steps.
|
||||
@@ -52,62 +56,63 @@ yarn dx
|
||||
|
||||
1. Configure database in the `packages/prisma/.env` file. Replace `<user>`, `<pass>`, `<db-host>`, `<db-port>` with their applicable values
|
||||
|
||||
```text
|
||||
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
||||
```
|
||||
```text
|
||||
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
||||
```
|
||||
|
||||
<details>
|
||||
<details>
|
||||
|
||||
<summary>
|
||||
If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick DB
|
||||
using Heroku
|
||||
</summary>
|
||||
<summary>
|
||||
If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick DB
|
||||
using Heroku
|
||||
</summary>
|
||||
|
||||
1. Create a free account with [Heroku](https://www.heroku.com/).
|
||||
1. Create a free account with [Heroku](https://www.heroku.com/).
|
||||
|
||||
2. Create a new app.
|
||||
2. Create a new app.
|
||||
|
||||
<img
|
||||
width="306"
|
||||
alt="Create an App"
|
||||
src="https://user-images.githubusercontent.com/16905768/115322780-b3d58c00-a17e-11eb-8a52-b758fb0ea942.png"
|
||||
/>
|
||||
<img
|
||||
width="306"
|
||||
alt="Create an App"
|
||||
src="https://user-images.githubusercontent.com/16905768/115322780-b3d58c00-a17e-11eb-8a52-b758fb0ea942.png"
|
||||
/>
|
||||
|
||||
3. In your new app, go to `Overview` and next to `Installed add-ons`, click `Configure Add-ons`. We need this to set up our database.
|
||||

|
||||
3. In your new app, go to `Overview` and next to `Installed add-ons`, click `Configure Add-ons`. We need this to set up our database.
|
||||

|
||||
|
||||
4. Once you clicked on `Configure Add-ons`, click on `Find more add-ons` and search for `postgres`. One of the options will be `Heroku Postgres` - click on that option.
|
||||

|
||||
4. Once you clicked on `Configure Add-ons`, click on `Find more add-ons` and search for `postgres`. One of the options will be `Heroku Postgres` - click on that option.
|
||||

|
||||
|
||||
5. Once the pop-up appears, click `Submit Order Form` - plan name should be `Hobby Dev - Free`.
|
||||
5. Once the pop-up appears, click `Submit Order Form` - plan name should be `Hobby Dev - Free`.
|
||||
|
||||
<img
|
||||
width="512"
|
||||
alt="Submit Order Form"
|
||||
src="https://user-images.githubusercontent.com/16905768/115323265-b4baed80-a17f-11eb-99f0-d67f019aa6df.png"
|
||||
/>
|
||||
<img
|
||||
width="512"
|
||||
alt="Submit Order Form"
|
||||
src="https://user-images.githubusercontent.com/16905768/115323265-b4baed80-a17f-11eb-99f0-d67f019aa6df.png"
|
||||
/>
|
||||
|
||||
6. Once you completed the above steps, click on your newly created `Heroku Postgres` and go to its `Settings`.
|
||||

|
||||
6. Once you completed the above steps, click on your newly created `Heroku Postgres` and go to its `Settings`.
|
||||

|
||||
|
||||
7. In `Settings`, copy your URI to your Cal.com .env file and replace the `postgresql://<user>:<pass>@<db-host>:<db-port>` with it.
|
||||

|
||||

|
||||
7. In `Settings`, copy your URI to your Cal.com .env file and replace the `postgresql://<user>:<pass>@<db-host>:<db-port>` with it.
|
||||

|
||||

|
||||
|
||||
8. To view your DB, once you add new data in Prisma, you can use [Heroku Data Explorer](https://heroku-data-explorer.herokuapp.com/).
|
||||
</details>
|
||||
8. To view your DB, once you add new data in Prisma, you can use [Heroku Data Explorer](https://heroku-data-explorer.herokuapp.com/).
|
||||
|
||||
</details>
|
||||
|
||||
1. Set a 32 character random string in your `apps/web/.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
||||
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
|
||||
|
||||
```sh
|
||||
npx prisma migrate deploy
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
|
||||
1. Run (in development mode)
|
||||
|
||||
```sh
|
||||
yarn dev --scope=@calcom/web
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Setting up your first user
|
||||
@@ -115,7 +120,7 @@ yarn dx
|
||||
1. Open [Prisma Studio](https://www.prisma.io/studio) to look at or modify the database content:
|
||||
|
||||
```sh
|
||||
npx prisma studio
|
||||
yarn db-studio
|
||||
```
|
||||
|
||||
1. Click on the `User` model to add a new user record.
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
---
|
||||
title: Upgrade
|
||||
---
|
||||
|
||||
# Upgrading
|
||||
|
||||
**Warning**: When performing database migrations, you may lose data if the migration is not done properly.
|
||||
|
||||
1. Pull the current version:
|
||||
```
|
||||
```sh
|
||||
git pull
|
||||
```
|
||||
2. Apply database migrations by running <b>one of</b> the following commands:
|
||||
|
||||
In a development environment, run:
|
||||
|
||||
```
|
||||
npx prisma migrate dev
|
||||
```sh
|
||||
yarn workspace @calcom/prisma db-migrate
|
||||
```
|
||||
|
||||
(this can clear your development database in some cases)
|
||||
|
||||
In a production environment, run:
|
||||
|
||||
```
|
||||
npx prisma migrate deploy
|
||||
```sh
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
|
||||
3. Check the `.env.example` and compare it to your current `.env` file. In case there are any fields not present
|
||||
@@ -27,17 +31,17 @@
|
||||
|
||||
For the current version, especially check if the variable `BASE_URL` is present and properly set in your environment, for example:
|
||||
|
||||
```
|
||||
```text
|
||||
BASE_URL='https://yourdomain.com'
|
||||
```
|
||||
|
||||
4. Start the server. In a development environment, just do:
|
||||
```
|
||||
```sh
|
||||
yarn dev
|
||||
```
|
||||
For a production build, run for example:
|
||||
```
|
||||
```sh
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
5. Enjoy the new version.
|
||||
5. Enjoy the new version.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Vercel
|
||||
---
|
||||
|
||||
# Vercel
|
||||
|
||||
## Requirements
|
||||
@@ -22,15 +26,15 @@ You need a PostgresDB database hosted somewhere. [Heroku](https://www.heroku.com
|
||||
yarn install
|
||||
```
|
||||
|
||||
4. Set up the database using the Prisma schema (found in `prisma/schema.prisma`)
|
||||
4. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
|
||||
|
||||
```sh
|
||||
npx prisma migrate deploy
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
|
||||
5. Open [Prisma Studio](https://www.prisma.io/studio) to look at or modify the database content:
|
||||
```
|
||||
npx prisma studio
|
||||
yarn db-studio
|
||||
```
|
||||
6. Click on the `User` model to add a new user record.
|
||||
7. Fill out the fields (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
|
||||
@@ -42,5 +46,5 @@ You need a PostgresDB database hosted somewhere. [Heroku](https://www.heroku.com
|
||||
1. Import from your forked repository
|
||||
1. Set the Environment Variables
|
||||
1. Set the root directory to `apps/web`
|
||||
1. Override the build command to `cd ../.. && npx turbo run build --scope=@calcom/web --include-dependencies --no-deps`
|
||||
1. Override the build command to `cd ../.. && yarn build`
|
||||
1. Hit Deploy
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Settings
|
||||
---
|
||||
|
||||
# Settings
|
||||
## Setting up or making changes to your Profile
|
||||
|
||||
@@ -21,7 +25,7 @@
|
||||
4. Click the button 'Save' located to the bottom right of your new password.
|
||||
5. You have now successfully changed your password!
|
||||
|
||||
## Change your email
|
||||
## Change your email
|
||||
|
||||
Go to [Profile Settings](https://app.cal.com/settings/profile). There, you will see the email associated with your account which you can then update. You’d just need to log out and back in to see the change take effect.
|
||||
|
||||
@@ -49,4 +53,4 @@ You can delete your account from within the [Settings](https://app.cal.com/setti
|
||||
|
||||
## How to change the language
|
||||
|
||||
Go to your [Profile Settings](https://app.cal.com/settings/profile). Under `Language` you will see the dropdown menu and you can use it to select your desired language.
|
||||
Go to your [Profile Settings](https://app.cal.com/settings/profile). Under `Language` you will see the dropdown menu and you can use it to select your desired language.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Teams
|
||||
---
|
||||
|
||||
# Teams
|
||||
|
||||
## How do I create a new team?
|
||||
@@ -25,7 +29,7 @@ Creating a team will allow you to create new event types for the team, invite te
|
||||
## How do I add and remove a description of my team?
|
||||
|
||||
1. Go to [Your Teams Settings](https://app.cal.com/settings/teams) and select the team you wish to edit.
|
||||
2. Located below your team name entry box is a large text box labeled 'About',
|
||||
2. Located below your team name entry box is a large text box labeled 'About',
|
||||
|
||||
## How do I upload my team logo?
|
||||
|
||||
@@ -50,4 +54,4 @@ Your team has now successfully been deleted.
|
||||
|
||||
## Where can I find my team's Event Types?
|
||||
|
||||
Once you open `Event Types` on your dashboard, you will find your team's Event Types below your individual ones.
|
||||
Once you open `Event Types` on your dashboard, you will find your team's Event Types below your individual ones.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
---
|
||||
title: Webhooks
|
||||
---
|
||||
|
||||
# Webhooks
|
||||
|
||||
## Create a new Webhook
|
||||
|
||||
1. Go to [Your Integrations](https://app.cal.com/integrations).
|
||||
@@ -19,22 +24,75 @@
|
||||
3. Press the button and from here your webhook will no longer work and be deleted.
|
||||
|
||||
## Webhook metadata
|
||||
|
||||
Metadata is a way to pass extra information to Cal.com about a booking that is returned through a webhook.
|
||||
|
||||
### Example
|
||||
|
||||
The best way to explain this is with an example. Let's say you're a bank, and people register an account on your website, but you want them to book an onboarding call with your team to get set up. You could send them to your Cal.com booking link as part of your onboarding process, but when the webhook is returned, it may be difficult to match up which user booked a meeting with the user's account in your own database. Hence, you can pass a `user_id` value for instance as a URL parameter, which makes no difference to the booking process, but when the webhook is returned, you will receive the metadata as part of the webhook payload.
|
||||
|
||||
Metadata is passed as a URL parameter on top of your booking link and follows the following syntax:
|
||||
|
||||
```text
|
||||
metadata[key_name]=value
|
||||
```
|
||||
|
||||
For example, if your booking link is `cal.com/rick/quick-chat`, you can pass a user ID of 123 like so:
|
||||
|
||||
```text
|
||||
cal.com/rick/quick-chat?metadata[user_id]=123
|
||||
```
|
||||
|
||||
As a result, the webhook will be returned in this format:
|
||||
|
||||
```text
|
||||
{ <other event details>, metadata: { user_id: 123 } }
|
||||
```
|
||||
\{ <other event details>, metadata: \{ user_id: 123 \} \}
|
||||
```
|
||||
|
||||
## Custom Webhooks template variable list
|
||||
|
||||
Customizable webhooks are a great way reduce the development effort and in many cases remove the need for a developer to build an additional integration service. Using a custom template you can easily decide what data you receive in your webhook endpoint, manage the payload and setup related workflows accordingly. Here’s a breakdown of the payload that you would receive via an incoming webhook.
|
||||
|
||||
### Webhook structure
|
||||
|
||||
| Variable | Type | Description |
|
||||
| ------------------- | -------- | -------------------------------------------------------------------------------------- |
|
||||
| triggerEvent | String | The name of the trigger event [BOOKING_CREATED, BOOKING_RESHEDULED, BOOKING_CANCELLED] |
|
||||
| createdAt | String | The time of the webhook trigger |
|
||||
| type | String | The event-type slug |
|
||||
| title | String | The event-type name |
|
||||
| startTime | String | The event's start time |
|
||||
| endTime | String | The event's end time |
|
||||
| description? | String | The event's description as described in the event type |
|
||||
| location? | String | Location of the event |
|
||||
| organizer | Person | The organizer of the event |
|
||||
| attendees | Person[] | The event booker & any guests |
|
||||
| uid? | String | The UID of the booking |
|
||||
| resheduleUid? | String | The UID for the rescheduling |
|
||||
| cancellationReason? | String | Reason for cancellation |
|
||||
| rejectionReason? | String | Reason for rejection |
|
||||
| team?.name | String | Name of the team booked |
|
||||
| team?.members | String[] | Members of the team booked |
|
||||
|
||||
### Person structure
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --------------- | ------ | --------------------------------------------------------------------- |
|
||||
| name | String | Name of the individual |
|
||||
| email | String | Email of the individual |
|
||||
| timeZone | String | Timezone of the individual ("America/New_York", "Asia/Kolkata", etc.) |
|
||||
| language.locale | String | Locale of the individual ("en", "fr", etc.) |
|
||||
|
||||
### Example usage of variables for custom template:
|
||||
|
||||
```sh
|
||||
\{
|
||||
|
||||
"content": "A new event has been scheduled",
|
||||
"type": "\{\{type\}\}",
|
||||
"name": "\{\{title\}\}",
|
||||
"organizer": "\{\{organizer.name\}\}",
|
||||
"booker": "\{\{attendees.0.name\}\}"
|
||||
|
||||
\}
|
||||
```
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
export default {
|
||||
github: 'https://github.com/calcom/docs',
|
||||
docsRepositoryBase: 'https://github.com/calcom/docs/blob/master',
|
||||
titleSuffix: ' | Cal.com',
|
||||
logo: (
|
||||
<h4 className="m-0">
|
||||
Cal.com
|
||||
</h4>
|
||||
),
|
||||
const themeConfig = {
|
||||
github: "https://github.com/calcom/cal.com",
|
||||
docsRepositoryBase: "https://github.com/calcom/cal.com/blob/main/apps/docs/pages",
|
||||
titleSuffix: " | Cal.com",
|
||||
logo: <h4 className="m-0">Cal.com</h4>,
|
||||
head: (
|
||||
<>
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
@@ -29,23 +25,9 @@ export default {
|
||||
<meta name="og:image" content="https://cal.com/og-image.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cal.com Docs" />
|
||||
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
|
||||
<meta name="msapplication-TileColor" content="#ff0000" />
|
||||
@@ -56,8 +38,8 @@ export default {
|
||||
prevLinks: true,
|
||||
nextLinks: true,
|
||||
footer: true,
|
||||
footerEditLink: 'Edit this page on GitHub',
|
||||
footerText: (
|
||||
<>© {new Date().getFullYear()} Cal.com, Inc. All rights reserved.</>
|
||||
),
|
||||
}
|
||||
footerEditLink: "Edit this page on GitHub",
|
||||
footerText: <>© {new Date().getFullYear()} Cal.com, Inc. All rights reserved.</>,
|
||||
};
|
||||
|
||||
export default themeConfig;
|
||||
|
||||
5
apps/docs/tsconfig.json
Normal file
5
apps/docs/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -99,3 +99,6 @@ CALENDSO_ENCRYPTION_KEY=
|
||||
|
||||
# Intercom Config
|
||||
NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require("@calcom/config/eslint-preset");
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@calcom/prisma/client";
|
||||
@@ -18,7 +18,7 @@ export default function AddToHomescreen() {
|
||||
<div className="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex w-0 flex-1 items-center">
|
||||
<span className="bg-brand text-brandcontrast flex rounded-lg bg-opacity-30 p-2">
|
||||
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
|
||||
<svg
|
||||
className="h-7 w-7 fill-current text-indigo-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
|
||||
const brandColor = "#292929";
|
||||
const brandTextColor = "#ffffff";
|
||||
const darkBrandColor = "#fafafa";
|
||||
|
||||
export function colorNameToHex(color: string) {
|
||||
const colors = {
|
||||
@@ -174,8 +175,24 @@ function hexToRGB(hex: string) {
|
||||
return [parseInt(color.slice(0, 2), 16), parseInt(color.slice(2, 4), 16), parseInt(color.slice(4, 6), 16)];
|
||||
}
|
||||
|
||||
function getContrastingTextColor(bgColor: string | null): string {
|
||||
bgColor = bgColor == "" || bgColor == null ? brandColor : bgColor;
|
||||
function normalizeHexCode(hex: string | null, dark: boolean) {
|
||||
if (!hex) {
|
||||
return !dark ? brandColor : darkBrandColor;
|
||||
}
|
||||
hex = hex.replace("#", "");
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split("")
|
||||
.map(function (hex) {
|
||||
return hex + hex;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function getContrastingTextColor(bgColor: string | null, dark: boolean): string {
|
||||
bgColor = bgColor == "" || bgColor == null ? (dark ? darkBrandColor : brandColor) : bgColor;
|
||||
const rgb = hexToRGB(bgColor);
|
||||
const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]);
|
||||
const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929
|
||||
@@ -191,18 +208,41 @@ export function isValidHexCode(val: string | null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function fallBackHex(val: string | null): string {
|
||||
export function fallBackHex(val: string | null, dark: boolean): string {
|
||||
if (val) if (colorNameToHex(val)) return colorNameToHex(val) as string;
|
||||
return brandColor;
|
||||
return dark ? darkBrandColor : brandColor;
|
||||
}
|
||||
|
||||
const BrandColor = ({ val = brandColor }: { val: string | undefined | null }) => {
|
||||
const BrandColor = ({
|
||||
lightVal = brandColor,
|
||||
darkVal = darkBrandColor,
|
||||
}: {
|
||||
lightVal: string | undefined | null;
|
||||
darkVal: string | undefined | null;
|
||||
}) => {
|
||||
// convert to 6 digit equivalent if 3 digit code is entered
|
||||
lightVal = normalizeHexCode(lightVal, false);
|
||||
darkVal = normalizeHexCode(darkVal, true);
|
||||
// ensure acceptable hex-code
|
||||
val = isValidHexCode(val) ? (val?.indexOf("#") === 0 ? val : "#" + val) : fallBackHex(val);
|
||||
lightVal = isValidHexCode(lightVal)
|
||||
? lightVal?.indexOf("#") === 0
|
||||
? lightVal
|
||||
: "#" + lightVal
|
||||
: fallBackHex(lightVal, false);
|
||||
darkVal = isValidHexCode(darkVal)
|
||||
? darkVal?.indexOf("#") === 0
|
||||
? darkVal
|
||||
: "#" + darkVal
|
||||
: fallBackHex(darkVal, true);
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("--brand-color", val);
|
||||
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val));
|
||||
}, [val]);
|
||||
document.documentElement.style.setProperty("--brand-color", lightVal);
|
||||
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true));
|
||||
document.documentElement.style.setProperty("--brand-color-dark-mode", darkVal);
|
||||
document.documentElement.style.setProperty(
|
||||
"--brand-text-color-dark-mode",
|
||||
getContrastingTextColor(darkVal, true)
|
||||
);
|
||||
}, [lightVal, darkVal]);
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,55 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
|
||||
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
|
||||
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]> & {
|
||||
name?: string;
|
||||
clearQueryParamsOnClose?: string[];
|
||||
};
|
||||
export function Dialog(props: DialogProps) {
|
||||
const { children, ...other } = props;
|
||||
const router = useRouter();
|
||||
const { children, name, ...dialogProps } = props;
|
||||
// only used if name is set
|
||||
const [open, setOpen] = useState(!!dialogProps.open);
|
||||
|
||||
if (name) {
|
||||
const clearQueryParamsOnClose = ["dialog", ...(props.clearQueryParamsOnClose || [])];
|
||||
dialogProps.onOpenChange = (open) => {
|
||||
if (props.onOpenChange) {
|
||||
props.onOpenChange(open);
|
||||
}
|
||||
// toggles "dialog" query param
|
||||
if (open) {
|
||||
router.query["dialog"] = name;
|
||||
} else {
|
||||
clearQueryParamsOnClose.forEach((queryParam) => {
|
||||
delete router.query[queryParam];
|
||||
});
|
||||
}
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
setOpen(open);
|
||||
};
|
||||
// handles initial state
|
||||
if (!open && router.query["dialog"] === name) {
|
||||
setOpen(true);
|
||||
}
|
||||
// allow overriding
|
||||
if (!("open" in dialogProps)) {
|
||||
dialogProps.open = open;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root {...other}>
|
||||
<DialogPrimitive.Root {...dialogProps}>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ImageUploader({
|
||||
<DialogContent>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 className="font-cal text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
<h3 className="font-cal text-lg leading-6 text-gray-900" id="modal-title">
|
||||
{t("upload_target", { target })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default function Loader() {
|
||||
return (
|
||||
<div className="loader border-brand dark:border-white">
|
||||
<span className="loader-inner bg-brand dark:bg-white"></span>
|
||||
<div className="loader border-brand dark:border-darkmodebrand">
|
||||
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ import { Toaster } from "react-hot-toast";
|
||||
|
||||
import LicenseBanner from "@ee/components/LicenseBanner";
|
||||
import TrialBanner from "@ee/components/TrialBanner";
|
||||
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
|
||||
import IntercomMenuItem from "@ee/lib/intercom/IntercomMenuItem";
|
||||
import ZendeskMenuItem from "@ee/lib/zendesk/ZendeskMenuItem";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants";
|
||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
@@ -37,6 +39,7 @@ import Dropdown, {
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import pkg from "../package.json";
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
import Button from "./ui/Button";
|
||||
@@ -61,7 +64,7 @@ function useRedirectToLoginIfUnauthenticated() {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `${location.pathname}${location.search}`,
|
||||
callbackUrl: `${NEXT_PUBLIC_BASE_URL}/${location.pathname}${location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -79,11 +82,11 @@ function useRedirectToOnboardingIfNeeded() {
|
||||
const user = query.data;
|
||||
|
||||
const [isRedirectingToOnboarding, setRedirecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && shouldShowOnboarding(user)) {
|
||||
setRedirecting(true);
|
||||
}
|
||||
user && setRedirecting(shouldShowOnboarding(user));
|
||||
}, [router, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRedirectingToOnboarding) {
|
||||
router.replace({
|
||||
@@ -191,7 +194,7 @@ export default function Shell(props: {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CustomBranding val={user?.brandColor} />
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<HeadSeo
|
||||
title={pageTitle ?? "Cal.com"}
|
||||
description={props.subtitle ? props.subtitle?.toString() : ""}
|
||||
@@ -246,7 +249,7 @@ export default function Shell(props: {
|
||||
</nav>
|
||||
</div>
|
||||
<TrialBanner />
|
||||
<div className="m-2 rounded-sm p-2 pt-2 pr-2 hover:bg-gray-100">
|
||||
<div className="rounded-sm pt-2 pb-2 pl-3 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2">
|
||||
<span className="hidden lg:inline">
|
||||
<UserDropdown />
|
||||
</span>
|
||||
@@ -254,6 +257,11 @@ export default function Shell(props: {
|
||||
<UserDropdown small />
|
||||
</span>
|
||||
</div>
|
||||
<small style={{ fontSize: "0.5rem" }} className="mx-3 mt-1 mb-2 hidden opacity-50 lg:block">
|
||||
© {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"}
|
||||
{process.env.NEXT_PUBLIC_APP_URL === "https://cal.com" ? "h" : "sh"}
|
||||
<span className="lowercase">-{user && user.plan}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,9 +310,7 @@ export default function Shell(props: {
|
||||
<div className="block min-h-[80px] justify-between px-4 sm:flex sm:px-6 md:px-8">
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="mb-8 w-full">
|
||||
<h1 className="font-cal mb-1 text-xl font-bold tracking-wide text-gray-900">
|
||||
{props.heading}
|
||||
</h1>
|
||||
<h1 className="font-cal mb-1 text-xl text-gray-900">{props.heading}</h1>
|
||||
<p className="text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
|
||||
</div>
|
||||
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
|
||||
@@ -379,7 +385,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={
|
||||
(process.env.NEXT_PUBLIC_APP_URL || process.env.BASE_URL) +
|
||||
(process.env.NEXT_PUBLIC_APP_URL || process.env.NEXT_PUBLIC_BASE_URL) +
|
||||
"/" +
|
||||
user?.username +
|
||||
"/avatar.png"
|
||||
@@ -411,7 +417,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent portalled={true}>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => {
|
||||
@@ -454,7 +460,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
viewBox="0 0 2447.6 2452.5"
|
||||
className={classNames(
|
||||
"text-gray-500 group-hover:text-gray-700",
|
||||
"mt-0.5 h-4 w-4 flex-shrink-0 ltr:mr-2 rtl:ml-2"
|
||||
"mt-0.5 h-4 w-4 flex-shrink-0 ltr:mr-4 rtl:ml-4"
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipRule="evenodd" fillRule="evenodd">
|
||||
@@ -484,7 +490,8 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<MapIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("visit_roadmap")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<HelpMenuItemDynamic />
|
||||
<IntercomMenuItem />
|
||||
<ZendeskMenuItem />
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
|
||||
@@ -14,6 +14,8 @@ import Loader from "@components/Loader";
|
||||
type AvailableTimesProps = {
|
||||
timeFormat: string;
|
||||
minimumBookingNotice: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
slotInterval: number | null;
|
||||
@@ -33,6 +35,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
timeFormat,
|
||||
users,
|
||||
schedulingType,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
}) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -45,6 +49,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
schedulingType,
|
||||
users,
|
||||
minimumBookingNotice,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
eventTypeId,
|
||||
});
|
||||
|
||||
@@ -95,7 +101,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
<Link href={bookingUrl}>
|
||||
<a
|
||||
className={classNames(
|
||||
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-brand dark:hover:text-brandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
||||
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@components/Dialog";
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import { TextArea } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
@@ -16,6 +17,9 @@ import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
function BookingListItem(booking: BookingItem) {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
@@ -120,7 +124,8 @@ function BookingListItem(booking: BookingItem) {
|
||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
</td>
|
||||
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
||||
@@ -165,7 +170,9 @@ function BookingListItem(booking: BookingItem) {
|
||||
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
|
||||
{!booking.confirmed && !booking.rejected && user!.id === booking.user!.id && (
|
||||
<TableActions actions={pendingActions} />
|
||||
)}
|
||||
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
|
||||
{!booking.confirmed && booking.rejected && (
|
||||
<div className="text-sm text-gray-500">{t("rejected")}</div>
|
||||
|
||||
@@ -4,11 +4,13 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import dayjsBusinessTime from "dayjs-business-time";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { memoize } from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import { doWorkAsync } from "@lib/doWorkAsync";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import getSlots from "@lib/slots";
|
||||
import { WorkingHours } from "@lib/types/schedule";
|
||||
@@ -51,10 +53,8 @@ function isOutOfBounds(
|
||||
switch (periodType) {
|
||||
case PeriodType.ROLLING: {
|
||||
const periodRollingEndDay = periodCountCalendarDays
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
|
||||
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
|
||||
? dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
|
||||
: dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
|
||||
return date.endOf("day").isAfter(periodRollingEndDay);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,13 @@ function DatePicker({
|
||||
const [month, setMonth] = useState<string>("");
|
||||
const [year, setYear] = useState<string>("");
|
||||
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
||||
|
||||
const [daysFromState, setDays] = useState<
|
||||
| {
|
||||
disabled: Boolean;
|
||||
date: number;
|
||||
}[]
|
||||
| null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
|
||||
setBrowsingDate(date || dayjs().tz(timeZone()));
|
||||
@@ -101,13 +107,57 @@ function DatePicker({
|
||||
setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
|
||||
setYear(browsingDate.format("YYYY"));
|
||||
setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
|
||||
setDays(null);
|
||||
}
|
||||
}, [browsingDate, i18n.language]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const isDisabled = (
|
||||
day: number,
|
||||
{
|
||||
browsingDate,
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}
|
||||
) => {
|
||||
const date = browsingDate.startOf("day").date(day);
|
||||
return (
|
||||
isOutOfBounds(date, {
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
}) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
eventLength,
|
||||
}).length
|
||||
);
|
||||
};
|
||||
|
||||
const isDisabledRef = useRef(
|
||||
memoize(isDisabled, (day, { browsingDate }) => {
|
||||
// Make a composite cache key
|
||||
return day + "_" + browsingDate.toString();
|
||||
})
|
||||
);
|
||||
|
||||
const days = (() => {
|
||||
if (!browsingDate) {
|
||||
return [];
|
||||
}
|
||||
if (daysFromState) {
|
||||
return daysFromState;
|
||||
}
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = browsingDate.date(1).day();
|
||||
if (weekStart === "Monday") {
|
||||
@@ -117,33 +167,49 @@ function DatePicker({
|
||||
|
||||
const days = Array(weekdayOfFirst).fill(null);
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
const date = browsingDate.startOf("day").date(day);
|
||||
return (
|
||||
isOutOfBounds(date, {
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
}) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}).length
|
||||
);
|
||||
};
|
||||
const isDisabledMemoized = isDisabledRef.current;
|
||||
|
||||
const daysInMonth = browsingDate.daysInMonth();
|
||||
const daysInitialOffset = days.length;
|
||||
|
||||
// Build UI with All dates disabled
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({ disabled: isDisabled(i), date: i });
|
||||
days.push({
|
||||
disabled: true,
|
||||
date: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Update dates with their availability
|
||||
doWorkAsync({
|
||||
batch: 1,
|
||||
name: "DatePicker",
|
||||
length: daysInMonth,
|
||||
callback: (i: number, isLast) => {
|
||||
let day = i + 1;
|
||||
days[daysInitialOffset + i] = {
|
||||
disabled: isDisabledMemoized(day, {
|
||||
browsingDate,
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}),
|
||||
date: day,
|
||||
};
|
||||
},
|
||||
batchDone: () => {
|
||||
setDays([...days]);
|
||||
},
|
||||
});
|
||||
|
||||
return days;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browsingDate]);
|
||||
})();
|
||||
|
||||
if (!browsingDate) {
|
||||
return <Loader />;
|
||||
@@ -213,7 +279,7 @@ function DatePicker({
|
||||
"hover:border-brand hover:border dark:hover:border-white",
|
||||
day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium",
|
||||
date && date.isSame(browsingDate.date(day.date), "day")
|
||||
? "bg-brand text-brandcontrast"
|
||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
|
||||
: !day.disabled
|
||||
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
|
||||
: ""
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// TODO: replace headlessui with radix-ui
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
@@ -13,7 +12,7 @@ type Props = {
|
||||
onToggle24hClock: (is24hClock: boolean) => void;
|
||||
};
|
||||
|
||||
const TimeOptions: FC<Props> = (props) => {
|
||||
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone }) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [is24hClock, setIs24hClock] = useState(false);
|
||||
const { t } = useLocale();
|
||||
@@ -25,44 +24,28 @@ const TimeOptions: FC<Props> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
|
||||
props.onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
}
|
||||
}, [selectedTimeZone]);
|
||||
|
||||
}, [selectedTimeZone, onSelectTimeZone]);
|
||||
const handle24hClockToggle = (is24hClock: boolean) => {
|
||||
setIs24hClock(is24hClock);
|
||||
props.onToggle24hClock(is24h(is24hClock));
|
||||
onToggle24hClock(is24h(is24hClock));
|
||||
};
|
||||
|
||||
return selectedTimeZone !== "" ? (
|
||||
<div className="max-w-80 absolute z-10 w-full rounded-sm border border-gray-200 bg-white px-4 py-2 dark:border-0 dark:bg-gray-700">
|
||||
<div className="mb-4 flex">
|
||||
<div className="w-1/2 font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="ltr:mr-3">
|
||||
<span className="text-sm text-gray-500 dark:text-white">{t("am_pm")}</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={handle24hClockToggle}
|
||||
className={classNames(
|
||||
is24hClock ? "bg-brand text-brandcontrast" : "bg-gray-200 dark:bg-gray-600",
|
||||
"relative inline-flex h-5 w-8 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2"
|
||||
)}>
|
||||
<span className="sr-only">{t("use_setting")}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
is24hClock ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ltr:ml-3 rtl:mr-3">
|
||||
<span className="text-sm text-gray-500 dark:text-white">{t("24_h")}</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
<div className="font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
|
||||
{t("am_pm")}
|
||||
</label>
|
||||
<Switch
|
||||
name="24hClock"
|
||||
label={t("24_h")}
|
||||
defaultChecked={is24hClock}
|
||||
onCheckedChange={handle24hClockToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
// Get router variables
|
||||
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
@@ -11,10 +18,12 @@ import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
@@ -32,7 +41,7 @@ dayjs.extend(customParseFormat);
|
||||
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Props) => {
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { isReady, Theme } = useTheme(profile.theme);
|
||||
@@ -62,11 +71,13 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
}, [router.query.date]);
|
||||
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState("h:mma");
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||
}, [telemetry]);
|
||||
|
||||
@@ -107,7 +118,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
username={profile.slug || undefined}
|
||||
// avatar={profile.image || undefined}
|
||||
/>
|
||||
<CustomBranding val={profile.brandColor} />
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
<main
|
||||
className={
|
||||
@@ -120,6 +131,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
<div className="flex items-center">
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-900 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
@@ -164,10 +176,11 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
<div className="px-4 sm:flex sm:p-4 sm:py-5">
|
||||
<div
|
||||
className={
|
||||
"hidden pr-8 sm:border-r sm:dark:border-gray-800 md:block " +
|
||||
"hidden pr-8 sm:border-r sm:dark:border-gray-800 md:flex md:flex-col " +
|
||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-900 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
@@ -207,6 +220,15 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
<TimezoneDropdown />
|
||||
|
||||
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
|
||||
{previousPage === `${BASE_URL}/${profile.slug}` && (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<ArrowLeftIcon
|
||||
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
<p className="sr-only">Go Back</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
@@ -236,6 +258,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
date={selectedDate}
|
||||
users={eventType.users}
|
||||
schedulingType={eventType.schedulingType ?? null}
|
||||
beforeBufferTime={eventType.beforeEventBuffer}
|
||||
afterBufferTime={eventType.afterEventBuffer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CalendarIcon, ClockIcon, CreditCardIcon, ExclamationIcon } from "@heroi
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -12,7 +13,7 @@ import { ReactMultiEmail } from "react-multi-email";
|
||||
import { useMutation } from "react-query";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { createPaymentLink } from "@ee/lib/stripe/client";
|
||||
import { createPaymentLink } from "@calcom/stripe/client";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
@@ -24,6 +25,7 @@ import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import { parseZone } from "@lib/parseZone";
|
||||
import slugify from "@lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import { EmailInput, Form } from "@components/form/fields";
|
||||
@@ -50,25 +52,24 @@ type BookingFormValues = {
|
||||
};
|
||||
};
|
||||
|
||||
const BookingPage = (props: BookingPageProps) => {
|
||||
const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { contracts } = useContracts();
|
||||
const { eventType } = props;
|
||||
|
||||
const { data: session } = useSession();
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
|
||||
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/* @ts-ignore */
|
||||
router.replace(`/${eventOwner.username}`);
|
||||
}
|
||||
}, [contracts, eventType.metadata.smartContractAddress, router]);
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async ({ attendees, paymentUid, ...responseData }) => {
|
||||
onSuccess: async (responseData) => {
|
||||
const { attendees, paymentUid } = responseData;
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -84,9 +85,6 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (location === "integrations:jitsi") {
|
||||
return "https://meet.jit.si/cal/" + uuidv4();
|
||||
}
|
||||
if (location.includes("integration")) {
|
||||
return t("web_conferencing_details_to_follow");
|
||||
}
|
||||
@@ -97,8 +95,8 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date,
|
||||
type: props.eventType.id,
|
||||
user: props.profile.slug,
|
||||
type: eventType.id,
|
||||
user: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
@@ -109,20 +107,18 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
});
|
||||
|
||||
const rescheduleUid = router.query.rescheduleUid as string;
|
||||
const { isReady, Theme } = useTheme(props.profile.theme);
|
||||
|
||||
const { isReady, Theme } = useTheme(profile.theme);
|
||||
const date = asStringOrNull(router.query.date);
|
||||
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
|
||||
|
||||
const [guestToggle, setGuestToggle] = useState(props.booking && props.booking.attendees.length > 1);
|
||||
const [guestToggle, setGuestToggle] = useState(booking && booking.attendees.length > 1);
|
||||
|
||||
const eventTypeDetail = { isWeb3Active: false, ...props.eventType };
|
||||
const eventTypeDetail = { isWeb3Active: false, ...eventType };
|
||||
|
||||
type Location = { type: LocationType; address?: string };
|
||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||
const locations: Location[] = useMemo(
|
||||
() => (props.eventType.locations as Location[]) || [],
|
||||
[props.eventType.locations]
|
||||
() => (eventType.locations as Location[]) || [],
|
||||
[eventType.locations]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,15 +142,15 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
[LocationType.Huddle01]: "Huddle01 Video",
|
||||
[LocationType.Tandem]: "Tandem Video",
|
||||
};
|
||||
|
||||
const loggedInIsOwner = eventType.users[0].name === session?.user.name;
|
||||
const defaultValues = () => {
|
||||
if (!rescheduleUid) {
|
||||
return {
|
||||
name: (router.query.name as string) || "",
|
||||
email: (router.query.email as string) || "",
|
||||
name: loggedInIsOwner ? "" : session?.user?.name || (router.query.name as string) || "",
|
||||
email: loggedInIsOwner ? "" : session?.user?.email || (router.query.email as string) || "",
|
||||
notes: (router.query.notes as string) || "",
|
||||
guests: ensureArray(router.query.guest) as string[],
|
||||
customInputs: props.eventType.customInputs.reduce(
|
||||
customInputs: eventType.customInputs.reduce(
|
||||
(customInputs, input) => ({
|
||||
...customInputs,
|
||||
[input.id]: router.query[slugify(input.label)],
|
||||
@@ -163,17 +159,17 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!props.booking || !props.booking.attendees.length) {
|
||||
if (!booking || !booking.attendees.length) {
|
||||
return {};
|
||||
}
|
||||
const primaryAttendee = props.booking.attendees[0];
|
||||
const primaryAttendee = booking.attendees[0];
|
||||
if (!primaryAttendee) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
name: primaryAttendee.name || "",
|
||||
email: primaryAttendee.email || "",
|
||||
guests: props.booking.attendees.slice(1).map((attendee) => attendee.email),
|
||||
guests: booking.attendees.slice(1).map((attendee) => attendee.email),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -213,7 +209,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
if (!date) return "No date";
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(timeFormat);
|
||||
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
@@ -239,7 +235,6 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
let web3Details;
|
||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||
web3Details = {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
userWallet: window.web3.currentProvider.selectedAddress,
|
||||
userSignature: contracts[(eventTypeDetail.metadata.smartContractAddress || null) as number],
|
||||
@@ -250,18 +245,18 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(props.eventType.length, "minute").format(),
|
||||
eventTypeId: props.eventType.id,
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(booking.locationType ? booking : { locationType: selectedLocation }),
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
});
|
||||
@@ -274,52 +269,51 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
<title>
|
||||
{rescheduleUid
|
||||
? t("booking_reschedule_confirmation", {
|
||||
eventTypeTitle: props.eventType.title,
|
||||
profileName: props.profile.name,
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})
|
||||
: t("booking_confirmation", {
|
||||
eventTypeTitle: props.eventType.title,
|
||||
profileName: props.profile.name,
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})}{" "}
|
||||
| Cal.com
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<CustomBranding val={props.profile.brandColor} />
|
||||
<main className=" mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<main className="mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
|
||||
{isReady && (
|
||||
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-neutral-900 sm:rounded-sm">
|
||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
||||
<AvatarGroup
|
||||
border="border-2 border-white dark:border-gray-900"
|
||||
size={14}
|
||||
items={[{ image: props.profile.image || "", alt: props.profile.name || "" }].concat(
|
||||
props.eventType.users
|
||||
.filter((user) => user.name !== props.profile.name)
|
||||
items={[{ image: profile.image || "", alt: profile.name || "" }].concat(
|
||||
eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
image: user.avatar || "",
|
||||
alt: user.name || "",
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
<h2 className="font-cal mt-2 font-medium text-gray-500 dark:text-gray-300">
|
||||
{props.profile.name}
|
||||
</h2>
|
||||
<h2 className="font-cal mt-2 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
|
||||
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
|
||||
{props.eventType.title}
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<p className="mb-2 text-gray-500">
|
||||
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{props.eventType.length} {t("minutes")}
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{props.eventType.price > 0 && (
|
||||
{eventType.price > 0 && (
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
|
||||
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={props.eventType.price / 100.0}
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={props.eventType.currency.toUpperCase()}
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
@@ -333,7 +327,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p>
|
||||
<p className="mb-8 text-gray-600 dark:text-white">{eventType.description}</p>
|
||||
</div>
|
||||
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
@@ -348,7 +342,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder={t("example_name")}
|
||||
/>
|
||||
</div>
|
||||
@@ -363,7 +357,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
<EmailInput
|
||||
{...bookingForm.register("email")}
|
||||
required
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -397,13 +391,18 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
{t("phone_number")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<PhoneInput name="phone" placeholder={t("enter_phone_number")} id="phone" required />
|
||||
<PhoneInput
|
||||
// @ts-expect-error
|
||||
control={bookingForm.control}
|
||||
name="phone"
|
||||
placeholder={t("enter_phone_number")}
|
||||
id="phone"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{props.eventType.customInputs
|
||||
{eventType.customInputs
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((input) => (
|
||||
<div className="mb-4" key={input.id}>
|
||||
@@ -421,7 +420,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
rows={3}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
@@ -432,7 +431,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
@@ -443,7 +442,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
@@ -467,7 +466,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!props.eventType.disableGuests && (
|
||||
{!eventType.disableGuests && (
|
||||
<div className="mb-4">
|
||||
{!guestToggle && (
|
||||
<label
|
||||
@@ -525,7 +524,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
{...bookingForm.register("notes")}
|
||||
id="notes"
|
||||
rows={3}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder={t("share_additional_notes")}
|
||||
/>
|
||||
</div>
|
||||
@@ -539,7 +538,9 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
</div>
|
||||
</Form>
|
||||
{mutation.isError && (
|
||||
<div className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||
<div
|
||||
data-testid="booking-fail"
|
||||
className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
|
||||
@@ -52,15 +52,13 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DialogPrimitive.Title className="font-cal text-xl font-bold text-gray-900">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
||||
{children}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 gap-x-2 sm:mt-8 sm:flex sm:flex-row-reverse">
|
||||
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
||||
<DialogClose onClick={onConfirm} asChild>
|
||||
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
|
||||
</DialogClose>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { createEventTypeInput } from "@calcom/prisma/zod/eventtypeCustom";
|
||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import showToast from "@lib/notification";
|
||||
import { slugify } from "@lib/slugify";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
|
||||
@@ -48,7 +48,6 @@ interface Props {
|
||||
export default function CreateEventTypeButton(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const modalOpen = useToggleQuery("new");
|
||||
|
||||
// URL encoded params
|
||||
const teamId: number | undefined =
|
||||
@@ -67,7 +66,7 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
if (name === "title" && type === "change") {
|
||||
if (value.title) setValue("slug", value.title.replace(/\s+/g, "-").toLowerCase());
|
||||
if (value.title) setValue("slug", slugify(value.title));
|
||||
else setValue("slug", "");
|
||||
}
|
||||
});
|
||||
@@ -94,44 +93,33 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openModal = (option: EventTypeParent) => {
|
||||
// setTimeout fixes a bug where the url query params are removed immediately after opening the modal
|
||||
setTimeout(() => {
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
new: "1",
|
||||
eventPage: option.slug,
|
||||
teamId: option.teamId || undefined,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// remove url params after close modal to reset state
|
||||
const closeModal = () => {
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { id: router.query.id || undefined },
|
||||
});
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "new-eventtype",
|
||||
eventPage: option.slug,
|
||||
teamId: option.teamId,
|
||||
};
|
||||
if (!option.teamId) {
|
||||
delete query.teamId;
|
||||
}
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={modalOpen.isOn}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) closeModal();
|
||||
}}>
|
||||
<Dialog name="new-eventtype" clearQueryParamsOnClose={["eventPage", "teamId"]}>
|
||||
{!hasTeams || props.isIndividualTeam ? (
|
||||
<Button
|
||||
onClick={() => openModal(props.options[0])}
|
||||
data-testid="new-event-type"
|
||||
StartIcon={PlusIcon}
|
||||
{...(props.canAddEvents ? { href: modalOpen.hrefOn } : { disabled: true })}>
|
||||
disabled={!props.canAddEvents}>
|
||||
{t("new_event_type_btn")}
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
@@ -11,7 +13,6 @@ import { List } from "@components/List";
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
import Switch from "@components/ui/Switch";
|
||||
|
||||
import ConnectIntegration from "./ConnectIntegrations";
|
||||
import DisconnectIntegration from "./DisconnectIntegration";
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants";
|
||||
import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration";
|
||||
import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration";
|
||||
|
||||
@@ -17,7 +18,7 @@ export default function ConnectIntegration(props: {
|
||||
|
||||
const mutation = useMutation(async () => {
|
||||
const state: IntegrationOAuthCallbackState = {
|
||||
returnTo: location.pathname + location.search,
|
||||
returnTo: NEXT_PUBLIC_BASE_URL + location.pathname + location.search,
|
||||
};
|
||||
const stateStr = encodeURIComponent(JSON.stringify(state));
|
||||
const searchParams = `?state=${stateStr}`;
|
||||
|
||||
@@ -73,7 +73,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
@@ -89,7 +89,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
<input
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
@@ -120,11 +120,11 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
<div className="mt-5 flex space-x-2 sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-black" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
||||
|
||||
<div className="inline-block transform rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UserIcon className="text-brandcontrast h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function TeamCreate(props: Props) {
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<div className="mt-5 flex flex-row-reverse sm:mt-4">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("create_team")}
|
||||
</button>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function TeamListItem(props: Props) {
|
||||
</>
|
||||
)}
|
||||
{!isInvitee && (
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<div className="flex rtl:space-x-reverse">
|
||||
<TeamRole role={team.role} />
|
||||
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function TeamSettings(props: Props) {
|
||||
name="" // typescript requires name but we don't want component to render name label
|
||||
id="team-url"
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm">
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
<img className="mx-auto h-6" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
|
||||
)}
|
||||
{props.heading && (
|
||||
<h2 className="font-cal mt-6 text-center text-3xl font-bold text-neutral-900">{props.heading}</h2>
|
||||
<h2 className="font-cal mt-6 text-center text-3xl text-neutral-900">{props.heading}</h2>
|
||||
)}
|
||||
</div>
|
||||
{props.loading && (
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function Avatar(props: AvatarProps) {
|
||||
return title ? (
|
||||
<Tooltip.Tooltip delayDuration={300}>
|
||||
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
|
||||
<Tooltip.Content className="bg-brand text-brandcontrast rounded-sm p-2 text-sm shadow-sm">
|
||||
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white shadow-sm">
|
||||
<Tooltip.Arrow />
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
|
||||
@@ -4,9 +4,8 @@ import classNames from "@lib/classNames";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
// import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
|
||||
export type AvatarGroupProps = {
|
||||
border?: string; // this needs to be the color of the parent container background, i.e.: border-white dark:border-gray-900
|
||||
size: number;
|
||||
truncateAfter?: number;
|
||||
items: {
|
||||
@@ -18,44 +17,23 @@ export type AvatarGroupProps = {
|
||||
};
|
||||
|
||||
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
/* const truncatedAvatars: string[] =
|
||||
props.items.length > props.truncateAfter
|
||||
? props.items
|
||||
.slice(props.truncateAfter)
|
||||
.map((item) => item.title)
|
||||
.filter(Boolean)
|
||||
: [];*/
|
||||
|
||||
return (
|
||||
<ul className={classNames("-rtl:space-x-reverse flex space-x-2 overflow-hidden", props.className)}>
|
||||
<ul className={classNames(props.className)}>
|
||||
{props.items.slice(0, props.truncateAfter).map((item, idx) => {
|
||||
if (item.image != null) {
|
||||
return (
|
||||
<li key={idx} className="inline-block">
|
||||
<Avatar imageSrc={item.image} title={item.title} alt={item.alt || ""} size={props.size} />
|
||||
<li key={idx} className="-mr-2 inline-block">
|
||||
<Avatar
|
||||
className={props.border}
|
||||
imageSrc={item.image}
|
||||
title={item.title}
|
||||
alt={item.alt || ""}
|
||||
size={props.size}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{/*props.items.length > props.truncateAfter && (
|
||||
<li className="relative inline-block">
|
||||
<Tooltip.Tooltip delayDuration="300">
|
||||
<Tooltip.TooltipTrigger className="cursor-default">
|
||||
<span className="w-16 absolute bottom-1.5 border-2 border-gray-300 flex-inline items-center text-white pt-4 text-2xl top-0 rounded-full block bg-neutral-600">+1</span>
|
||||
</Tooltip.TooltipTrigger>
|
||||
{truncatedAvatars.length !== 0 && (
|
||||
<Tooltip.Content className="p-2 text-sm text-white rounded-sm shadow-sm bg-brand">
|
||||
<Tooltip.Arrow />
|
||||
<ul>
|
||||
{truncatedAvatars.map((title) => (
|
||||
<li key={title}>{title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Tooltip>
|
||||
</li>
|
||||
)*/}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
export type AvatarProps = {
|
||||
user: Pick<User, "name" | "username" | "avatar"> & { emailMd5?: string };
|
||||
@@ -11,6 +10,11 @@ export type AvatarProps = {
|
||||
alt: string;
|
||||
};
|
||||
|
||||
// defaultAvatarSrc from profile.tsx can't be used as it imports crypto
|
||||
function defaultAvatarSrc({ md5 }) {
|
||||
return `https://www.gravatar.com/avatar/${md5}?s=160&d=identicon&r=PG`;
|
||||
}
|
||||
|
||||
// An SSR Supported version of Avatar component.
|
||||
// FIXME: title support is missing
|
||||
export function AvatarSSR(props: AvatarProps) {
|
||||
|
||||
@@ -1,137 +1,3 @@
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export type ButtonBaseProps = {
|
||||
color?: "primary" | "secondary" | "minimal" | "warn";
|
||||
size?: "base" | "sm" | "lg" | "fab" | "icon";
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
StartIcon?: SVGComponent;
|
||||
EndIcon?: SVGComponent;
|
||||
shallow?: boolean;
|
||||
};
|
||||
export type ButtonProps = ButtonBaseProps &
|
||||
(
|
||||
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
|
||||
| (JSX.IntrinsicElements["button"] & { href?: never })
|
||||
);
|
||||
|
||||
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
|
||||
props: ButtonProps,
|
||||
forwardedRef
|
||||
) {
|
||||
const {
|
||||
loading = false,
|
||||
color = "primary",
|
||||
size = "base",
|
||||
StartIcon,
|
||||
EndIcon,
|
||||
shallow,
|
||||
// attributes propagated from `HTMLAnchorProps` or `HTMLButtonProps`
|
||||
...passThroughProps
|
||||
} = props;
|
||||
// Buttons are **always** disabled if we're in a `loading` state
|
||||
const disabled = props.disabled || loading;
|
||||
|
||||
// If pass an `href`-attr is passed it's `<a>`, otherwise it's a `<button />`
|
||||
const isLink = typeof props.href !== "undefined";
|
||||
const elementType = isLink ? "a" : "button";
|
||||
|
||||
const element = React.createElement(
|
||||
elementType,
|
||||
{
|
||||
...passThroughProps,
|
||||
disabled,
|
||||
ref: forwardedRef,
|
||||
className: classNames(
|
||||
// base styles independent what type of button it is
|
||||
"inline-flex items-center",
|
||||
// different styles depending on size
|
||||
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-sm",
|
||||
size === "base" && "px-3 py-2 text-sm font-medium rounded-sm",
|
||||
size === "lg" && "px-4 py-2 text-base font-medium rounded-sm",
|
||||
size === "icon" &&
|
||||
"group p-2 border rounded-sm border-transparent text-neutral-400 hover:border-gray-200 transition",
|
||||
// turn button into a floating action button (fab)
|
||||
size === "fab" ? "fixed" : "relative",
|
||||
size === "fab" && "justify-center bottom-20 right-8 rounded-full p-4 w-14 h-14",
|
||||
|
||||
// different styles depending on color
|
||||
color === "primary" &&
|
||||
(disabled
|
||||
? "border border-transparent bg-gray-400 text-white"
|
||||
: "border border-transparent dark:text-brandcontrast text-brandcontrast bg-brand dark:bg-brand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
|
||||
color === "secondary" &&
|
||||
(disabled
|
||||
? "border border-gray-200 text-gray-400 bg-white"
|
||||
: "border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900 dark:bg-transparent dark:text-white dark:border-gray-800 dark:hover:bg-gray-900"),
|
||||
color === "minimal" &&
|
||||
(disabled
|
||||
? "text-gray-400 bg-transparent"
|
||||
: "text-gray-700 bg-transparent hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-gray-100 focus:ring-neutral-500"),
|
||||
color === "warn" &&
|
||||
(disabled
|
||||
? "text-gray-400 bg-transparent"
|
||||
: "text-gray-700 bg-transparent hover:text-red-700 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
|
||||
// set not-allowed cursor if disabled
|
||||
loading ? "cursor-wait" : disabled ? "cursor-not-allowed" : "",
|
||||
props.className
|
||||
),
|
||||
// if we click a disabled button, we prevent going through the click handler
|
||||
onClick: disabled
|
||||
? (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
}
|
||||
: props.onClick,
|
||||
},
|
||||
<>
|
||||
{StartIcon && (
|
||||
<StartIcon
|
||||
className={classNames(
|
||||
"inline",
|
||||
size === "icon" ? "h-5 w-5 " : "-ml-1 h-5 w-5 ltr:mr-2 rtl:ml-2 rtl:ml-2 rtl:-mr-1"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{props.children}
|
||||
{loading && (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<svg
|
||||
className={classNames(
|
||||
"mx-4 h-5 w-5 animate-spin",
|
||||
color === "primary" ? "text-white dark:text-black" : "text-black"
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{EndIcon && <EndIcon className="-mr-1 inline h-5 w-5 ltr:ml-2 rtl:mr-2" />}
|
||||
</>
|
||||
);
|
||||
return props.href ? (
|
||||
<Link passHref href={props.href} shallow={shallow && shallow}>
|
||||
{element}
|
||||
</Link>
|
||||
) : (
|
||||
element
|
||||
);
|
||||
});
|
||||
|
||||
export default Button;
|
||||
// TODO: Remove this file once every Button is imported from `@calcom/ui`
|
||||
export * from "@calcom/ui/Button";
|
||||
export { default } from "@calcom/ui/Button";
|
||||
|
||||
@@ -25,8 +25,11 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
|
||||
({ children, ...props }, forwardedRef) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Content
|
||||
portalled={props.portalled}
|
||||
{...props}
|
||||
className="z-10 mt-1 w-48 origin-top-right rounded-sm bg-white text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
className={`${
|
||||
props.portalled ? `` : `md:-ml-[55px]`
|
||||
} z-10 mt-1 -ml-0 w-full origin-top-right rounded-sm bg-white text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}
|
||||
ref={forwardedRef}>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import React, { FC, Fragment } from "react";
|
||||
import React, { FC } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
import Dropdown, {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import Button from "./Button";
|
||||
|
||||
export type ActionType = {
|
||||
@@ -38,57 +42,28 @@ const TableActions: FC<Props> = ({ actions }) => {
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Menu as="div" className="inline-block text-left lg:hidden ">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="mt-1 border border-transparent p-2 text-neutral-400 hover:border-gray-200">
|
||||
<span className="sr-only">{t("open_options")}</span>
|
||||
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="absolute right-0 mt-2 w-56 origin-top-right divide-y divide-neutral-100 rounded-sm bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{actions.map((action) => {
|
||||
const Element = typeof action.onClick === "function" ? "span" : "a";
|
||||
return (
|
||||
<Menu.Item key={action.id} disabled={action.disabled}>
|
||||
{({ active }) => (
|
||||
<Element
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
className={classNames(
|
||||
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
|
||||
"group flex items-center px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<action.icon
|
||||
className="h-5 w-5 text-neutral-400 group-hover:text-neutral-500 ltr:mr-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{action.label}
|
||||
</Element>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<div className="inline-block text-left lg:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="h-[38px] w-[38px] cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900">
|
||||
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled>
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem key={action.id}>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full font-normal"
|
||||
href={action.href}
|
||||
StartIcon={action.icon}
|
||||
onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Headline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("font-cal text-xl font-bold text-gray-900 dark:text-white", props?.className);
|
||||
const classes = classnames("font-cal text-xl text-gray-900 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
|
||||
toggleDay(idx);
|
||||
}}
|
||||
className={`
|
||||
bg-brand text-brandcontrast
|
||||
bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast
|
||||
h-10 w-10 rounded px-3 py-1 focus:outline-none
|
||||
${activeDays[idx + 1] ? "rounded-r-none" : ""}
|
||||
${activeDays[idx - 1] ? "rounded-l-none" : ""}
|
||||
|
||||
@@ -62,7 +62,9 @@ export type ColorPickerProps = {
|
||||
};
|
||||
|
||||
const ColorPicker = (props: ColorPickerProps) => {
|
||||
const init = !isValidHexCode(props.defaultValue) ? fallBackHex(props.defaultValue) : props.defaultValue;
|
||||
const init = !isValidHexCode(props.defaultValue)
|
||||
? fallBackHex(props.defaultValue, false)
|
||||
: props.defaultValue;
|
||||
const [color, setColor] = useState(init);
|
||||
const [isOpen, toggle] = useState(false);
|
||||
const popover = useRef() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React from "react";
|
||||
import BasePhoneInput, { Props as PhoneInputProps } from "react-phone-number-input";
|
||||
import { Control } from "react-hook-form";
|
||||
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { Optional } from "@lib/types/utils";
|
||||
|
||||
export const PhoneInput = (
|
||||
props: Optional<PhoneInputProps<React.InputHTMLAttributes<HTMLInputElement>>, "onChange">
|
||||
) => (
|
||||
type PhoneInputProps = {
|
||||
value: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
|
||||
<BasePhoneInput
|
||||
{...props}
|
||||
{...rest}
|
||||
name={name}
|
||||
control={control}
|
||||
className={classNames(
|
||||
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white",
|
||||
props.className
|
||||
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
|
||||
)}
|
||||
onChange={() => {
|
||||
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
|
||||
|
||||
@@ -10,6 +10,7 @@ import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { TimeRange } from "@lib/types/schedule";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import Button from "@components/ui/Button";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
@@ -46,6 +47,10 @@ type TimeRangeFieldProps = {
|
||||
};
|
||||
|
||||
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
const [selected, setSelected] = useState<number | undefined>();
|
||||
@@ -57,7 +62,9 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||
|
||||
const getOption = (time: ConfigType) => ({
|
||||
value: dayjs(time).toDate().valueOf(),
|
||||
label: dayjs(time).utc().format("HH:mm"),
|
||||
label: dayjs(time)
|
||||
.utc()
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm"),
|
||||
// .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }),
|
||||
});
|
||||
|
||||
@@ -82,7 +89,7 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||
handleSelected(value);
|
||||
return (
|
||||
<Select
|
||||
className="w-[6rem]"
|
||||
className="w-30"
|
||||
options={options}
|
||||
onFocus={() => setOptions(timeOptions())}
|
||||
onBlur={() => setOptions([])}
|
||||
@@ -100,7 +107,7 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||
name={`${name}.end`}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
className="w-[6rem]"
|
||||
className="w-30"
|
||||
options={options}
|
||||
onFocus={() => setOptions(timeOptions({ selected }))}
|
||||
onBlur={() => setOptions([])}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import { useRef } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
interface SetTimesModalProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -21,6 +25,11 @@ export default function SetTimesModal(props: SetTimesModalProps) {
|
||||
const startMinsRef = useRef<HTMLInputElement>(null!);
|
||||
const endHoursRef = useRef<HTMLInputElement>(null!);
|
||||
const endMinsRef = useRef<HTMLInputElement>(null!);
|
||||
const [endMinuteDisable, setEndMinuteDisable] = useState(false);
|
||||
const [maximumStartTime, setMaximumStartTime] = useState({ hour: endHours, minute: 59 });
|
||||
const [minimumEndTime, setMinimumEndTime] = useState({ hour: startHours, minute: 59 });
|
||||
|
||||
const STEP = 15;
|
||||
|
||||
const isValidTime = (startTime: number, endTime: number) => {
|
||||
if (new Date(startTime) > new Date(endTime)) {
|
||||
@@ -34,6 +43,48 @@ export default function SetTimesModal(props: SetTimesModalProps) {
|
||||
return true;
|
||||
};
|
||||
|
||||
// compute dynamic range for minimum and maximum allowed hours/minutes.
|
||||
const setEdgeTimes = (
|
||||
(step) =>
|
||||
(
|
||||
startHoursRef: React.MutableRefObject<HTMLInputElement>,
|
||||
startMinsRef: React.MutableRefObject<HTMLInputElement>,
|
||||
endHoursRef: React.MutableRefObject<HTMLInputElement>,
|
||||
endMinsRef: React.MutableRefObject<HTMLInputElement>
|
||||
) => {
|
||||
//parse all the refs
|
||||
const startHour = parseInt(startHoursRef.current.value);
|
||||
let startMinute = parseInt(startMinsRef.current.value);
|
||||
const endHour = parseInt(endHoursRef.current.value);
|
||||
let endMinute = parseInt(endMinsRef.current.value);
|
||||
|
||||
//convert to dayjs object
|
||||
const startTime = dayjs(`${startHour}:${startMinute}`, "hh:mm");
|
||||
const endTime = dayjs(`${endHour}:${endMinute}`, "hh:mm");
|
||||
|
||||
//compute minimin and maximum allowed
|
||||
const maximumStartTime = endTime.subtract(step, "minute");
|
||||
const maximumStartHour = maximumStartTime.hour();
|
||||
const maximumStartMinute = startHour === endHour ? maximumStartTime.minute() : 59;
|
||||
|
||||
const minimumEndTime = startTime.add(step, "minute");
|
||||
const minimumEndHour = minimumEndTime.hour();
|
||||
const minimumEndMinute = startHour === endHour ? minimumEndTime.minute() : 0;
|
||||
|
||||
//check allow min/max minutes when the end/start hour matches
|
||||
if (startHoursRef.current.value === endHoursRef.current.value) {
|
||||
if (parseInt(startMinsRef.current.value) >= maximumStartMinute)
|
||||
startMinsRef.current.value = maximumStartMinute.toString();
|
||||
if (parseInt(endMinsRef.current.value) <= minimumEndMinute)
|
||||
endMinsRef.current.value = minimumEndMinute.toString();
|
||||
}
|
||||
|
||||
//save into state
|
||||
setMaximumStartTime({ hour: maximumStartHour, minute: maximumStartMinute });
|
||||
setMinimumEndTime({ hour: minimumEndHour, minute: minimumEndMinute });
|
||||
}
|
||||
)(STEP);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
@@ -65,7 +116,7 @@ export default function SetTimesModal(props: SetTimesModalProps) {
|
||||
</div>
|
||||
<div className="mb-4 flex">
|
||||
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("start_time")}</label>
|
||||
<div>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="startHours" className="sr-only">
|
||||
{t("hours")}
|
||||
</label>
|
||||
@@ -73,17 +124,18 @@ export default function SetTimesModal(props: SetTimesModalProps) {
|
||||
ref={startHoursRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
maxLength={2}
|
||||
max={maximumStartTime.hour}
|
||||
minLength={2}
|
||||
name="hours"
|
||||
id="startHours"
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="9"
|
||||
defaultValue={startHours}
|
||||
onChange={() => setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef)}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="startMinutes" className="sr-only">
|
||||
{t("minutes")}
|
||||
</label>
|
||||
@@ -91,27 +143,28 @@ export default function SetTimesModal(props: SetTimesModalProps) {
|
||||
ref={startMinsRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
step="15"
|
||||
max={maximumStartTime.minute}
|
||||
step={STEP}
|
||||
maxLength={2}
|
||||
name="minutes"
|
||||
id="startMinutes"
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="30"
|
||||
defaultValue={startMinutes}
|
||||
onChange={() => setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("end_time")}</label>
|
||||
<div>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="endHours" className="sr-only">
|
||||
{t("hours")}
|
||||
</label>
|
||||
<input
|
||||
ref={endHoursRef}
|
||||
type="number"
|
||||
min="0"
|
||||
min={minimumEndTime.hour}
|
||||
max="24"
|
||||
maxLength={2}
|
||||
name="hours"
|
||||
@@ -119,25 +172,32 @@ export default function SetTimesModal(props: SetTimesModalProps) {
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="17"
|
||||
defaultValue={endHours}
|
||||
onChange={(e) => {
|
||||
if (endHoursRef.current.value === "24") endMinsRef.current.value = "0";
|
||||
setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef);
|
||||
setEndMinuteDisable(endHoursRef.current.value === "24");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="endMinutes" className="sr-only">
|
||||
{t("minutes")}
|
||||
</label>
|
||||
<input
|
||||
ref={endMinsRef}
|
||||
type="number"
|
||||
min="0"
|
||||
min={minimumEndTime.minute}
|
||||
max="59"
|
||||
maxLength={2}
|
||||
step="15"
|
||||
step={STEP}
|
||||
name="minutes"
|
||||
id="endMinutes"
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="30"
|
||||
defaultValue={endMinutes}
|
||||
disabled={endMinuteDisable}
|
||||
onChange={() => setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
163
apps/web/components/webhook/WebhookDialogForm.tsx
Normal file
163
apps/web/components/webhook/WebhookDialogForm.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
|
||||
import customTemplate, { hasTemplateIntegration } from "@lib/webhooks/integrationTemplate";
|
||||
|
||||
import { DialogFooter } from "@components/Dialog";
|
||||
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
import { TWebhook } from "@components/webhook/WebhookListItem";
|
||||
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
|
||||
|
||||
export default function WebhookDialogForm(props: {
|
||||
eventTypeId?: number;
|
||||
defaultValues?: TWebhook;
|
||||
handleClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const handleSubscriberUrlChange = (e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
};
|
||||
const {
|
||||
defaultValues = {
|
||||
id: "",
|
||||
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
|
||||
subscriberUrl: "",
|
||||
active: true,
|
||||
payloadTemplate: null,
|
||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId">,
|
||||
} = props;
|
||||
|
||||
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
return (
|
||||
<Form
|
||||
data-testid="WebhookDialogForm"
|
||||
form={form}
|
||||
handleSubmit={async (event) => {
|
||||
const e = { ...event, eventTypeId: props.eventTypeId };
|
||||
if (!useCustomPayloadTemplate && event.payloadTemplate) {
|
||||
event.payloadTemplate = null;
|
||||
}
|
||||
if (event.id) {
|
||||
await utils.client.mutation("viewer.webhook.edit", e);
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
} else {
|
||||
await utils.client.mutation("viewer.webhook.create", e);
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
}
|
||||
props.handleClose();
|
||||
}}
|
||||
className="space-y-4">
|
||||
<input type="hidden" {...form.register("id")} />
|
||||
<fieldset className="space-y-2">
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="active"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue("active", isChecked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<TextField
|
||||
label={t("subscriber_url")}
|
||||
{...form.register("subscriberUrl")}
|
||||
required
|
||||
type="url"
|
||||
onChange={handleSubscriberUrlChange}
|
||||
/>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
|
||||
<Controller
|
||||
key={key}
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
label={t(key.toLowerCase())}
|
||||
defaultChecked={field.value.includes(key)}
|
||||
onCheckedChange={(isChecked) => {
|
||||
const value = field.value;
|
||||
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
|
||||
|
||||
form.setValue("eventTriggers", newValue, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
|
||||
<div className="space-x-3 text-sm rtl:space-x-reverse">
|
||||
<label>
|
||||
<input
|
||||
className="text-neutral-900 focus:ring-neutral-500"
|
||||
type="radio"
|
||||
name="useCustomPayloadTemplate"
|
||||
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
|
||||
defaultChecked={!useCustomPayloadTemplate}
|
||||
/>{" "}
|
||||
Default
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
className="text-neutral-900 focus:ring-neutral-500"
|
||||
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
|
||||
name="useCustomPayloadTemplate"
|
||||
type="radio"
|
||||
defaultChecked={useCustomPayloadTemplate}
|
||||
/>{" "}
|
||||
Custom
|
||||
</label>
|
||||
</div>
|
||||
{useCustomPayloadTemplate && (
|
||||
<TextArea
|
||||
{...form.register("payloadTemplate")}
|
||||
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
<WebhookTestDisclosure />
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
103
apps/web/components/webhook/WebhookListContainer.tsx
Normal file
103
apps/web/components/webhook/WebhookListContainer.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import classNames from "classnames";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
import Button from "@components/ui/Button";
|
||||
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
|
||||
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
|
||||
|
||||
export type WebhookListContainerType = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
eventTypeId?: number;
|
||||
};
|
||||
|
||||
export default function WebhookListContainer(props: WebhookListContainerType) {
|
||||
const { t } = useLocale();
|
||||
const query = props.eventTypeId
|
||||
? trpc.useQuery(["viewer.webhook.list", { eventTypeId: props.eventTypeId }], {
|
||||
suspense: true,
|
||||
})
|
||||
: trpc.useQuery(["viewer.webhook.list"], {
|
||||
suspense: true,
|
||||
});
|
||||
|
||||
const [newWebhookModal, setNewWebhookModal] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<TWebhook | null>(null);
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
<ShellSubHeading className="mt-10" title={props.title} subtitle={props.subtitle} />
|
||||
<List>
|
||||
<ListItem className={classNames("flex-col")}>
|
||||
<div
|
||||
className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
|
||||
<Image width={40} height={40} src="/integrations/webhooks.svg" alt="Webhooks" />
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3">Webhooks</ListItemTitle>
|
||||
<ListItemText component="p">{t("automation")}</ListItemText>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => setNewWebhookModal(true)}
|
||||
data-testid="new_webhook">
|
||||
{t("new_webhook")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{data.length ? (
|
||||
<List>
|
||||
{data.map((item) => (
|
||||
<WebhookListItem
|
||||
key={item.id}
|
||||
webhook={item}
|
||||
onEditWebhook={() => {
|
||||
setEditing(item);
|
||||
setEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
) : null}
|
||||
|
||||
{/* New webhook dialog */}
|
||||
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
|
||||
<DialogContent>
|
||||
<WebhookDialogForm
|
||||
eventTypeId={props.eventTypeId}
|
||||
handleClose={() => setNewWebhookModal(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit webhook dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<WebhookDialogForm
|
||||
key={editing.id}
|
||||
eventTypeId={props.eventTypeId || undefined}
|
||||
handleClose={() => setEditModalOpen(false)}
|
||||
defaultValues={editing}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
93
apps/web/components/webhook/WebhookListItem.tsx
Normal file
93
apps/web/components/webhook/WebhookListItem.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
||||
|
||||
export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ListItem className="-mt-px flex w-full p-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex max-w-full flex-col truncate">
|
||||
<div className="flex space-y-1">
|
||||
<span
|
||||
className={classNames(
|
||||
"truncate text-sm",
|
||||
props.webhook.active ? "text-neutral-700" : "text-neutral-200"
|
||||
)}>
|
||||
{props.webhook.subscriberUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex">
|
||||
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
|
||||
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
className={classNames(
|
||||
"w-max rounded-sm px-1 text-xs ",
|
||||
props.webhook.active ? "bg-blue-100 text-blue-700" : "bg-blue-50 text-blue-200"
|
||||
)}>
|
||||
{t(`${eventTrigger.toLowerCase()}`)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Tooltip content={t("edit_webhook")}>
|
||||
<Button
|
||||
onClick={() => props.onEditWebhook()}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={PencilAltIcon}
|
||||
className="ml-4 w-full self-center p-2"></Button>
|
||||
</Tooltip>
|
||||
<Dialog>
|
||||
<Tooltip content={t("delete_webhook")}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={TrashIcon}
|
||||
className="ml-2 w-full self-center p-2"></Button>
|
||||
</DialogTrigger>
|
||||
</Tooltip>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_webhook")}
|
||||
confirmBtnText={t("confirm_delete_webhook")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() =>
|
||||
deleteWebhook.mutate({
|
||||
id: props.webhook.id,
|
||||
eventTypeId: props.webhook.eventTypeId || undefined,
|
||||
})
|
||||
}>
|
||||
{t("delete_webhook_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
64
apps/web/components/webhook/WebhookTestDisclosure.tsx
Normal file
64
apps/web/components/webhook/WebhookTestDisclosure.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ChevronRightIcon, SwitchHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { InputGroupBox } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function WebhookTestDisclosure() {
|
||||
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
|
||||
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
|
||||
const { t } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
|
||||
onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
|
||||
<CollapsibleTrigger type="button" className={"flex w-full cursor-pointer"}>
|
||||
<ChevronRightIcon className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
|
||||
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<InputGroupBox className="space-y-0 border-0 px-0">
|
||||
<div className="flex justify-between bg-gray-50 p-2">
|
||||
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
|
||||
<Button
|
||||
StartIcon={SwitchHorizontalIcon}
|
||||
type="button"
|
||||
color="minimal"
|
||||
disabled={mutation.isLoading}
|
||||
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
|
||||
{t("ping_test")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-8 border-gray-50 p-2 text-gray-500">
|
||||
{!mutation.data && <em>{t("no_data_yet")}</em>}
|
||||
{mutation.status === "success" && (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"ml-auto w-max px-2 py-1 text-xs",
|
||||
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
|
||||
)}>
|
||||
{mutation.data.ok ? t("success") : t("failed")}
|
||||
</div>
|
||||
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</InputGroupBox>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,6 @@ export function ContractsProvider({ children }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<ContractsContext.Provider value={value}>{children}</ContractsContext.Provider>
|
||||
</>
|
||||
|
||||
@@ -13,9 +13,11 @@ const TrialBanner = () => {
|
||||
|
||||
if (!user || user.plan !== "TRIAL") return null;
|
||||
|
||||
const trialDaysLeft = dayjs(user.createdDate)
|
||||
.add(TRIAL_LIMIT_DAYS + 1, "day")
|
||||
.diff(dayjs(), "day");
|
||||
const trialDaysLeft = user.trialEndsAt
|
||||
? dayjs(user.trialEndsAt).add(1, "day").diff(dayjs(), "day")
|
||||
: dayjs(user.createdDate)
|
||||
.add(TRIAL_LIMIT_DAYS + 1, "day")
|
||||
.diff(dayjs(), "day");
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { stringify } from "querystring";
|
||||
import React, { SyntheticEvent, useEffect, useState } from "react";
|
||||
|
||||
import { PaymentData } from "@ee/lib/stripe/server";
|
||||
import { PaymentData } from "@calcom/stripe/server";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import Head from "next/head";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import getStripe from "@calcom/stripe/client";
|
||||
import PaymentComponent from "@ee/components/stripe/Payment";
|
||||
import getStripe from "@ee/lib/stripe/client";
|
||||
import { PaymentPageProps } from "@ee/pages/payment/[uid]";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrowserLocale24h } from "@lib/timeFormat";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(toArray);
|
||||
@@ -21,7 +22,7 @@ dayjs.extend(timezone);
|
||||
|
||||
const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const [is24h, setIs24h] = useState(false);
|
||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
||||
const [date, setDate] = useState(dayjs.utc(props.booking.startTime));
|
||||
const { isReady, Theme } = useTheme(props.profile.theme);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user